How To Write A REST Client In Rust

This article is a sample from Zero To Production In Rust, a book on backend development in Rust.
You can get a copy of the book on zero2prod.com.
Subscribe to the newsletter to be notified when a new episode is published.

TL;DR

We need to send a confirmation email to the new subscribers of our newsletter.
To pull it off we need to learn:

We'll deal with both the happy and the unhappy path (server errors and timeouts).

Chapter 7 - Part 0

  1. Confirmation Emails
  2. How To Send An Email
  3. How To Write A REST Client Using reqwest
  4. How To Test A REST Client
  5. First Sketch Of EmailClient::send_email
  6. Dealing With Failures
  7. Summary

Confirmation Emails

You can find the snapshot of the codebase at the beginning of this chapter on GitHub.

In the previous chapter we introduced validation for the email addresses of new subscribers - they must comply with the email format.
We now have emails that are syntactically valid but we are still uncertain about their existence: does anybody actually use those email addresses? Are they reachable?
We have no idea and there is only one way to find out: sending out an actual confirmation email.

Your spider-senses should be going off now - do we actually need to know at this stage of the subscriber lifetime? Can't we just wait for the next newsletter issue to find out if they receive our emails or not?

If performing thorough validation was our only concern, I'd concur: we should wait for the next issue to go out instead of adding more complexity to our POST /subscriptions endpoint.
There is one more thing we are concerned about though, which we cannot postpone: subscriber consent.

An email address is not a password - if you have been on the Internet long enough there is a high chance your email is not so difficult to come by.
Certain types of email addresses (e.g. professional emails) are outright public.

This opens up the possibility of abuse.
A malicious user could start subscribing an email address to all sort of newsletters across the internet, flooding the victim's inbox with junk.
A shady newsletter owner, instead, could start scraping email addresses from the web and adding them to its newsletter email list.

This is why a request to POST /subscriptions is not enough to say "This person wants to receive my newsletter content!".
If you are dealing with European citizens, it is a legal requirement to get explicit consent from the user.

This is why it has become common practice to send confirmation emails: after entering your details in the newsletter HTML form you will receive an email in your inbox asking you to confirm that you do indeed want to subscribe to that newsletter.
This works nicely for us - we shield our users from abuse and we get to confirm that the email addresses they provided actually exist before trying to send them a newsletter issue.

The Confirmation User Journey

Let's look at our confirmation flow from a user perspective.

They will receive an email with a confirmation link.
Once they click on it something happens and they are then redirected to a success page ("You are now a subscriber of our newsletter! Yay!"). From that point onwards, they will receive all newsletter issues in their inbox.

How will the backend work?
We will try to keep it as simple as we can - our version will not perform a redirect on confirmation, we will just return a 200 OK to the browser.

Every time a user wants to subscribe to our newsletter they fire a POST /subscriptions request. Our request handler will:

Once they click on the link, a browser tab will open up and a GET request will be fired to our GET /subscriptions/confirm endpoint. Our request handler will:

There are a few other possible designs (e.g. use a JWT instead of a unique token) and we have a few corner cases to handle (e.g. what happens if they click on the link twice? What happens if they try to subscribe twice?) - we will discuss both at the most appropriate time as we make progress with the implementation.

The Implementation Strategy

There is a lot to do here, so we will split the work in three conceptual chunks:

Let's get started!

How To Send An Email

How do you actually send an email?
How does it work?

You have to look into SMTP - the Simple Mail Transfer Protocol.
It has been around since the early days of the Internet - the first RFC dates back to 1982.

SMTP does for emails what HTTP does for web pages: it is an application-level protocol that ensures that different implementations of email servers and clients can understand each other and exchange messages.

Now, let's make things clear - we will not build our own private email server, it would take too long and we would not gain much from the effort. We will be leveraging a third-party service.
What do email delivery services expect these days? Do we need to talk SMTP to them?

Not necessarily.
SMTP is a specialised protocol: unless you have been working with emails before, it is unlikely you have direct experience with it. Learning a new protocol takes time and you are bound to make mistakes along the way - that is why most providers expose two interfaces: an SMTP and a REST API.
If you are familiar with the email protocol, or you need some non-conventional configuration, go ahead with the SMTP interface. Otherwise, most developers will get up and running much faster (and more reliably) using a REST API.

As you might have guessed, that is what we will be going for as well - we will write a REST client.

Choosing An Email API

There is no shortage of email API providers on the market and you are likely to know the names of the major ones - AWS SES, SendGrid, MailGun, Mailchimp, Postmark.

I was looking for a simple enough API (e.g. how easy is it to literally just send an email?), a smooth onboarding flow and a free plan that does not require entering your credit card details just to test the service out.
That is how I landed on Postmark.

To complete the next sections you will have to sign up to Postmark and, once you are logged into their portal, authorise a single sender email.

Create single sender address

Once you are done, we can move forward!

Disclaimer: Postmark is not paying me to promote their services here.

The Email Client Interface

There are usually two approaches when it comes to a new piece of functionality: you can do it bottom-up, starting from the implementation details and slowly working your way up, or you can do it top-down, by designing the interface first and then figuring out how the implementation is going to work (to an extent).
In this case, we will go for the second route.

What kind of interface do we want for our email client?
We'd like to have some kind of send_email method. At the moment we just need to send a single email out at a time - we will deal with the complexity of sending emails in batches when we start working on newsletter issues.
What arguments should send_email accept?

We'll definitely need the recipient email address, the subject line and the email content. We'll ask for both an HTML and a plain text version of the email content - some email clients are not able to render HTML and some users explicitly disable HTML emails. By sending both versions we err on the safe side.
What about the sender email address?
We'll assume that all emails sent by an instance of the client are coming from the same address - therefore we do not need it as an argument of send_email, it will be one of the arguments in the constructor of the client itself.

We also expect send_email to be an asynchronous function, given that we will be performing I/O to talk to a remote server.

