How To Write A REST Client In Rust
- 8422 words
- 43 min
This article is a sample from Zero To Production In Rust, a hands-on introduction to backend development in Rust.
You can get a copy of the book at zero2prod.com.
TL;DR
We need to send a confirmation email to the new subscribers of our newsletter.
To pull it off we need to learn:
- how to write a REST API client using
reqwests
; - how to test it using HTTP mocking via
wiremock
.
We'll deal with both the happy and the unhappy path (server errors and timeouts).
Chapter 7 - Part 0
- Confirmation Emails
- How To Send An Email
- How To Write A REST Client Using
reqwest
- How To Test A REST Client
- First Sketch Of
EmailClient::send_email
- Dealing With Failures
- 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.
Subscriber Consent
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:
- add their details to our database in the
subscribers
table, withstatus
equal topending_confirmation
; - generate a (unique)
subscription_token
; - store
subscription_token
in our database against theirid
in asubscription_tokens
table; - send an email to the new subscriber containing a link structured as
https://<our-api-domain>/subscriptions/confirm?token=<subscription_token>
; - return a
200 OK
.
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:
- retrieve
subscription_token
from the query parameters; - retrieve the subscriber id associated with
subscription_token
from thesubscription_tokens
table; - update the subscriber status from
pending_confirmation
toactive
in thesubscribers
table; - return a
200 OK
.
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:
- write a module to send an email;
- adapt the logic of our existing
POST /subscriptions
request handler to match the new specification; - write a
GET /subscriptions/confirm
request handler from scratch.
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.
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
?
- It has been extensively battle-tested (~8.5 million downloads);
- It offers a primarily asynchronous interface, with the option to enable a synchronous one via the
blocking
feature flag; - It relies on
tokio
as its asynchronous executor, matching what we are already using due toactix-web
; - It does not depend on any system library if you choose to use
rustls
to back the TLS implementation (rustls-tls
feature flag instead ofdefault-tls
), making it extremely portable.
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
:
http_client
, to store aClient
instance;base_url
, to store the URL of the API we will be making requests to.sender
, the address we are going to set as sender for our emails.
//! 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::default())
.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:
- derive the
Clone
trait forEmailClient
, build an instance of it once and then pass a clone toapp_data
every time we need to build anApp
:
//! 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::default())
.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)
}
- wrap
EmailClient
inactix_web::web::Data
(anArc
pointer) and pass a pointer toapp_data
every time we need to build anApp
- like we are doing withPgPool
:
//! 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::default())
.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
// [...]
#[tokio::main]
async fn main() -> std::io::Result<()> {
// [...]
let configuration = get_configuration().expect("Failed to read configuration.");
let connection_pool = PgPoolOptions::new()
.acquire_timeout(std::time::Duration::from_secs(2))
.connect_lazy_with(configuration.database.with_db());
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: "[email protected]"
#! 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: "[email protected]"
We can now build an EmailClient
instance in main
and pass it to the run
function:
//! src/main.rs
// [...]
use zero2prod::email_client::EmailClient;
#[tokio::main]
async fn main() -> std::io::Result<()> {
// [...]
let configuration = get_configuration().expect("Failed to read configuration.");
let connection_pool = PgPoolOptions::new()
.acquire_timeout(std::time::Duration::from_secs(2))
.connect_lazy_with(configuration.database.with_db());
// 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!()
}
}
This will not compile straight-away - we need to add two feature flags to tokio
in our Cargo.toml
:
#! Cargo.toml
# [...]
[dev-dependencies]
# [...]
tokio = { version = "1", features = ["rt", "macros"] }
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.5.2"
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 [email protected]
? 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": "[email protected]",
"To": "[email protected]",
"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:
- a
POST
request to the/email
endpoint; - a JSON body, with fields that map closely to the arguments of
send_email
. We need to be careful with field names, they must be pascal cased; - an authorization header,
X-Postmark-Server-Token
, with a value set to a secret token that we can retrieve from their portal.
If the request succeeds, we get something like this back:
HTTP/1.1 200 OK
Content-Type: application/json
{
"To": "[email protected]",
"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.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(),
};
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.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(),
};
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 secrecy::Secret;
// [...]
pub struct EmailClient {
// [...]
// We don't want to log this by accident
authorization_token: Secret<String>
}
impl EmailClient {
pub fn new(
// [...]
authorization_token: Secret<String>
) -> Self {
Self {
// [...]
authorization_token
}
}
// [...]
}
//! src/configuration.rs
// [...]
#[derive(serde::Deserialize)]
pub struct EmailClientSettings {
// [...]
// New (secret) configuration value!
pub authorization_token: Secret<String>
}
// [...]
We can then let the compiler tell us what else needs to be modified:
//! src/email_client.rs
// [...]
#[cfg(test)]
mod tests {
use secrecy::Secret;
// [...]
#[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,
Secret::new(Faker.fake())
);
// [...]
}
}
//! src/main.rs
// [...]
#[tokio::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: "[email protected]"
# 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
use secrecy::{ExposeSecret, Secret};
// [...]
impl EmailClient {
// [...]
pub async fn send_email(
// [...]
) -> Result<(), String> {
// [...]
let builder = self
.http_client
.post(&url)
.header(
"X-Postmark-Server-Token",
self.authorization_token.expose_secret()
)
.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.expose_secret()
)
.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,
Secret::new(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:
- non-success status codes (e.g.
4xx
,5xx
, etc.); - slow responses.
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,
Secret::new(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,
Secret::new(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.expose_secret()
)
.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.expose_secret()
)
.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,
Secret::new(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(), Secret::new(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!
Refactoring: Fail fast
The timeout on our HTTP client is currently hard-coded to 10 seconds:
//! src/email_client.rs
// [...]
impl EmailClient {
pub fn new(
// [...]
) -> Self {
let http_client = Client::builder()
.timeout(std::time::Duration::from_secs(10))
// [...]
}
}
This implies that our timeout test takes roughly 10 seconds to fail - that is a long time, especially if you are running tests after every little change.
Let's make the timeout threshold configurable to keep our test suite responsive.
//! src/email_client.rs
// [...]
impl EmailClient {
pub fn new(
// [...]
// New argument!
timeout: std::time::Duration,
) -> Self {
let http_client = Client::builder()
.timeout(timeout)
// [...]
}
}
//! src/configuration.rs
// [...]
#[derive(serde::Deserialize)]
pub struct EmailClientSettings {
// [...]
// New configuration value!
pub timeout_milliseconds: u64
}
impl EmailClientSettings {
// [...]
pub fn timeout(&self) -> std::time::Duration {
std::time::Duration::from_millis(self.timeout_milliseconds)
}
}
//! src/main.rs
// [...]
#[tokio::main]
async fn main() -> std::io::Result<()> {
// [...]
let timeout = configuration.email_client.timeout();
let email_client = EmailClient::new(
configuration.email_client.base_url,
sender_email,
configuration.email_client.authorization_token,
// Pass new argument from configuration
timeout
);
// [...]
}
#! configuration/base.yaml
# [...]
email_client:
# [...]
timeout_milliseconds: 10000
The project should compile.
We still need to edit the tests though!
//! src/email_client.rs
// [...]
#[cfg(test)]
mod tests {
// [...]
fn email_client(base_url: String) -> EmailClient {
EmailClient::new(
base_url,
email(),
Secret::new(Faker.fake()),
// Much lower than 10s!
std::time::Duration::from_millis(200),
)
}
}
//! tests/health_check.rs
// [...]
async fn spawn_app() -> TestApp {
// [...]
let timeout = configuration.email_client.timeout();
let email_client = EmailClient::new(
configuration.email_client.base_url,
sender_email,
configuration.email_client.authorization_token,
timeout
);
}
All tests should succeed - and the overall execution time should be down to less than a second for the whole test suite.
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!
This article is a sample from Zero To Production In Rust, a hands-on introduction to backend development in Rust.
You can get a copy of the book at zero2prod.com.
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.
- Getting Started
- Installing The Rust Toolchain
- Project Setup
- IDEs
- Continuous Integration
- Our Driving Example
- What Should Our Newsletter Do?
- Working In Iterations
- Sign Up A New Subscriber
- Telemetry
- Unknown Unknowns
- Observability
- Logging
- Instrumenting /POST subscriptions
- Structured Logging
- Go Live
- We Must Talk About Deployments
- Choosing Our Tools
- A Dockerfile For Our Application
- Deploy To DigitalOcean Apps Platform
- Rejecting Invalid Subscribers #1
- Requirements
- First Implementation
- Validation Is A Leaky Cauldron
- Type-Driven Development
- Ownership Meets Invariants
- Panics
- Error As Values -
Result
- Reject Invalid Subscribers #2
- Error Handling
- What Is The Purpose Of Errors?
- Error Reporting For Operators
- Errors For Control Flow
- Avoid "Ball Of Mud" Error Enums
- Who Should Log Errors?
- Naive Newsletter Delivery
- User Stories Are Not Set In Stone
- Do Not Spam Unconfirmed Subscribers
- All Confirmed Subscribers Receive New Issues
- Implementation Strategy
- Body Schema
- Fetch Confirmed Subscribers List
- Send Newsletter Emails
- Validation Of Stored Data
- Limitations Of The Naive Approach
- Securing Our API
- Fault-tolerant Newsletter Delivery