Stitching everything together, we have something that looks more or less like this:

//! src/email_client.rs

use crate::domain::SubscriberEmail;

pub struct EmailClient;

impl EmailClient {
  pub async fn send_email(
    &self,
    recipient: SubscriberEmail,
    subject: &str,
    html_content: &str,
    text_content: &str
  ) -> Result<(), String> {
    todo!()
  }
}
//! src/lib.rs

pub mod configuration;
pub mod domain;
// New entry!
pub mod email_client;
pub mod routes;
pub mod startup;
pub mod telemetry;

There is an unresolved question - the return type. We sketched a Result<(), String> which is a way to spell "I'll think about error handling later".
Plenty of work left to do, but it is a start - we said we were going to start from the interface, not that we'd nail it down in one sitting!

How To Write A REST Client Using reqwest

To talk with a REST API we need an HTTP client.
There are a few different options in the Rust ecosystem: synchronous vs asynchronous, pure Rust vs bindings to an underlying native library, tied to tokio or async-std, opinionated vs highly customisable, etc.

We will go with the most popular option on crates.io: reqwest.

What to say about reqwest?

If you look closely, we are already using reqwest!
It is the HTTP client we used to fire off requests at our API in the integration tests. Let's lift it from a development dependency to a runtime dependency:

#! Cargo.toml
# [...]

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

[dev-dependencies]
# Remove `reqwest`'s entry from this list
# [...]

reqwest::Client

The main type you will be dealing with when working with reqwest is reqwest::Client - it exposes all the methods we need to perform requests against a REST API.

We can get a new client instance by invoking Client::new or we can go with Client::builder if we need to tune the default configuration.
We will stick to Client::new for the time being.

Let's add three fields to EmailClient:

//! src/email_client.rs

use crate::domain::SubscriberEmail;
use reqwest::Client;

pub struct EmailClient {
  http_client: Client,
  base_url: String,
  sender: SubscriberEmail
}

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

  // [...]
}

Connection Pooling

Before executing an HTTP request against an API hosted on a remote server we need to establish a connection.
It turns out that connecting is a fairly expensive operation, even more so if using HTTPS: creating a brand-new connection every time we need to fire off a request can impact the performance of our application and might lead to a problem known as socket exhaustion under load.

To mitigate the issue, most HTTP clients offer connection pooling: after the first request to a remote server has been completed, they will keep the connection open (for a certain amount of time) and re-use it if we need to fire off another request to the same server, therefore avoiding the need to re-establish a connection from scratch.

reqwest is no different - every time a Client instance is created reqwest initialises a connection pool under the hood.
To leverage this connection pool we need to reuse the same Client across multiple requests.
It is also worth pointing out that Client::clone does not create a new connection pool - we just clone a pointer to the underlying pool.

How To Reuse The Same reqwest::Client In actix-web

To re-use the same HTTP client across multiple requests in actix-web we need to store a copy of it in the application context - we will then be able to retrieve a reference to Client in our request handlers using an extractor (e.g. actix_web::web::Data).

How do we pull it off? Let's look at the code where we build a HttpServer:

//! src/startup.rs
// [...]

pub fn run(listener: TcpListener, db_pool: PgPool) -> Result<Server, std::io::Error> {
  let db_pool = Data::new(db_pool);
  let server = HttpServer::new(move || {
    App::new()
            .wrap(TracingLogger)
            .route("/health_check", web::get().to(health_check))
            .route("/subscriptions", web::post().to(subscribe))
            .app_data(db_pool.clone())
  })
          .listen(listener)?
          .run();
  Ok(server)
}

We have two options:

//! src/email_client.rs
// [...]
#[derive(Clone)]
pub struct EmailClient {
  http_client: Client,
  base_url: String,
  sender: SubscriberEmail
}

// [...]
//! src/startup.rs
use crate::email_client::EmailClient;
// [...]

pub fn run(
  listener: TcpListener,
  db_pool: PgPool,
  email_client: EmailClient,
) -> Result<Server, std::io::Error> {
  let db_pool = Data::new(db_pool);
  let server = HttpServer::new(move || {
    App::new()
            .wrap(TracingLogger)
            .route("/health_check", web::get().to(health_check))
            .route("/subscriptions", web::post().to(subscribe))
            .app_data(db_pool.clone())
            .app_data(email_client.clone())
  })
          .listen(listener)?
          .run();
  Ok(server)
}
//! src/startup.rs
use crate::email_client::EmailClient;
// [...]

pub fn run(
  listener: TcpListener,
  db_pool: PgPool,
  email_client: EmailClient,
) -> Result<Server, std::io::Error> {
  let db_pool = Data::new(db_pool);
  let email_client = Data::new(email_client);
  let server = HttpServer::new(move || {
    App::new()
            .wrap(TracingLogger)
            .route("/health_check", web::get().to(health_check))
            .route("/subscriptions", web::post().to(subscribe))
            .app_data(db_pool.clone())
            .app_data(email_client.clone())
  })
          .listen(listener)?
          .run();
  Ok(server)
}

Which way is best?
If EmailClient were just a wrapper around a Client instance, the first option would be preferable - we avoid wrapping the connection pool twice with Arc.
This is not the case though: EmailClient has two data fields attached (base_url and sender). The first implementation allocates new memory to hold a copy of that data every time an App instance is created, while the second shares it among all App instances.
That's why we will be using the second strategy.

Beware though: we are creating an App instance for each thread - the cost of a string allocation (or a pointer clone) is negligible when looking at the bigger picture.
We are going through the decision-making process here as an exercise to understand the tradeoffs - you might have to make a similar call in the future where the cost of the two options is remarkably different.

Configuring Our EmailClient

If you run cargo check, you will get an error:

error[E0061]: this function takes 3 arguments but 2 arguments were supplied
  --> src/main.rs:24:5
   |
24 |     run(listener, connection_pool)?.await?;
   |     ^^^ --------  --------------- supplied 2 arguments
   |     |
   |     expected 3 arguments

error: aborting due to previous error

Let's fix it!
What do we have in main right now?

//! src/main.rs
// [...]

#[actix_web::main]
async fn main() -> std::io::Result<()> {
  // [...]
  let configuration = get_configuration().expect("Failed to read configuration.");
  let connection_pool = PgPoolOptions::new()
          .connect_timeout(std::time::Duration::from_secs(2))
          .connect_with(configuration.database.with_db())
          .await
          .expect("Failed to connect to Postgres.");

  let address = format!(
    "{}:{}",
    configuration.application.host, configuration.application.port
  );
  let listener = TcpListener::bind(address)?;
  run(listener, connection_pool)?.await?;
  Ok(())
}

We are building the dependencies of our application using the values specified in the configuration we retrieved via get_configuration.
To build an EmailClient instance we need the base URL of the API we want to fire requests to and the sender email address - let's add them to our Settings struct:

//! src/configuration.rs
// [...]
use crate::domain::SubscriberEmail;

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

#[derive(serde::Deserialize)]
pub struct EmailClientSettings {
  pub base_url: String,
  pub sender_email: String,
}

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

// [...]

We then need to set values for them in our configuration files:

#! configuration/base.yaml

application:
# [...]
database:
# [...]
email_client:
  base_url: "localhost"
  sender_email: "test@gmail.com"
#! configuration/production.yaml
application:
# [...]
database:
# [...]
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"

We can now build an EmailClient instance in main and pass it to the run function:

//! src/main.rs
// [...]
use zero2prod::email_client::EmailClient;

#[actix_web::main]
async fn main() -> std::io::Result<()> {
  // [...]
  let configuration = get_configuration().expect("Failed to read configuration.");
  let connection_pool = PgPoolOptions::new()
          .connect_timeout(std::time::Duration::from_secs(2))
          .connect_with(configuration.database.with_db())
          .await
          .expect("Failed to connect to Postgres.");

  // 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
  );

  let address = format!(
    "{}:{}",
    configuration.application.host, configuration.application.port
  );
  let listener = TcpListener::bind(address)?;
  // New argument for `run`, `email_client`
  run(listener, connection_pool, email_client)?.await?;
  Ok(())
}

cargo check should now pass, although there are a few warnings about unused variables - we will get to those soon enough.
What about our tests?

cargo check --all-targets returns a similar error to the one we were seeing before with cargo check:

error[E0061]: this function takes 3 arguments but 2 arguments were supplied
  --> tests/health_check.rs:36:18
   |
36 |     let server = run(listener, connection_pool.clone())
   |                  ^^^ --------  ----------------------- supplied 2 arguments
   |                  |
   |                  expected 3 arguments

error: aborting due to previous error

You are right - it is a symptom of code duplication. We will get to refactor the initialisation logic of our integration tests, but not yet.
Let's patch it quickly to make it compile:

//! tests/health_check.rs

// [...]
use zero2prod::email_client::EmailClient;
// [...]

async fn spawn_app() -> TestApp {
  // [...]

  let mut configuration = get_configuration()
          .expect("Failed to read configuration.");
  configuration.database.database_name = Uuid::new_v4().to_string();
  let connection_pool = configure_database(&configuration.database).await;

  // Build a new email client
  let sender_email = configuration.email_client.sender()
          .expect("Invalid sender email address.");
  let email_client = EmailClient::new(
    configuration.email_client.base_url,
    sender_email
  );

  // Pass the new client to `run`!
  let server = run(listener, connection_pool.clone(), email_client)
          .expect("Failed to bind address");
  let _ = tokio::spawn(server);
  TestApp {
    address,
    db_pool: connection_pool,
  }
}

// [...]

cargo test should succeed now.

How To Test A REST Client

We have gone through most of the setup steps: we sketched an interface for EmailClient and we wired it up with the application, using a new configuration type - EmailClientSettings.
To stay true to our test-driven development approach, it is now time to write a test!
We could start from our integration tests: change the ones for POST /subscriptions to make sure that the endpoint conforms to our new requirements.
It would take us a long time to turn them green though: apart from sending an email, we need to add logic to generate a unique token and store it.

Let's start smaller: we will just test our EmailClient component in isolation.
It will boost our confidence that it behaves as expected when tested as a unit, reducing the number of issues we might encounter when integrating it into the larger confirmation email flow.
It will also give us a chance to see if the interface we landed on is ergonomic and easy to test.

What should we actually test though?
The main purpose of our EmailClient::send_email is to perform an HTTP call: how do we know if it happened? How do we check that the body and the headers were populated as we expected?
We need to intercept that HTTP request - time to spin up a mock server!

HTTP Mocking With wiremock

Let's add a new module for tests at the bottom of src/email_client.rs with the skeleton of a new test:

//! src/email_client.rs
// [...]

#[cfg(test)]
mod tests {
  #[tokio::test]
  async fn send_email_fires_a_request_to_base_url() {
    todo!()
  }
}

We do not know enough about Postmark to make assertions about what we should see in the outgoing HTTP request.
Nonetheless, as the test name says, it is reasonable to expect a request to be fired to the server at EmailClient::base_url!

Let's add wiremock to our development dependencies:

#! Cargo.toml
# [...]

[dev-dependencies]
# [...]
wiremock = "0.4.9"

Using wiremock, we can write send_email_fires_a_request_to_base_url as follows:

//! src/email_client.rs
// [...]

#[cfg(test)]
mod tests {
  use crate::domain::SubscriberEmail;
  use crate::email_client::EmailClient;
  use fake::faker::internet::en::SafeEmail;
  use fake::faker::lorem::en::{Paragraph, Sentence};
  use fake::{Fake, Faker};
  use wiremock::matchers::any;
  use wiremock::{Mock, MockServer, ResponseTemplate};

  #[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);

    Mock::given(any())
            .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
  }
}

Let's break down what is happening, step by step.

wiremock::MockServer

let mock_server = MockServer::start().await;

wiremock::MockServer is a full-blown HTTP server.
MockServer::start asks the operating system for a random available port and spins up the server on a background thread, ready to listen for incoming requests.

How do we point our email client to our mock server? We can retrieve the address of the mock server using the MockServer::uri method; we can then pass it as base_url to EmailClient::new:

let email_client = EmailClient::new(mock_server.uri(), sender);

wiremock::Mock

Out of the box, wiremock::MockServer returns 404 Not Found to all incoming requests.
We can instruct the mock server to behave differently by mounting a Mock.

Mock::given(any())
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount( & mock_server)
.await;

When wiremock::MockServer receives a request, it iterates over all the mounted mocks to check if the request matches their conditions.
The matching conditions for a mock are specified using Mock::given.

We are passing any() to Mock::Given which, according to wiremock's documentation,

Match all incoming requests, regardless of their method, path, headers or body. You can use it to verify that a request has been fired towards the server, without making any other assertion about it.

Basically, it always matches, regardless of the request - which is what we want here!

When an incoming request matches the conditions of a mounted mock, wiremock::MockServer returns a response following what was specified in respond_with.
We passed ResponseTemplate::new(200) - a 200 OK response without a body.

A wiremock::Mock becomes effective only after it has been mounted on a wiremock::Mockserver - that's what our call to Mock::mount is about.

The Intent Of A Test Should Be Clear

We then have the actual invocation of EmailClient::send_email:

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;

You'll notice that we are leaning heavily on fake here: we are generating random data for all the inputs to send_email (and sender, in the previous section).
We could have just hard-coded a bunch of values, why did we choose to go all the way and make them random?

A reader, skimming the test code, should be able to identify easily the property that we are trying to test.
Using random data conveys a specific message: do not pay attention to these inputs, their values do not influence the outcome of the test, that's why they are random!

Hard-coded values, instead, should always give you pause: does it matter that subscriber_email is set to marco@gmail.com? Should the test pass if I set it to another value?
In a test like ours, the answer is obvious. In a more intricate setup, it often isn't.

Mock expectations

The end of the test looks a bit cryptic: there is an // Assert comment... but no assertion afterwards.
Let's go back to our Mock setup line:

Mock::given(any())
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount( & mock_server)
.await;

What does .expect(1) do?
It sets an expectation on our mock: we are telling the mock server that during this test it should receive exactly one request that matches the conditions set by this mock.
We could also use ranges for our expectations - e.g. expect(1..) if we want to see at least one request, expect(1..=3) if we expect at least one request but no more than three, etc.

Expectations are verified when MockServer goes out of scope - at the end of our test function, indeed!
Before shutting down, MockServer will iterate over all the mounted mocks and check if their expectations have been verified. If the verification step fails, it will trigger a panic (and fail the test).

Let's run cargo test:

---- email_client::tests::send_email_fires_a_request_to_base_url stdout ----
thread 'email_client::tests::send_email_fires_a_request_to_base_url' panicked at 
'not yet implemented', src/email_client.rs:24:9

Ok, we are not even getting to the end of the test yet because we have a placeholder todo!() as the body of send_email.
Let's replace it with a dummy Ok:

//! src/email_client.rs
// [...]

impl EmailClient {
  // [...]

  pub async fn send_email(
    &self,
    recipient: SubscriberEmail,
    subject: &str,
    html_content: &str,
    text_content: &str
  ) -> Result<(), String> {
    // No matter the input
    Ok(())
  }
}

// [...]

If we run cargo test again, we'll get to see wiremock in action:

---- email_client::tests::send_email_fires_a_request_to_base_url stdout ----
thread 'email_client::tests::send_email_fires_a_request_to_base_url' panicked at 
'Verifications failed:
- Mock #0.
        Expected range of matching incoming requests: == 1
        Number of matched incoming requests: 0
'

The server expected one request, but it received none - therefore the test failed.

The time has come to properly flesh out EmailClient::send_email.

First Sketch Of EmailClient::send_email

To implement EmailClient::send_email we need to check out the API documentation of Postmark. Let's start from their "Send a single email" user guide.

Their email sending example looks like this:

curl "https://api.postmarkapp.com/email" \
  -X POST \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -H "X-Postmark-Server-Token: server token" \
  -d '{
  "From": "sender@example.com",
  "To": "receiver@example.com",
  "Subject": "Postmark test",
  "TextBody": "Hello dear Postmark user.",
  "HtmlBody": "<html><body><strong>Hello</strong> dear Postmark user.</body></html>"
}'

Let's break it down - to send an email we need:

If the request succeeds, we get something like this back:

HTTP/1.1 200 OK
Content-Type: application/json

{
	"To": "receiver@example.com",
	"SubmittedAt": "2021-01-12T07:25:01.4178645-05:00",
	"MessageID": "0a129aee-e1cd-480d-b08d-4f48548ff48d",
	"ErrorCode": 0,
	"Message": "OK"
}

We have enough to implement the happy path!

reqwest::Client::post

reqwest::Client exposes a post method - it takes the URL we want to call with a POST request as argument and it returns a RequestBuilder.
RequestBuilder gives us a fluent API to build out the rest of the request we want to send, piece by piece.
Let's give it a go:

//! src/email_client.rs
// [...]

impl EmailClient {
  // [...]

  pub async fn send_email(
    &self,
    recipient: SubscriberEmail,
    subject: &str,
    html_content: &str,
    text_content: &str
  ) -> Result<(), String> {
    // You can do better using `reqwest::Url::join` if you change 
    // `base_url`'s type from `String` to `reqwest::Url`.
    // I'll leave it as an exercise for the reader!
    let url = format!("{}/email", self.base_url);
    let builder = self.http_client.post(&url);
    Ok(())
  }
}

// [...]

JSON body

We can encode the request body schema as a struct:

//! src/email_client.rs
// [...]

impl EmailClient {
  // [...]

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

struct SendEmailRequest {
  from: String,
  to: String,
  subject: String,
  html_body: String,
  text_body: String,
}

// [...]

If the json feature flag for reqwest is enabled (as we did), builder will expose a json method that we can leverage to set request_body as the JSON body of the request:

//! src/email_client.rs
// [...]

impl EmailClient {
// [...]

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

It almost works:

error[E0277]: the trait bound `SendEmailRequest: Serialize` is not satisfied
  --> src/email_client.rs:34:56
   |
34 |         let builder = self.http_client.post(&url).json(&request_body);
   |                                                        ^^^^^^^^^^^^^ 
          the trait `Serialize` is not implemented for `SendEmailRequest`

Let's derive serde::Serialize for SendEmailRequest to make it serializable:

//! src/email_client.rs
// [...]

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

Awesome, it compiles!
The json method goes a bit further than simple serialization: it will also set the Content-Type header to application/json - matching what we saw in the example!

Authorization Token

We are almost there - we need to add an authorization header, X-Postmark-Server-Token, to the request.
Just like the sender email address, we want to store the token value as a field in EmailClient.

Let's amend EmailClient::new and EmailClientSettings:

//! src/email_client.rs

use crate::domain::SubscriberEmail;
use reqwest::Client;

pub struct EmailClient {
  http_client: Client,
  base_url: String,
  sender: SubscriberEmail,
  authorization_token: String
}

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

  // [...]
}
//! src/configuration.rs
// [...]

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

// [...]

We can then let the compiler tell us what else needs to be modified:

//! src/email_client.rs
// [...]

#[cfg(test)]
mod tests {
  // [...]

  #[tokio::test]
  async fn send_email_fires_a_request_to_base_url() {
    let mock_server = MockServer::start().await;
    let sender = SubscriberEmail::parse(SafeEmail().fake()).unwrap();
    // New argument!
    let email_client = EmailClient::new(mock_server.uri(), sender, Faker.fake());

    // [...]
  }
}
//! src/main.rs
// [...]

#[actix_web::main]
async fn main() -> std::io::Result<()> {
  // [...]
  let email_client = EmailClient::new(
    configuration.email_client.base_url,
    sender_email,
    // Pass argument from configuration
    configuration.email_client.authorization_token,
  );
  // [...]
}
//! tests/health_check.rs
// [...]

async fn spawn_app() -> TestApp {
  // [...]
  let email_client = EmailClient::new(
    configuration.email_client.base_url,
    sender_email,
    // Pass argument from configuration
    configuration.email_client.authorization_token,
  );
  // [...]
}
// [...]
#! configuration/base.yml
# [...]
email_client:
  base_url: "localhost"
  sender_email: "test@gmail.com"
  # New value! 
  # We are only setting the development value,
  # we'll deal with the production token outside of version control
  # (given that it's a sensitive secret!)
  authorization_token: "my-secret-token"

We can now use the authorization token in send_email:

//! src/email_client.rs
// [...]

impl EmailClient {
// [...]

  pub async fn send_email(
    // [...]
  ) -> Result<(), String> {
    // [...]
    let builder = self
            .http_client
            .post(&url)
            .header("X-Postmark-Server-Token", &self.authorization_token)
            .json(&request_body);
    Ok(())
  }
}

It compiles straight away.

Executing The Request

We have all the ingredients - we just need to fire the request now!
We can use the send method:

//! src/email_client.rs
// [...]

impl EmailClient {
// [...]

  pub async fn send_email(
    // [...]
  ) -> Result<(), String> {
    // [...]
    self
            .http_client
            .post(&url)
            .header("X-Postmark-Server-Token", &self.authorization_token)
            .json(&request_body)
            .send()
            .await?;
    Ok(())
  }
}

send is asynchronous, therefore we need to await the future it returns.
send is also a fallible operation - e.g. we might fail to establish a connection to the server. We'd like to return an error if send fails - that's why we use the ? operator.

The compiler, though, is not happy:

error[E0277]: `?` couldn't convert the error to `std::string::String`
--> src/email_client.rs:41:19
   |
41 |             .await?;
   |                   ^ 
    the trait `From<reqwest::Error>` is not implemented for `std::string::String`

The error variant returned by send is of type reqwest::Error, while our send_email uses String as error type. The compiler has looked for a conversion (an implementation of the From trait), but it could not find any - therefore it errors out.
If you recall, we used String as error variant mostly as a placeholder - let's change send_email's signature to return Result<(), reqwest::Error>.

//! src/email_client.rs
// [...]

impl EmailClient {
// [...]

  pub async fn send_email(
    // [...]
  ) -> Result<(), reqwest::Error> {
    // [...]
  }
}

The error should be gone now!
cargo test should pass too: congrats!

Tightening Our Happy Path Test

Let's look again at our "happy path" test:

//! src/email_client.rs
// [...]

#[cfg(test)]
mod tests {
  use crate::domain::SubscriberEmail;
  use crate::email_client::EmailClient;
  use fake::faker::internet::en::SafeEmail;
  use fake::faker::lorem::en::{Paragraph, Sentence};
  use fake::{Fake, Faker};
  use wiremock::matchers::any;
  use wiremock::{Mock, MockServer, ResponseTemplate};

  #[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, Faker.fake());

    let subscriber_email = SubscriberEmail::parse(SafeEmail().fake()).unwrap();
    let subject: String = Sentence(1..2).fake();
    let content: String = Paragraph(1..10).fake();

    Mock::given(any())
            .respond_with(ResponseTemplate::new(200))
            .expect(1)
            .mount(&mock_server)
            .await;

    // Act
    let _ = email_client
            .send_email(subscriber_email, &subject, &content, &content)
            .await;

    // Assert
    // Mock expectations are checked on drop
  }
}

To ease ourselves into the world of wiremock we started with something very basic - we are just asserting that the mock server gets called once. Let's beef it up to check that the outgoing request looks indeed like we expect it to.

Headers, Path And Method

any is not the only matcher offered by wiremock out of the box: there are handful available in wiremock's matchers module.
We can use header_exists to verify that the X-Postmark-Server-Token is set on the request to the server:

//! src/email_client.rs
// [...]

#[cfg(test)]
mod tests {
  // [...]
  // We removed `any` from the import list
  use wiremock::matchers::header_exists;

  #[tokio::test]
  async fn send_email_fires_a_request_to_base_url() {
    // [...]

    Mock::given(header_exists("X-Postmark-Server-Token"))
            .respond_with(ResponseTemplate::new(200))
            .expect(1)
            .mount(&mock_server)
            .await;

    // [...]
  }
}

We can chain multiple matchers together using the and method.
Let's add header to check that the Content-Type is set to the correct value, path to assert on the endpoint being called and method to verify the HTTP verb:

//! src/email_client.rs
// [...]

#[cfg(test)]
mod tests {
  // [...]
  use wiremock::matchers::{header, header_exists, path, method};

  #[tokio::test]
  async fn send_email_fires_a_request_to_base_url() {
    // [...]

    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;

    // [...]
  }
}

Body

So far, so good: cargo test still passes.
What about the request body?

We could use body_json to match exactly the request body.
We probably do not need to go as far as that - it would be enough to check that the body is valid JSON and it contains the set of field names shown in Postmark's example.

There is no out-of-the-box matcher that suits our needs - we need to implement our own!
wiremock exposes a Match trait - everything that implements it can be used as a matcher in given and and.
Let's stub it out:

//! src/email_client.rs
// [...]

#[cfg(test)]
mod tests { 
  use wiremock::Request;
  // [...]

  struct SendEmailBodyMatcher;

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

  // [...]
}

We get the incoming request as input, request, and we need to return a boolean value as output: true, if the mock matched, false otherwise.
We need to deserialize the request body as JSON - let's add serde-json to the list of our development dependencies:

#! Cargo.toml
# [...]

[dev-dependencies]
# [...]
serde_json = "1"

We can now write matches' implementation:

//! src/email_client.rs
// [...]

#[cfg(test)]
mod tests {
  // [...]

  struct SendEmailBodyMatcher;

  impl wiremock::Match for SendEmailBodyMatcher {
    fn matches(&self, request: &Request) -> bool {
      // Try to parse the body as a JSON value
      let result: Result<serde_json::Value, _> =
              serde_json::from_slice(&request.body);
      if let Ok(body) = result {
        // Check that all the mandatory fields are populated
        // without inspecting the field values
        body.get("From").is_some()
                && body.get("To").is_some()
                && body.get("Subject").is_some()
                && body.get("HtmlBody").is_some()
                && body.get("TextBody").is_some()
      } else {
        // If parsing failed, do not match the request
        false
      }
    }
  }

  #[tokio::test]
  async fn send_email_fires_a_request_to_base_url() {
    // [...]

    Mock::given(header_exists("X-Postmark-Server-Token"))
            .and(header("Content-Type", "application/json"))
            .and(path("/email"))
            .and(method("POST"))
            // Use our custom matcher!
            .and(SendEmailBodyMatcher)
            .respond_with(ResponseTemplate::new(200))
            .expect(1)
            .mount(&mock_server)
            .await;

    // [...]
  }
}

It compiles!
But our tests are failing now...

---- email_client::tests::send_email_fires_a_request_to_base_url stdout ----
thread 'email_client::tests::send_email_fires_a_request_to_base_url' panicked at 
'Verifications failed:
- Mock #0.
        Expected range of matching incoming requests: == 1
        Number of matched incoming requests: 0
'

Why is that?
Let's add a dbg! statement to our matcher to inspect the incoming request:

//! src/email_client.rs
// [...]

#[cfg(test)]
mod tests {
  // [...]

  impl wiremock::Match for SendEmailBodyMatcher {
    fn matches(&self, request: &Request) -> bool {
      // [...]
      if let Ok(body) = result {
        dbg!(&body);
        // [...]
      } else {
        false
      }
    }
  }
  // [...]
}

If you run the test again with cargo test send_email you will get something that looks like this:

--- email_client::tests::send_email_fires_a_request_to_base_url stdout ----
[src/email_client.rs:71] &body = Object({
    "from": String("[...]"),
    "to": String("[...]"),
    "subject": String("[...]"),
    "html_body": String("[...]"),
    "text_body": String("[...]"),
})
thread 'email_client::tests::send_email_fires_a_request_to_base_url' panicked at '
Verifications failed:
- Mock #0.
        Expected range of matching incoming requests: == 1
        Number of matched incoming requests: 0
'

It seems we forgot about the casing requirement - field names must be pascal cased!
We can fix it easily by adding an annotation on SendEmailRequest:

//! src/email_client.rs
// [...]

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

The test should pass now.
Before we move on, let's rename the test to send_email_sends_the_expected_request - it captures better the test purpose at this point.

Refactoring: Avoid Unnecessary Memory Allocations

We focused on getting send_email to work - now we can look at it again to see if there is any room for improvement.
Let's zoom on the request body:

//! src/email_client.rs
// [...]

impl EmailClient {
// [...]

  pub async fn send_email(
    // [...]
  ) -> Result<(), reqwest::Error> {
    // [...]
    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(),
    };
    // [...]
  }
}

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

For each field we are allocating a bunch of new memory to store a cloned String - it is wasteful. It would be more efficient to reference the existing data without performing any additional allocation.
We can pull it off by restructuring SendEmailRequest: instead of String we have to use a string slice (&str) as type for all fields.
A string slice is a just pointer to a memory buffer owned by somebody else. To store a reference in a struct we need to add a lifetime parameter: it keeps track of how long those references are valid for - it's the compiler's job to make sure that references do not stay around longer than the memory buffer they point to!

Let's do it!

//! src/email_client.rs
// [...]

impl EmailClient {
// [...]

  pub async fn send_email(
    // [...]
  ) -> Result<(), reqwest::Error> {
    // [...]
    // No more `.to_owned`!
    let request_body = SendEmailRequest {
      from: self.sender.as_ref(),
      to: recipient.as_ref(),
      subject,
      html_body: html_content,
      text_body: text_content,
    };
    // [...]
  }
}

#[derive(serde::Serialize)]
#[serde(rename_all = "PascalCase")]
// Lifetime parameters always start with an apostrophe, `'`
struct SendEmailRequest<'a> {
  from: &'a str,
  to: &'a str,
  subject: &'a str,
  html_body: &'a str,
  text_body: &'a str,
}

That's it, quick and painless - serde does all the heavy lifting for us and we are left with more performant code!

Dealing With Failures

We have a good grip on the happy path - what happens instead if things don't go as expected?
We will look at two scenarios:

Error Status Codes

Our current happy path test is only making assertions on the side-effect performed by send_email - we are not actually inspecting the value it returns!
Let's make sure that it is an Ok(()) if the server returns a 200 OK:

//! src/email_client.rs
// [...]

#[cfg(test)]
mod tests {
  // [...]
  use wiremock::matchers::any;
  use claim::assert_ok;
  // [...]
   
  // New happy-path test!
  #[tokio::test]
  async fn send_email_succeeds_if_the_server_returns_200() {
    // Arrange
    let mock_server = MockServer::start().await;
    let sender = SubscriberEmail::parse(SafeEmail().fake()).unwrap();
    let email_client = EmailClient::new(mock_server.uri(), sender, Faker.fake());

    let subscriber_email = SubscriberEmail::parse(SafeEmail().fake()).unwrap();
    let subject: String = Sentence(1..2).fake();
    let content: String = Paragraph(1..10).fake();

    // We do not copy in all the matchers we have in the other test.
    // The purpose of this test is not to assert on the request we 
    // are sending out! 
    // We add the bare minimum needed to trigger the path we want
    // to test in `send_email`.
    Mock::given(any())
            .respond_with(ResponseTemplate::new(200))
            .expect(1)
            .mount(&mock_server)
            .await;

    // Act
    let outcome = email_client
            .send_email(subscriber_email, &subject, &content, &content)
            .await;

    // Assert
    assert_ok!(outcome);
  }
}

No surprises, the test passes.
Let's look at the opposite case now - we expect an Err variant if the server returns a 500 Internal Server Error.

//! src/email_client.rs
// [...]

#[cfg(test)]
mod tests {
  // [...]
  use claim::assert_err;
  // [...]

  #[tokio::test]
  async fn send_email_fails_if_the_server_returns_500() {
    // Arrange
    let mock_server = MockServer::start().await;
    let sender = SubscriberEmail::parse(SafeEmail().fake()).unwrap();
    let email_client = EmailClient::new(mock_server.uri(), sender, Faker.fake());

    let subscriber_email = SubscriberEmail::parse(SafeEmail().fake()).unwrap();
    let subject: String = Sentence(1..2).fake();
    let content: String = Paragraph(1..10).fake();

    Mock::given(any())
            // Not a 200 anymore!
            .respond_with(ResponseTemplate::new(500))
            .expect(1)
            .mount(&mock_server)
            .await;

    // Act
    let outcome = email_client
            .send_email(subscriber_email, &subject, &content, &content)
            .await;

    // Assert
    assert_err!(outcome);
  }
}

We got some work to do here instead:

--- email_client::tests::send_email_fails_if_the_server_returns_500 stdout ----
thread 'email_client::tests::send_email_fails_if_the_server_returns_500' panicked at 
'assertion failed, expected Err(..), got Ok(())', src/email_client.rs:163:9

Let's look again at send_email:

//! src/email_client.rs
// [...]

impl EmailClient {
  //[...]
  pub async fn send_email(
    //[...]
  ) -> Result<(), reqwest::Error> {
    //[...]
    self.http_client
            .post(&url)
            .header("X-Postmark-Server-Token", &self.authorization_token)
            .json(&request_body)
            .send()
            .await?;
    Ok(())
  }
}
//[...]

The only step that might return an error is send - let's check reqwest's docs!

This method fails if there was an error while sending request, redirect loop was detected or redirect limit was exhausted.

Basically, send returns Ok as long as it gets a valid response from the server - no matter the status code!
To get the behaviour we want we need to look at the methods available on reqwest::Response - in particular, error_for_status:

Turn a response into an error if the server returned an error.

It seems to suit our needs, let's try it out.

//! src/email_client.rs
// [...]

impl EmailClient {
  //[...]
  pub async fn send_email(
    //[...]
  ) -> Result<(), reqwest::Error> {
    //[...]
    self.http_client
            .post(&url)
            .header("X-Postmark-Server-Token", &self.authorization_token)
            .json(&request_body)
            .send()
            .await?
            .error_for_status()?;
    Ok(())
  }
}
//[...]

Awesome, the test passes!

Timeouts

What happens instead if the server returns a 200 OK, but it takes ages to send it back?
We can instruct our mock server to wait a configurable amount of time before sending a response back.
Let's experiment a little with a new integration test - what if the server takes 3 minutes to respond!?

//! src/email_client.rs
// [...]

#[cfg(test)]
mod tests {
  // [...]
   
  #[tokio::test]
  async fn send_email_times_out_if_the_server_takes_too_long() {
    // Arrange
    let mock_server = MockServer::start().await;
    let sender = SubscriberEmail::parse(SafeEmail().fake()).unwrap();
    let email_client = EmailClient::new(mock_server.uri(), sender, Faker.fake());

    let subscriber_email = SubscriberEmail::parse(SafeEmail().fake()).unwrap();
    let subject: String = Sentence(1..2).fake();
    let content: String = Paragraph(1..10).fake();

    let response = ResponseTemplate::new(200)
            // 3 minutes!
            .set_delay(std::time::Duration::from_secs(180));
    Mock::given(any())
            .respond_with(response)
            .expect(1)
            .mount(&mock_server)
            .await;

    // Act
    let outcome = email_client
            .send_email(subscriber_email, &subject, &content, &content)
            .await;

    // Assert
    assert_err!(outcome);
  }
}

After a while, you should see something like this:

test email_client::tests::send_email_times_out_if_the_server_takes_too_long ... 
test email_client::tests::send_email_times_out_if_the_server_takes_too_long 
has been running for over 60 seconds

This is far from ideal: if the server starts misbehaving we might start to accumulate several "hanging" requests.
We are not hanging up on the server, so the connection is busy: every time we need to send an email we will have to open a new connection. If the server does not recover fast enough, and we do not close any of the open connections, we might end up with socket exhaustion/performance degradation.

As a rule of thumb: every time you are performing an IO operation, always set a timeout!
If the server takes longer than the timeout to respond, we should fail and return an error.

Choosing the right timeout value is often more an art than a science, especially if retries are involved: set it too low and you might overwhelm the server with retried requests; set it too high and you risk again to see degradation on the client side.
Nonetheless, better to have a conservative timeout threshold than to have none.

reqwest gives us two options: we can either add a default timeout on the Client itself, which applies to all outgoing requests, or we can specify a per-request timeout.
Let's go for a Client-wide timeout: we'll set it in EmailClient::new.

//! src/email_client.rs
// [...]
impl EmailClient {
  pub fn new(
    // [...]
  ) -> Self {
    let http_client = Client::builder()
            .timeout(std::time::Duration::from_secs(10))
            .build()
            .unwrap();
    Self {
      http_client,
      base_url,
      sender,
      authorization_token,
    }
  }
}
// [...]

If we run the test again, it should pass (after 10 seconds have elapsed).

Refactoring: Test Helpers

There is a lot of duplicated code in our four tests for EmailClient - let's extract the common bits in a set of test helpers.

//! src/email_client.rs
// [...]

#[cfg(test)]
mod tests {
  // [...]

  /// Generate a random email subject
  fn subject() -> String {
    Sentence(1..2).fake()
  }

  /// Generate a random email content
  fn content() -> String {
    Paragraph(1..10).fake()
  }

  /// Generate a random subscriber email
  fn email() -> SubscriberEmail {
    SubscriberEmail::parse(SafeEmail().fake()).unwrap()
  }

  /// Get a test instance of `EmailClient`.
  fn email_client(base_url: String) -> EmailClient {
    EmailClient::new(base_url, email(), Faker.fake())
  }

  // [...]
}

Let's use them in send_email_sends_the_expected_request:

//! src/email_client.rs
// [...]

#[cfg(test)]
mod tests {
  // [...]

  #[tokio::test]
  async fn send_email_sends_the_expected_request() {
    // Arrange
    let mock_server = MockServer::start().await;
    let email_client = email_client(mock_server.uri());

    Mock::given(header_exists("X-Postmark-Server-Token"))
            .and(header("Content-Type", "application/json"))
            .and(path("/email"))
            .and(method("POST"))
            .and(SendEmailBodyMatcher)
            .respond_with(ResponseTemplate::new(200))
            .expect(1)
            .mount(&mock_server)
            .await;

    // Act
    let _ = email_client
            .send_email(email(), &subject(), &content(), &content())
            .await;

    // Assert
  }
}

Way less visual noise - the intent of the test is front and center.
Go ahead and refactor the other three!

Summary

It took us a bit of work, but we now have a pretty decent REST client for Postmark's API!
The REST client was the first ingredient of our confirmation email flow: in the next instalment we will focus on generating a unique confirmation link which we will then pass within the body of the outgoing email.

As always, all the code we wrote in this chapter can be found on GitHub.

See you next time!


Zero To Production In Rust is a hands-on introduction to backend development in Rust.
Subscribe to the newsletter to be notified when a new episode is published.


Book - Table Of Contents

Click to expand!

The Table of Contents is provisional and might change over time. The draft below is the most accurate picture at this point in time.

  1. Getting Started
    • Installing The Rust Toolchain
    • Project Setup
    • IDEs
    • Continuous Integration
  2. Our Driving Example
    • What Should Our Newsletter Do?
    • Working In Iterations
  3. Sign Up A New Subscriber
  4. Telemetry
    • Unknown Unknowns
    • Observability
    • Logging
    • Instrumenting /POST subscriptions
    • Structured Logging
  5. Go Live
    • We Must Talk About Deployments
    • Choosing Our Tools
    • A Dockerfile For Our Application
    • Deploy To DigitalOcean Apps Platform
  6. Rejecting Invalid Subscribers #1
    • Requirements
    • First Implementation
    • Validation Is A Leaky Cauldron
    • Type-Driven Development
    • Ownership Meets Invariants
    • Panics
    • Error As Values - Result
  7. Reject Invalid Subscribers #2
  8. Publish A Newsletter Issue
    • The Newsletter State Machine
    • /newsletters/draft
    • /newsletters/issue
  9. Securing Our API
    • Security Properties
    • Trasport Layer Security (TLS)
    • Authentication / Authorization
  10. Hardening Our Email Delivery Logic
  • Send Emails Asynchronously
  1. Monitoring
  • Metrics: Prometheus
  • Dashboards: Grafana
  • Alerts: AlertManager
  1. Performance
  • Micro-benchmarking
  • Load Testing