Luca Palmieri - Zero To Production in Rust - An Introduction To Backend Development-Independently Published (2024)
Luca Palmieri - Zero To Production in Rust - An Introduction To Backend Development-Independently Published (2024)
Foreword xiii
Preface xv
What Is This Book About . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . xv
Cloud-native applications . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . xv
Working in a team . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . xvi
Who Is This Book For . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . xvii
1 Getting Started 1
1.1 Installing The Rust Toolchain . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
1.1.1 Compilation Targets . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
1.1.2 Release Channels . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
1.1.3 What Toolchains Do We Need? . . . . . . . . . . . . . . . . . . . . . . . . . . 2
1.2 Project Setup . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
1.3 IDEs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
1.3.1 Rust-analyzer . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4
1.3.2 IntelliJ Rust . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4
1.3.3 What Should I Use? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4
1.4 Inner Development Loop . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4
1.4.1 Faster Linking . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5
1.4.2 cargo-watch . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
1.5 Continuous Integration . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
1.5.1 CI Steps . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
1.5.2 Ready-to-go CI Pipelines . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
i
ii CONTENTS
4 Telemetry 89
4.1 Unknown Unknowns . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 89
4.2 Observability . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 90
4.3 Logging . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 91
4.3.1 The log Crate . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 91
4.3.2 actix-web’s Logger Middleware . . . . . . . . . . . . . . . . . . . . . . . . . . 92
4.3.3 The Facade Pattern . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 93
4.4 Instrumenting POST /subscriptions . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 95
4.4.1 Interactions With External Systems . . . . . . . . . . . . . . . . . . . . . . . . 96
4.4.2 Think Like A User . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 97
4.4.3 Logs Must Be Easy To Correlate . . . . . . . . . . . . . . . . . . . . . . . . . . 99
4.5 Structured Logging . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 101
4.5.1 The tracing Crate . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 102
4.5.2 Migrating From log To tracing . . . . . . . . . . . . . . . . . . . . . . . . . . 102
CONTENTS iii
When you read these lines, Rust has achieved its biggest goal: make an offer to programmers to write their
production systems in a different language. By the end of the book, it is still your choice to follow that path,
but you have all you need to consider the offer. I’ve been part of the growth process of two widely different
languages: Ruby and Rust - by programming them, but also by running events, being part of their project
management and running business around them. Through that, I had the privilege of being in touch with
many of the creators of those languages and consider some of them friends. Rust has been my one chance
in life to see and help a language grow from the experimental stage to adoption in the industry.
I’ll let you in on a secret I learned along the way: programming languages are not adopted because of a feature
checklist. It’s a complex interplay between good technology, the ability to speak about it and finding enough
people willing to take long bets. When I write these lines, over 5000 people have contributed to the Rust
project, often for free, in their spare time - because they believe in that bet. But you don’t have to contribute
to the compiler or be recorded in a git log to contribute to Rust. Luca’s book is such a contribution: it gives
newcomers a perspective on Rust and promotes the good work of those many people.
Rust was never intended to be a research platform - it was always meant as a programming language solving
real, tangible issues in large codebases. It is no surprise that it comes out of an organization that maintains a
very large and complex codebase - Mozilla, creators of Firefox. When I joined Rust, it was just ambition - but
the ambition was to industrialize research to make the software of tomorrow better. With all of its theoretical
concepts, linear typing, region based memory management, the programming language was always meant
for everyone. This reflects in its lingo: Rust uses accessible names like “Ownership” and “Borrowing” for
the concepts I just mentioned. Rust is an industry language, through and through.
And that reflects in its proponents: I’ve known Luca for years as a community member who knows a ton
about Rust. But his deeper interest lies in convincing people that Rust is worth a try by addressing their
needs. The title and structure of this book reflects one of the core values of Rust: to find its worth in writing
production software that is solid and works. Rust shows its strength in the care and knowledge that went
into it to write stable software productively. Such an experience is best found with a guide and Luca is one
of the best guides you can find around Rust.
Rust doesn’t solve all of your problems, but it has made an effort to eliminate whole categories of mistakes.
There’s the view out there that safety features in languages are there because of the incompetence of pro-
grammers. I don’t subscribe to this view. Emily Dunham, captured it well in her RustConf 2017 keynote:
“safe code allows you to take better risks”. Much of the magic of the Rust community lies in this positive
view of its users: whether you are a newcomer or an experienced developer, we trust your experience and
your decision-making. In this book, Luca offers a lot of new knowledge that can be applied even outside of
xiii
xiv CONTENTS
Rust, well explained in the context of daily software praxis. I wish you a great time reading, learning and
contemplating.
Florian Gilcher,
Management Director of Ferrous Systems and
Co-Founder of the Rust Foundation
Preface
Zero To Production will focus on the challenges of writing Cloud-native applications in a team of
four or five engineers with different levels of experience and proficiency.
Cloud-native applications
Defining what Cloud-native application means is, by itself, a topic for a whole new book1 . Instead of pre-
scribing what Cloud-native applications should look like, we can lay down what we expect them to do.
Paraphrasing Cornelia Davis, we expect Cloud-native applications:
• To achieve high-availability while running in fault-prone environments;
• To allow us to continuously release new versions with zero downtime;
• To handle dynamic workloads (e.g. request volumes).
These requirements have a deep impact on the viable solution space for the architecture of our software.
High availability implies that our application should be able to serve requests with no downtime even if
one or more of our machines suddenly starts failing (a common occurrence in a Cloud environment2 ). This
1
Like the excellent Cloud-native patterns by Cornelia Davis!
2
For example, many companies run their software on AWS Spot Instances to reduce their infrastructure bills. The price of Spot
xv
xvi CONTENTS
forces our application to be distributed - there should be multiple instances of it running on multiple ma-
chines.
The same is true if we want to be able to handle dynamic workloads - we should be able to measure if our
system is under load and throw more compute at the problem by spinning up new instances of the applica-
tion. This also requires our infrastructure to be elastic to avoid overprovisioning and its associated costs.
Running a replicated application influences our approach to data persistence - we will avoid using the local
filesystem as our primary storage solution, relying instead on databases for our persistence needs.
Zero To Production will thus extensively cover topics that might seem tangential to pure backend application
development. But Cloud-native software is all about rainbows and DevOps, therefore we will be spending
plenty of time on topics traditionally associated with the craft of operating systems.
We will cover how to instrument your Rust application to collect logs, traces and metrics to be able to
observe our system.
We will cover how to set up and evolve your database schema via migrations.
We will cover all the material required to use Rust to tackle both day one and day two concerns of a Cloud-
native API.
Working in a team
The impact of those three requirements goes beyond the technical characteristics of our system: it influences
how we build our software.
To be able to quickly release a new version of our application to our users we need to be sure that our applic-
ation works.
If you are working on a solo project you can rely on your thorough understanding of the whole system: you
wrote it, it might be small enough to fit entirely in your head at any point in time.3
If you are working in a team on a commercial project, you will be very often working on code that was neither
written or reviewed by you. The original authors might not be around anymore.
You will end up being paralysed by fear every time you are about to introduce changes if you are relying on
your comprehensive understanding of what the code does to prevent it from breaking.
You want automated tests.
Running on every commit. On every branch. Keeping main healthy.
You want to leverage the type system to make undesirable states difficult or impossible to represent.
You want to use every tool at your disposal to empower each member of the team to evolve that piece of
software. To contribute fully to the development process even if they might not be as experienced as you or
equally familiar with the codebase or the technologies you are using.
instances is the result of a continuous auction and it can be substantially cheaper than the corresponding full price for On Demand
instances (up to 90% cheaper!).
There is one gotcha: AWS can decommission your Spot instances at any point in time. Your software must be fault-tolerant to
leverage this opportunity.
3
Assuming you wrote it recently.
Your past self from one year ago counts as a stranger for all intents and purposes in the world of software development. Pray that
your past self wrote comments for your present self if you are about to pick up again an old project of yours.
CONTENTS xvii
Zero To Production will therefore put a strong emphasis on test-driven development and continuous integ-
ration from the get-go - we will have a CI pipeline set up before we even have a web server up and running!
We will be covering techniques such as black-box testing for APIs and HTTP mocking - not wildly popular
or well documented in the Rust community yet extremely powerful.
We will also borrow terminology and techniques from the Domain Driven Design world, combining them
with type-driven design to ensure the correctness of our systems.
Our main focus is enterprise software: correct code which is expressive enough to model the domain
and supple enough to support its evolution over time.
We will thus have a bias for boring and correct solutions, even if they incur a performance overhead that
could be optimised away with a more careful and chiseled approach.
Get it to run first, optimise it later (if needed).
Yes.
I am writing this book for the seasoned backend developers who have read The Rust Book and are now
trying to port over a couple of simple systems.
I am writing this book for the new engineers on my team, a trail to help them make sense of the codebases
they will contribute to over the coming weeks and months.
I am writing this book for a niche whose needs I believe are currently underserved by the articles and resources
available in the Rust ecosystem.
xviii CONTENTS
Getting Started
There is more to a programming language than the language itself: tooling is a key element of the experience
of using the language.
The same applies to many other technologies (e.g. RPC frameworks like gRPC or Apache Avro) and it often
has a disproportionate impact on the uptake (or the demise) of the technology itself.
Tooling should therefore be treated as a first-class concern both when designing and teaching the language
itself.
The Rust community has put tooling at the forefront since its early days: it shows.
We are now going to take a brief tour of a set of tools and utilities that are going to be useful in our jour-
ney. Some of them are officially supported by the Rust organisation, others are built and maintained by the
community.
1
2 CHAPTER 1. GETTING STARTED
The Rust project strives for stability without stagnation. Quoting from Rust’s documentation:
[..] you should never have to fear upgrading to a new version of stable Rust. Each upgrade should
be painless, but should also bring you new features, fewer bugs, and faster compile times.
That is why, for application development, you should generally rely on the latest released version of the
compiler to run, build and test your software - the so-called stable channel.
A new version of the compiler is released on the stable channel every six weeks1 - the latest version at the
time of writing is v1.72.02 .
Testing your software using the beta compiler is one of the many ways to support the Rust project - it helps
catching bugs before the release date3 .
nightly serves a different purpose: it gives early adopters access to unfinished features4 before they are re-
leased (or even on track to be stabilised!).
I would invite you to think twice if you are planning to run production software on top of the nightly
compiler: it’s called unstable for a reason.
You can update your toolchains with rustup update, while rustup toolchain list will give you an over-
view of what is installed on your system.
We will not need (or perform) any cross-compiling - our production workloads will be running in contain-
ers, hence we do not need to cross-compile from our development machine to the target host used in our
production environment.
1
More details on the release schedule can be found in the Rust book.
2
You can check the next version and its release date at Rust forge.
3
It’s fairly rare for beta releases to contain issues thanks to the CI/CD setup of the Rust project. One of its most interesting
components is crater, a tool designed to scrape crates.io and GitHub for Rust projects to build them and run their test suites to
identify potential regressions. Pietro Albini gave an awesome overview of the Rust release process in his Shipping a compiler every
six weeks talk at RustFest 2019.
4
You can check the list of feature flags available on nightly in The Unstable Book. Spoiler: there are loads.
1.2. PROJECT SETUP 3
You will not be spending a lot of quality time working directly with rustc - your main interface for building
and testing Rust applications will be cargo, Rust’s build tool.
You can double-check everything is up and running with
cargo --version
Let’s use cargo to create the skeleton of the project we will be working on for the whole book:
cargo new zero2prod
You should have a new zero2prod folder, with the following file structure:
zero2prod/
Cargo.toml
.gitignore
.git
src/
main.rs
We will be using GitHub as a reference given its popularity and the recently released GitHub Actions feature
for CI pipelines, but you are of course free to choose any other git hosting solution (or none at all).
1.3 IDEs
The project skeleton is ready, it is now time to fire up your favourite editor so that we can start messing
around with it.
Different people have different preferences but I would argue that the bare minimum you want to have, espe-
cially if you are starting out with a new programming language, is a setup that supports syntax highlighting,
code navigation and code completion.
Syntax highlighting gives you immediate feedback on glaring syntax errors, while code navigation and code
4 CHAPTER 1. GETTING STARTED
completion enable “exploratory” programming: jumping in and out of the source of your dependencies,
quick access to the available methods on a struct or an enum you imported from a crate without having to
continuously switch between your editor and docs.rs.
You have two main options for your IDE setup: rust-analyzer and IntelliJ Rust.
1.3.1 Rust-analyzer
rust-analyzer5 is an implementation of the Language Server Protocol for Rust.
The Language Server Protocol makes it easy to leverage rust-analyzer in many different editors, including
but not limited to VS Code, Emacs, Vim/NeoVim and Sublime Text 3.
Editor-specific setup instructions can be found on rust-analyzer’s website.
• Run tests;
• Run the application.
This is also known as the inner development loop.
The speed of your inner development loop is as an upper bound on the number of iterations that you can
complete in a unit of time.
If it takes 5 minutes to compile and run the application, you can complete at most 12 iterations in an hour.
Cut it down to 2 minutes and you can now fit in 30 iterations in the same hour!
Rust does not help us here - compilation speed can become a pain point on big projects. Let’s see what we
can do to mitigate the issue before moving forward.
# On Windows
# ```
# cargo install -f cargo-binutils
# rustup component add llvm-tools-preview
# ```
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
[target.x86_64-pc-windows-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
# On Linux:
# - Ubuntu, `sudo apt-get install lld clang`
# - Arch, `sudo pacman -S lld clang`
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "linker=clang", "-C", "link-arg=-fuse-ld=lld"]
# On MacOS, `brew install llvm` and follow steps in `brew info llvm`
[target.x86_64-apple-darwin]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
6 CHAPTER 1. GETTING STARTED
[target.aarch64-apple-darwin]
rustflags = ["-C", "link-arg=-fuse-ld=/opt/homebrew/opt/llvm/bin/ld64.lld"]
There is ongoing work on the Rust compiler to use lld as the default linker where possible - soon enough
this custom configuration will not be necessary to achieve higher compilation performance!8
1.4.2 cargo-watch
We can also mitigate the impact on our productivity by reducing the perceived compilation time - i.e. the
time you spend looking at your terminal waiting for cargo check or cargo run to complete.
Tooling can help here - let’s install cargo-watch:
cargo install cargo-watch
cargo-watch monitors your source code to trigger commands every time a file changes.
For example:
cargo watch -x check
In trunk-based development we should be able to deploy our main branch at any point in time.
Every member of the team can branch off from main, develop a small feature or fix a bug, merge back into
main and release to our users.
Continuous Integration empowers each member of the team to integrate their changes into the main
branch multiple times a day.
1.5.1 CI Steps
1.5.1.1 Tests
If your CI pipeline had a single step, it should be testing.
Tests are a first-class concept in the Rust ecosystem and you can leverage cargo to run your unit and integra-
tion tests:
cargo test
cargo test also takes care of building the project before running tests, hence you do not need to run cargo
buildbeforehand (even though most pipelines will invoke cargo build before running tests to cache de-
pendencies).
The easiest way to measure code coverage of a Rust project is via cargo tarpaulin, a cargo subcommand
developed by xd009642. You can install tarpaulin with
# At the time of writing tarpaulin only supports
# x86_64 CPU architectures running Linux.
cargo install cargo-tarpaulin
while
cargo tarpaulin --ignore-tests
will compute code coverage for your application code, ignoring your test functions.
tarpaulin can be used to upload code coverage metrics to popular services like Codecov or Coveralls - in-
structions can be found in tarpaulin’s README.
1.5.1.3 Linting
Writing idiomatic code in any programming language requires time and practice.
It is easy at the beginning of your learning journey to end up with fairly convoluted solutions to problems
that could otherwise be tackled with a much simpler approach.
Static analysis can help: in the same way a compiler steps through your code to ensure it conforms to the
language rules and constraints, a linter will try to spot unidiomatic code, overly-complex constructs and
common mistakes/inefficiencies.
The Rust team maintains clippy, the official Rust linter9 .
clippy is included in the set of components installed by rustup if you are using the default profile. Often
CI environments use rustup’s minimal profile, which does not include clippy.
You can easily install it with
rustup component add clippy
In our CI pipeline we would like to fail the linter check if clippy emits any warnings.
We can achieve it with
cargo clippy -- -D warnings
Static analysis is not infallible: from time to time clippy might suggest changes that you do not believe to
be either correct or desirable.
You can mute a warning using the #[allow(clippy::lint_name)] attribute on the affected code block or
9
Yes, clippy is named after the (in)famous paperclip-shaped Microsoft Word assistant.
1.5. CONTINUOUS INTEGRATION 9
disable the noisy lint altogether for the whole project with a configuration line in clippy.toml or a project-
level #![allow(clippy::lint_name)] directive.
Details on the available lints and how to tune them for your specific purposes can be found in clippy’s
README.
1.5.1.4 Formatting
Most organizations have more than one line of defence for the main branch: one is provided by the CI
pipeline checks, the other is often a pull request review.
A lot can be said on what distinguishes a value-adding PR review process from a soul-sucking one - no need
to re-open the whole debate here.
I know for sure what should not be the focus of a good PR review: formatting nitpicks - e.g. Can you add a
newline here?, I think we have a trailing whitespace there!, etc.
Let machines deal with formatting while reviewers focus on architecture, testing thoroughness, reliability,
observability. Automated formatting removes a distraction from the complex equation of the PR review
process. You might dislike this or that formatting choice, but the complete erasure of formatting bikeshed-
ding is worth the minor discomfort.
rustfmt is the official Rust formatter.
Just like clippy, rustfmt is included in the set of default components installed by rustup. If missing, you
can easily install it with
rustup component add rustfmt
It will fail when a commit contains unformatted code, printing the difference to the console.10
You can tune rustfmt for a project with a configuration file, rustfmt.toml. Details can be found in rust-
fmt’s README.
They also provide cargo-audit11 , a convenient cargo sub-command to check if vulnerabilities have been
reported for any of the crates in the dependency tree of your project.
You can install it with
cargo install cargo-audit
Give a man a fish, and you feed him for a day. Teach a man to fish, and you feed him for a lifetime.
Hopefully I have taught you enough to go out there and stitch together a solid CI pipeline for your Rust
projects.
We should also be honest and admit that it can take multiple hours of fidgeting around to learn how to use
the specific flavour of configuration language used by a CI provider and the debugging experience can often
be quite painful, with long feedback cycles.
I have thus decided to collect a set of ready-made configuration files for the most popular CI providers - the
exact steps we just described, ready to be embedded in your project repository:
• GitHub Actions;
• CircleCI;
• GitLab CI;
• Travis.
It is often much easier to tweak an existing setup to suit your specific needs than to write a new one from
scratch.
11
cargo-deny, developed by Embark Studios, is another cargo sub-command that supports vulnerability scanning of your de-
pendency tree. It also bundles additional checks you might want to perform on your dependencies - it helps you identify unmain-
tained crates, define rules to restrict the set of allowed software licenses and spot when you have multiple versions of the same crate
in your lock file (wasted compilation cycles!). It requires a bit of upfront effort in configuration, but it can be a powerful addition
to your CI toolbox.
Chapter 2
Zero To Production will focus on the challenges of writing cloud-native applications in a team of four
or five engineers with different levels of experience and proficiency.
It flips the hierarchy you are used to: the material you are studying is not relevant because somebody claims
it is, it is relevant because it is useful to get closer to a solution.
You learn new techniques and when it makes sense to reach for them.
The devil is in the details: a problem-based learning path can be delightful, yet it is painfully easy to misjudge
how challenging each step of the journey is going to be.
Our driving example needs to be:
We will go for an email newsletter - the next section will detail the functionality we plan to cover1 .
1
Who knows, I might end up using our home-grown newsletter application to release the final chapter - it would definitely
provide me with a sense of closure.
11
12 CHAPTER 2. BUILDING AN EMAIL NEWSLETTER
As a …,
I want to …,
So that …
A user story helps us to capture who we are building for (as a), the actions they want to perform (want to)
as well as their motives (so that).
We will fulfill two user stories:
• As a blog visitor,
I want to subscribe to the newsletter,
So that I can receive email updates when new content is published on the blog;
• As the blog author,
I want to send an email to all my subscribers,
So that I can notify them when new content is published.
We will not add features to
• unsubscribe;
• manage multiple newsletters;
• segment subscribers in multiple audiences;
• track opening and click rates.
2
Make no mistake: when buying a SaaS product it is often not the software itself that you are paying for - you are paying for
the peace of mind of knowing that there is an engineering team working full time to keep the service up and running, for their legal
and compliance expertise, for their security team. We (developers) often underestimate how much time (and headaches) that saves
us over time.
2.3. WORKING IN ITERATIONS 13
As said, pretty barebone. We would definitely not be able to launch publicly without giving users the pos-
sibility to unsubscribe.
Nonetheless, fulfilling those two stories will give us plenty of opportunities to practice and hone our skills!
2.3.1 Coming Up
Strategy is clear, we can finally get started: the next chapter will focus on the subscription functionality.
Getting off the ground will require some initial heavy-lifting: choosing a web framework, setting up the
infrastructure for managing database migrations, putting together our application scaffolding as well as our
setup for integration testing.
Expect to spend way more time pair programming with the compiler going forward!
shots, showing what the project looks like at end of each chapter and key sections.
If you get stuck, make sure to compare your code with the one in the repository!
Chapter 3
We spent the whole previous chapter defining what we will be building (an email newsletter!), narrowing
down a precise set of requirements. It is now time to roll up our sleeves and get started with it.
This chapter will take a first stab at implementing this user story:
As a blog visitor,
I want to subscribe to the newsletter,
So that I can receive email updates when new content is published on the blog.
We expect our blog visitors to input their email address in a form embedded on a web page.
The form will trigger an API call to a backend server that will actually process the information, store it and
send back a response.
This chapter will focus on that backend server - we will implement the /subscriptions POST endpoint.
15
16 CHAPTER 3. SIGN UP A NEW SUBSCRIBER
We will be relying on our Continuous Integration pipeline to keep us in check throughout the process - if
you have not set it up yet, go back to Chapter 1 and grab one of the ready-made templates.
Throughout this chapter and beyond I suggest you to keep a couple of extra browser tabs open: actix-web’s
website, actix-web’s documentation and actix-web’s examples collection.
We can use /health_check to verify that the application is up and ready to accept incoming requests.
Combine it with a SaaS service like pingdom.com and you can be alerted when your API goes dark - quite a
good baseline for an email newsletter that you are running on the side.
A health-check endpoint can also be handy if you are using a container orchestrator to juggle your applica-
tion (e.g. Kubernetes or Nomad): the orchestrator can call /health_check to detect if the API has become
unresponsive and trigger a restart.
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
HttpServer::new(|| {
App::new()
.route("/", web::get().to(greet))
.route("/{name}", web::get().to(greet))
3.3. OUR FIRST ENDPOINT: A BASIC HEALTH CHECK 17
})
.bind("127.0.0.1:8000")?
.run()
.await
}
We have not added actix-web and tokio to our list of dependencies, therefore the compiler cannot resolve
what we imported.
We can either fix the situation manually, by adding
#! Cargo.toml
# [...]
[dependencies]
actix-web = "4"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
under [dependencies] in our Cargo.toml or we can use cargo add to quickly add the latest version of both
crates as a dependency of our project:
cargo add actix-web@4
cargo add tokio@1 --features macros,rt-multi-thread
1
During our development process we are not always interested in producing a runnable binary: we often just want to know if
our code compiles or not. cargo check was born to serve exactly this usecase: it runs the same checks that are run by cargo build,
but it does not bother to perform any machine code generation. It is therefore much faster and provides us with a tighter feedback
loop. See link for more details.
18 CHAPTER 3. SIGN UP A NEW SUBSCRIBER
You can now launch the application with cargo run and perform a quick manual test:
curl http://127.0.0.1:8000
Hello World!
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
HttpServer::new(|| {
App::new()
.route("/", web::get().to(greet))
.route("/{name}", web::get().to(greet))
})
.bind("127.0.0.1:8000")?
.run()
.await
}
App is the component whose job is to take an incoming request as input and spit out a response.
Let’s zoom in on our code snippet:
App::new()
.route("/", web::get().to(greet))
.route("/{name}", web::get().to(greet))
App is a practical example of the builder pattern: new() gives us a clean slate to which we can add, one bit at
a time, new behaviour using a fluent API (i.e. chaining method calls one after the other).
We will cover the majority of App’s API surface on a need-to-know basis over the course of the whole book:
by the end of our journey you should have touched most of its methods at least once.
• path, a string, possibly templated (e.g. "/{name}") to accommodate dynamic path segments;
• route, an instance of the Route struct.
"/" will match all requests without any segment following the base path - i.e. http://localhost:8000/.
web::get() is a short-cut for Route::new().guard(guard::Get()) a.k.a. the request should be passed to
the handler if and only if its HTTP method is GET.
You can start to picture what happens when a new request comes in: App iterates over all registered endpoints
until it finds a matching one (both path template and guards are satisfied) and passes over the request object
to the handler.
This is not 100% accurate but it is a good enough mental model for the time being.
What does a handler look like instead? What is its function signature?
We only have one example at the moment, greet:
async fn greet(req: HttpRequest) -> impl Responder {
[...]
}
greet is an asynchronous function that takes an HttpRequest as input and returns something that imple-
20 CHAPTER 3. SIGN UP A NEW SUBSCRIBER
ments the Responder trait2 . A type implements the Responder trait if it can be converted into a HttpRe-
sponse - it is implemented off the shelf for a variety of common types (e.g. strings, status codes, bytes, Ht-
tpResponse, etc.) and we can roll our own implementations if needed.
Do all our handlers need to have the same function signature of greet?
No! actix-web, channelling some forbidden trait black magic, allows a wide range of different function
signatures for handlers, especially when it comes to input arguments. We will get back to it soon enough.
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
HttpServer::new(|| {
App::new()
.route("/", web::get().to(greet))
.route("/{name}", web::get().to(greet))
})
.bind("127.0.0.1:8000")?
.run()
.await
}
What is #[tokio::main] doing here? Well, let’s remove it and see what happens! cargo check screams at
us with these errors:
error[E0277]: `main` has invalid return type `impl std::future::Future`
--> src/main.rs:8:20
|
8 | async fn main() -> Result<(), std::io::Error> {
| ^^^^^^^^^^^^^^^^^^^
| `main` can only return types that implement `std::process::Termination`
|
= help: consider using `()`, or a `Result`
2
impl Responder is using the impl Trait syntax introduced in Rust 1.26 - you can find more details in Rust’s 2018 edition
guide.
3.3. OUR FIRST ENDPOINT: A BASIC HEALTH CHECK 21
We need main to be asynchronous because HttpServer::run is an asynchronous method but main, the entry-
point of our binary, cannot be an asynchronous function. Why is that?
Asynchronous programming in Rust is built on top of the Future trait: a future stands for a value that
may not be there yet. All futures expose a poll method which has to be called to allow the future to make
progress and eventually resolve to its final value. You can think of Rust’s futures as lazy: unless polled, there
is no guarantee that they will execute to completion. This has often been described as a pull model compared
to the push model adopted by other languages3 .
Rust’s standard library, by design, does not include an asynchronous runtime: you are supposed to bring
one into your project as a dependency, one more crate under [dependencies] in your Cargo.toml. This
approach is extremely versatile: you are free to implement your own runtime, optimised to cater for the
specific requirements of your usecase (see the Fuchsia project or bastion’s actor framework).
This explains why main cannot be an asynchronous function: who is in charge to call poll on it?
There is no special configuration syntax that tells the Rust compiler that one of your dependencies is an
asynchronous runtime (e.g. as we do for allocators) and, to be fair, there is not even a standardised definition
of what a runtime is (e.g. an Executor trait).
You are therefore expected to launch your asynchronous runtime at the top of your main function and then
use it to drive your futures to completion.
You might have guessed by now what is the purpose of #[tokio::main], but guesses are not enough to
satisfy us: we want to see it.
How?
tokio::main is a procedural macro and this is a great opportunity to introduce cargo expand, an awesome
addition to our Swiss army knife for Rust development:
Rust macros operate at the token level: they take in a stream of symbols (e.g. in our case, the whole main
function) and output a stream of new symbols which then gets passed to the compiler. In other words, the
main purpose of Rust macros is code generation.
How do we debug or inspect what is happening with a particular macro? You inspect the tokens it outputs!
That is exactly where cargo expand shines: it expands all macros in your code without passing the output
to the compiler, allowing you to step through it and understand what is going on.
Let’s use cargo expand to demystify #[tokio::main]:
cargo expand
Unfortunately, it fails:
error: the option `Z` is only accepted on the nightly compiler
error: could not compile `zero2prod`
3
Check out the release notes of async/await for more details. The talk by withoutboats at Rust LATAM 2019 is another
excellent reference on the topic. If you prefer books to talks, check out Futures Explained in 200 Lines of Rust.
22 CHAPTER 3. SIGN UP A NEW SUBSCRIBER
We are using the stable compiler to build, test and run our code. cargo-expand, instead, relies on the
nightly compiler to expand our macros.
You can install the nightly compiler by running
rustup toolchain install nightly --allow-downgrade
Some components of the bundle installed by rustup might be broken/missing on the latest nightly release:
--allow-downgrade tells rustup to find and install the latest nightly where all the needed components are
available.
You can use rustup default to change the default toolchain used by cargo and the other tools managed by
rustup. In our case, we do not want to switch over to nightly - we just need it for cargo-expand.
Luckily enough, cargo allows us to specify the toolchain on a per-command basis:
# Use the nightly toolchain just for this command invocation
cargo +nightly expand
/// [...]
We are starting tokio’s async runtime and we are using it to drive the future returned by HttpServer::run
to completion.
In other words, the job of #[tokio::main] is to give us the illusion of being able to define an asynchronous
main while, under the hood, it just takes our main asynchronous body and writes the necessary boilerplate
to make it run on top of tokio’s runtime.
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
HttpServer::new(|| {
App::new()
.route("/", web::get().to(greet))
.route("/{name}", web::get().to(greet))
})
.bind("127.0.0.1:8000")?
.run()
.await
}
First of all we need a request handler. Mimicking greet we can start with this signature:
async fn health_check(req: HttpRequest) -> impl Responder {
todo!()
}
We said that Responder is nothing more than a conversion trait into a HttpResponse. Returning an instance
of HttpResponse directly should work then!
24 CHAPTER 3. SIGN UP A NEW SUBSCRIBER
Looking at its documentation we can use HttpResponse::Ok to get a HttpResponseBuilder primed with a
200 status code. HttpResponseBuilder exposes a rich fluent API to progressively build out a HttpResponse
response, but we do not need it here: we can get a HttpResponse with an empty body by calling finish on
the builder.
Gluing everything together:
async fn health_check(req: HttpRequest) -> impl Responder {
HttpResponse::Ok().finish()
}
A quick cargo check confirms that our handler is not doing anything weird. A closer look at HttpRe-
sponseBuilder unveils that it implements Responder as well - we can therefore omit our call to finish and
shorten our handler to:
async fn health_check(req: HttpRequest) -> impl Responder {
HttpResponse::Ok()
}
The next step is handler registration - we need to add it to our App via route:
App::new()
.route("/health_check", web::get().to(health_check))
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
HttpServer::new(|| {
App::new()
.route("/health_check", web::get().to(health_check))
})
.bind("127.0.0.1:8000")?
.run()
.await
}
Our health check response is indeed static and does not use any of the data bundled with the incoming
HTTP request (routing aside). We could follow the compiler’s advice and prefix req with an underscore…
or we could remove that input argument entirely from health_check:
async fn health_check() -> impl Responder {
HttpResponse::Ok()
}
Surprise surprise, it compiles! actix-web has some pretty advanced type magic going on behind the scenes
and it accepts a broad range of signatures as request handlers - more on that later.
What is left to do?
Well, a little test!
# Launch the application first in another terminal with `cargo run`
curl -v http://127.0.0.1:8000/health_check
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8000 (#0)
> GET /health_check HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.61.0
> Accept: */*
>
< HTTP/1.1 200 OK
< content-length: 0
< date: Wed, 05 Aug 2020 22:11:52 GMT
Congrats, you have just implemented your first working actix_web endpoint!
manually check that all our assumptions on its behaviour are still valid every time we perform some changes.
We’d like to automate as much as possible: those checks should be run in our CI pipeline every time we are
committing a change in order to prevent regressions.
While the behaviour of our health check might not evolve much over the course of our journey, it is a good
starting point to get our testing scaffolding properly set up.
#[tokio::test]
async fn health_check_succeeds() {
let response = health_check().await;
// This requires changing the return type of `health_check`
// from `impl Responder` to `HttpResponse` to compile
// You also need to import it with `use actix_web::HttpResponse`!
assert!(response.status().is_success())
}
}
Changing any of these two properties would break our API contract, but our test would still pass - not good
enough.
actix-web provides some conveniences to interact with an App without skipping the routing logic, but there
are severe shortcomings to its approach:
• migrating to another web framework would force us to rewrite our whole integration test suite. As
much as possible, we’d like our integration tests to be highly decoupled from the technology underpin-
ning our API implementation (e.g. having framework-agnostic integration tests is life-saving when
you are going through a large rewrite or refactoring!);
• due to some actix-web’s limitations4 , we wouldn’t be able to share our App startup logic between our
production code and our testing code, therefore undermining our trust in the guarantees provided
by our test suite due to the risk of divergence over time.
We will opt for a fully black-box solution: we will launch our application at the beginning of each test and
interact with it using an off-the-shelf HTTP client (e.g. reqwest).
#[cfg(test)]
mod tests {
// Import the code I want to test
use super::*;
// My tests
}
4
App is a generic struct and some of the types used to parametrise it are private to the actix_web project. It is therefore impossible
(or, at least, so cumbersome that I have never succeeded at it) to write a function that returns an instance of App.
28 CHAPTER 3. SIGN UP A NEW SUBSCRIBER
///
/// assert!(is_even(2));
/// assert!(!is_even(1));
/// ```
pub fn is_even(x: u64) -> bool {
x % 2 == 0
}
An embedded test module has privileged access to the code living next to it: it can interact with structs, meth-
ods, fields and functions that have not been marked as public and would normally not be available to a user
of our code if they were to import it as a dependency of their own project.
Embedded test modules are quite useful for what I call iceberg projects, i.e. the exposed surface is very lim-
ited (e.g. a couple of public functions), but the underlying machinery is much larger and fairly complicated
(e.g. tens of routines). It might not be straight-forward to exercise all the possible edge cases via the exposed
functions - you can then leverage embedded test modules to write unit tests for private sub-components to
increase your overall confidence in the correctness of the whole project.
Tests in the external tests folder and doc tests, instead, have exactly the same level of access to your code
that you would get if you were to add your crate as a dependency in another project. They are therefore used
mostly for integration testing, i.e. testing your code by calling it in the same exact way a user would.
Our email newsletter is not a library, therefore the line is a bit blurry - we are not exposing it to the world as
a Rust crate, we are putting it out there as an API accessible over the network.
Nonetheless we are going to use the tests folder for our API integration tests - it is more clearly separated
and it is easier to manage test helpers as sub-modules of an external test binary.
If you won’t take my word for it, we can run a quick experiment:
# Create the tests folder
mkdir -p tests
//! tests/health_check.rs
use zero2prod::main;
#[test]
fn dummy_test() {
main()
}
For more information about this error, try `rustc --explain E0432`.
error: could not compile `zero2prod`.
We need to refactor our project into a library and a binary: all our logic will live in the library crate while the
binary itself will be just an entrypoint with a very slim main function.
First step: we need to change our Cargo.toml.
It currently looks something like this:
[package]
name = "zero2prod"
version = "0.1.0"
authors = ["Luca Palmieri <[email protected]>"]
edition = "2021"
[dependencies]
# [...]
We are relying on cargo’s default behaviour: unless something is spelled out, it will look for a src/main.rs
file as the binary entrypoint and use the package.name field as the binary name.
Looking at the manifest target specification, we need to add a lib section to add a library to our project:
[package]
name = "zero2prod"
version = "0.1.0"
authors = ["Luca Palmieri <[email protected]>"]
edition = "2021"
30 CHAPTER 3. SIGN UP A NEW SUBSCRIBER
[lib]
# We could use any path here, but we are following the community convention
# We could specify a library name using the `name` field. If unspecified,
# cargo will default to `package.name`, which is what we want.
path = "src/lib.rs"
[dependencies]
# [...]
The lib.rs file does not exist yet and cargo won’t create it for us:
cargo check
Everything should be working now: cargo check passes and cargo run still launches our application.
Although it is working, our Cargo.toml file now does not give you at a glance the full picture: you see a
library, but you don’t see our binary there. Even if not strictly necessary, I prefer to have everything spelled
out as soon as we move out of the auto-generated vanilla configuration:
[package]
name = "zero2prod"
version = "0.1.0"
authors = ["Luca Palmieri <[email protected]>"]
edition = "2021"
[lib]
path = "src/lib.rs"
[dependencies]
# [...]
use zero2prod::run;
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
run().await
}
//! lib.rs
When we receive a GET request for /health_check we return a 200 OK response with no body.
// Act
let response = client
.get("http://127.0.0.1:8000/health_check")
.send()
.await
.expect("Failed to execute request.");
// Assert
assert!(response.status().is_success());
assert_eq!(Some(0), response.content_length());
}
#! Cargo.toml
# [...]
# Dev dependencies are used exclusively when running tests or examples
# They do not get included in the final application binary!
[dev-dependencies]
reqwest = "0.11"
# [...]
3.5. IMPLEMENTING OUR FIRST INTEGRATION TEST 33
The test also covers the full range of properties we are interested to check:
The test as it is crashes before doing anything useful: we are missing spawn_app, the last piece of the integra-
tion testing puzzle.
Why don’t we just call run in there? I.e.
//! tests/health_check.rs
// [...]
Running target/debug/deps/health_check-fc74836458377166
running 1 test
test health_check_works ...
test health_check_works has been running for over 60 seconds
No matter how long you wait, test execution will never terminate. What is going on?
use zero2prod::run;
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
// Bubble up the io::Error if we failed to bind the address
// Otherwise call .await on our Server
run()?.await
}
// if we fail to perform the required setup we can just panic and crash
// all the things.
fn spawn_app() {
let server = zero2prod::run().expect("Failed to bind address");
// Launch the server as a background task
// tokio::spawn returns a handle to the spawned future,
// but we have no use for it here, hence the non-binding let
let _ = tokio::spawn(server);
}
#[tokio::test]
async fn health_check_works() {
// No .await, no .expect
spawn_app();
// [...]
}
cargo test
Running target/debug/deps/health_check-a1d027e9ac92cd64
running 1 test
test health_check_works ... ok
3.5.1 Polishing
We got it working, now we need to have a second look and improve it, if needed or possible.
3.5.1.1 Clean Up
What happens to our app running in the background when the test run ends? Does it shut down? Does it
linger as a zombie somewhere?
Well, running cargo test multiple times in a row always succeeds - a strong hint that our 8000 port is getting
released at the end of each run, therefore implying that the application is correctly shut down.
36 CHAPTER 3. SIGN UP A NEW SUBSCRIBER
A second look at tokio::spawn’s documentation supports our hypothesis: when a tokio runtime is shut
down all tasks spawned on it are dropped. tokio::test spins up a new runtime at the beginning of each
test case and they shut down at the end of each test case.
In other words, good news - no need to implement any clean up logic to avoid leaking resources between test
runs.
• if port 8000 is being used by another program on our machine (e.g. our own application!), tests will
fail;
• if we try to run two or more tests in parallel only one of them will manage to bind the port, all others
will fail.
We can do better: tests should run their background application on a random available port.
First of all we need to change our run function - it should take the application address as an argument instead
of relying on a hard-coded value:
//! src/lib.rs
// [...]
fn spawn_app() {
let server = zero2prod::run("127.0.0.1:0").expect("Failed to bind address");
3.5. IMPLEMENTING OUR FIRST INTEGRATION TEST 37
let _ = tokio::spawn(server);
}
Done - the background app now runs on a random port every time we launch cargo test!
There is only a small issue… our test is failing5 !
running 1 test
test health_check_works ... FAILED
failures:
failures:
health_check_works
Our HTTP client is still calling 127.0.0.1:8000 and we really don’t know what to put there now: the
application port is determined at runtime, we cannot hard code it there.
We need, somehow, to find out what port the OS has gifted our application and return it from spawn_app.
There are a few ways to go about it - we will use a std::net::TcpListener.
Our HttpServer right now is doing double duty: given an address, it will bind it and then start the applic-
ation. We can take over the first step: we will bind the port on our own with TcpListener and then hand
5
There is a remote chance that the OS ended up picking 8000 as random port and everything worked out smoothly. Cheers to
you lucky reader!
38 CHAPTER 3. SIGN UP A NEW SUBSCRIBER
use actix_web::dev::Server;
use actix_web::{web, App, HttpResponse, HttpServer};
use std::net::TcpListener;
// [...]
The change broke both our main and our spawn_app function. I’ll leave main to you, let’s focus on
spawn_app:
//! tests/health_check.rs
// [...]
We can now leverage the application address in our test to point our reqwest::Client:
//! tests/health_check.rs
// [...]
3.6. REFOCUS 39
#[tokio::test]
async fn health_check_works() {
// Arrange
let address = spawn_app();
let client = reqwest::Client::new();
// Act
let response = client
// Use the returned application address
.get(&format!("{}/health_check", &address))
.send()
.await
.expect("Failed to execute request.");
// Assert
assert!(response.status().is_success());
assert_eq!(Some(0), response.content_length());
}
All is good - cargo test comes out green. Our setup is much more robust now!
3.6 Refocus
Let’s take a small break to look back, we covered a fair amount of ground!
We set out to implement a /health_check endpoint and that gave us the opportunity to learn more about
the fundamentals of our web framework, actix-web, as well as the basics of (integration) testing for Rust
APIs.
It is now time to capitalise on what we learned to finally fulfill the first user story of our email newsletter
project:
As a blog visitor,
I want to subscribe to the newsletter,
So that I can receive email updates when new content is published on the blog.
We expect our blog visitors to input their email address in a form embedded on a web page.
The form will trigger a POST /subscriptions call to our backend API that will actually process the inform-
ation, store it and send back a response.
We will have to dig into:
• how to read data collected in a HTML form in actix-web (i.e. how do I parse the request body of a
POST?);
• what libraries are available to work with a PostgreSQL database in Rust (diesel vs sqlx vs tokio-
postgres);
40 CHAPTER 3. SIGN UP A NEW SUBSCRIBER
the keys and values [in our form] are encoded in key-value tuples separated by ‘&’, with a ‘=’ between
the key and the value. Non-alphanumeric characters in both keys and values are percent encoded.
For example: if the name is Le Guin and the email is [email protected] the POST request body
should be name=le%20guin&email=ursula_le_guin%40gmail.com (spaces are replaced by %20 while @ be-
comes %40 - a reference conversion table can be found w3schools’s website).
To summarise:
• if a valid pair of name and email is supplied using the application/x-www-form-urlencoded format
the backend should return a 200 OK;
• if either name or email are missing the backend should return a 400 BAD REQUEST.
3.7. WORKING WITH HTML FORMS 41
#[tokio::test]
async fn health_check_works() {
[...]
}
#[tokio::test]
async fn subscribe_returns_a_200_for_valid_form_data() {
// Arrange
let app_address = spawn_app();
let client = reqwest::Client::new();
// Act
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
let response = client
.post(&format!("{}/subscriptions", &app_address))
.header("Content-Type", "application/x-www-form-urlencoded")
.body(body)
.send()
.await
.expect("Failed to execute request.");
// Assert
assert_eq!(200, response.status().as_u16());
}
#[tokio::test]
async fn subscribe_returns_a_400_when_data_is_missing() {
// Arrange
42 CHAPTER 3. SIGN UP A NEW SUBSCRIBER
// Assert
assert_eq!(
400,
response.status().as_u16(),
// Additional customised error message on test failure
"The API did not fail with 400 Bad Request when the payload was {}.",
error_message
);
}
}
failures:
failures:
health_check::subscribe_returns_a_400_when_data_is_missing
subscribe_returns_a_200_for_valid_form_data now passes: well, our handler accepts all incoming data
as valid, no surprises there.
subscribe_returns_a_400_when_data_is_missing, instead, is still red.
Time to do some real parsing on that request body. What does actix-web offer us?
3.7.3.1 Extractors
Quite prominent on actix-web’s User Guide is the Extractors’ section.
Extractors are used, as the name implies, to tell the framework to extract certain pieces of information from
an incoming request.
actix-web provides several extractors out of the box to cater for the most common usecases:
Luckily enough, there is an extractor that serves exactly our usecase: Form.
Reading straight from its documentation:
Example:
use actix_web::web;
#[derive(serde::Deserialize)]
struct FormData {
username: String,
}
So, basically… you just slap it there as an argument of your handler and actix-web, when a request comes in,
will somehow do the heavy-lifting for you. Let’s ride along for now and we will circle back later to understand
what is happening under the hood.
Using the example as a blueprint, we probably want something along these lines:
46 CHAPTER 3. SIGN UP A NEW SUBSCRIBER
//! src/lib.rs
// [...]
#[derive(serde::Deserialize)]
struct FormData {
email: String,
name: String
}
Fair enough: we need to add serde to our dependencies. Let’s add a new line to our Cargo.toml:
[dependencies]
# We need the optional `derive` feature to use `serde`'s procedural macros:
# `#[derive(Serialize)]` and `#[derive(Deserialize)]`.
# The feature is not enabled by default to avoid pulling in
# unnecessary dependencies for projects that do not need it.
serde = { version = "1", features = ["derive"]}
running 3 tests
test health_check_works ... ok
test subscribe_returns_a_200_for_valid_form_data ... ok
test subscribe_returns_a_400_when_data_is_missing ... ok
But why?
It is nothing more than a wrapper: it is generic over a type T which is then used to populate Form’s only field.
Not much to see here.
Where does the extraction magic take place?
async fn from_request(
req: &HttpRequest,
payload: &mut Payload
) -> Result<Self, Self::Error>;
from_request takes as inputs the head of the incoming HTTP request (i.e. HttpRequest) and the bytes of its
payload (i.e. Payload). It then returns Self, if the extraction succeeds, or an error type that can be converted
into actix_web::Error.
All arguments in the signature of a route handler must implement the FromRequest trait: actix-web will
invoke from_request for each argument and, if the extraction succeeds for all of them, it will then run the
actual handler function.
If one of the extractions fails, the corresponding error is returned to the caller and the handler is never invoked
(actix_web::Error can be converted to a HttpResponse).
This is extremely convenient: your handler does not have to deal with the raw incoming request and can
instead work directly with strongly-typed information, significantly simplifying the code that you need to
write to handle a request.
async fn from_request(
req: &HttpRequest,
payload: &mut Payload
) -> Result<Self, Self::Error> {
// Omitted stuff around extractor configuration (e.g. payload size limits)
To understand what is actually going under the hood we need to take a closer look at serde itself.
Serde is a framework for serializing and deserializing Rust data structures efficiently and generically.
3.7.3.3.1 Generically serde does not, by itself, provide support for (de)serialisation from/to any spe-
cific data format: you will not find any code inside serde that deals with the specifics of JSON, Avro or Mes-
sagePack. If you need support for a specific data format, you need to pull in another crate (e.g. serde_json
for JSON or avro-rs for Avro).
serde defines a set of interfaces or, as they themselves call it, a data model.
If you want to implement a library to support serialisation for a new data format, you have to provide an
implementation of the Serializer trait.
Each method on the Serializer trait corresponds to one of the 29 types that form serde’s data model -
your implementation of Serializer specifies how each of those types maps to your specific data format.
For example, if you were adding support for JSON serialisation, your serialize_seq implementation would
output an opening square bracket [ and return a type which can be used to serialize sequence elements.6
On the other side, you have the Serialize trait: your implementation of Serialize::serialize for a Rust
type is meant to specify how to decompose it according to serde’s data model using the methods available
on the Serializer trait.
Using again the sequence example, this is how Serialize is implemented for a Rust vector:
use serde::ser::{Serialize, Serializer, SerializeSeq};
6
You can look at serde_json’s serialize_seq implementation for confirmation. There is an optimisation for empty sequences
(you immediately output []), but that is pretty much what is happening.
50 CHAPTER 3. SIGN UP A NEW SUBSCRIBER
That is what allows serde to be agnostic with respect to data formats: once your type implements Serialize,
you are then free to use any concrete implementation of Serializer to actually perform the serialisation step
- i.e. you can serialize your type to any format for which there is an available Serializer implementation on
crates.io (spoiler: almost all commonly used data formats).
The same is true for deserialisation, via Deserialize and Deserializer, with a few additional details around
lifetimes to support zero-copy deserialisation.
defined in your project. It is tedious, error-prone and it takes time away from the application-specific logic
that you are supposed to be focused on.
Those two procedural macros, bundled with serde behind the derive feature flag, will parse the definition
of your type and automatically generate for you the right Serialize/Deserialize implementation.
This explains why Cloud-native applications are usually stateless: their persistence needs are delegated to
specialised external systems - databases.
If you are uncertain about your persistence requirements, use a relational database.
If you have no reason to expect massive scale, use PostgreSQL.
The offering when it comes to databases has exploded in the last twenty years.
From a data-model perspective, the NoSQL movement has brought us document-stores (e.g. MongoDB),
key-value stores (e.g. AWS DynamoDB), graph databases (e.g. Neo4J), etc.
We have databases that use RAM as their primary storage (e.g. Redis).
We have databases that are optimised for analytical queries via columnar storage (e.g. AWS RedShift).
There is a world of possibilities and you should definitely leverage this richness when designing systems.
Nonetheless, it is much easier to design yourself into a corner by using a specialised data storage solution
when you still do not have a clear picture of the data access patterns used by your application.
Relational databases are reasonably good as jack-of-all-trades: they will often be a good choice when building
the first version of your application, supporting you along the way while you explore the constraints of your
domain9 .
This is how we end up with PostgreSQL: a battle-tested piece of technology, widely supported across all
cloud providers if you need a managed offering, opensource, exhaustive documentation, easy to run locally
and in CI via Docker, well-supported within the Rust ecosystem.
8
Unless we implement some kind of synchronisation protocol between our replicas, which would quickly turn into a badly-
written poor-man-copy of a database.
9
Relational databases provide you with transactions - a powerful mechanism to handle partial failures and manage concurrent
access to shared data. We will discuss transactions in greater detail in Chapter 7.
3.8. STORING DATA: DATABASES 53
All three are massively popular projects that have seen significant adoption with a fair share of production
usage. How do you pick one?
It boils down to how you feel about three topics:
• compile-time safety;
• SQL-first vs a DSL for query building;
• async vs sync interface.
SQL is extremely portable - you can use it in any project where you have to interact with a relational database,
regardless of the programming language or the framework the application is written with.
diesel’s DSL, instead, is only relevant when working with diesel: you have to pay an upfront learning cost
to become fluent with it and it only pays off if you stick to diesel for your current and future projects. It is
also worth pointing out that expressing complex queries using diesel’s DSL can be difficult and you might
end up having to write raw SQL anyway.
On the flip side, diesel’s DSL makes it easier to write reusable components: you can split your complex
queries into smaller units and leverage them in multiple places, as you would do with a normal Rust function.
Your database is not sitting next to your application on the same physical machine host: to run queries you
have to perform network calls.
An asynchronous database driver will not reduce how long it takes to process a single query, but it will enable
your application to leverage all CPU cores to perform other meaningful work (e.g. serve another HTTP
request) while waiting for the database to return results.
Is this a significant enough benefit to take onboard the additional complexity introduced by asynchronous
code?
It depends on the performance requirements of your application.
Generally speaking, running queries on a separate threadpool should be more than enough for most usecases.
At the same time, if your web framework is already asynchronous, using an asynchronous database driver
will actually give you fewer headaches11 .
Both sqlx and tokio-postgres provide an asynchronous interface, while diesel is synchronous and does
not plan to roll out async support in the near future.
It is also worth mentioning that tokio-postgres is, at the moment, the only crate that supports query
pipelining. The feature is still at the design stage for sqlx while I could not find it mentioned anywhere in
diesel’s docs or issue tracker.
3.8.2.4 Summary
Let’s summarise everything we covered in a comparison matrix:
#[tokio::test]
async fn subscribe_returns_a_200_for_valid_form_data() {
// Arrange
let app_address = spawn_app();
let client = reqwest::Client::new();
// Act
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
let response = client
.post(&format!("{}/subscriptions", &app_address))
.header("Content-Type", "application/x-www-form-urlencoded")
.body(body)
.send()
.await
.expect("Failed to execute request.");
// Assert
assert_eq!(200, response.status().as_u16());
}
We want to check if the details of our new subscriber have actually been persisted.
How do we go about it?
1. leverage another endpoint of our public API to inspect the application state;
2. query directly the database in our test case.
Option 1 should be your go-to when possible: your tests remain oblivious to the implementation details of
the API (e.g. the underlying database technology and its schema) and are therefore less likely to be disrupted
by future refactorings.
Unfortunately we do not have any public endpoint on our API that allows us to verify if a subscriber exists.
We could add a GET /subscriptions endpoint to fetch the list of existing subscribers, but we would then
have to worry about securing it: we do not want to have the names and emails of our subscribers exposed on
the public internet without any form of authentication.
We will probably end up writing a GET /subscriptions endpoint down the line (i.e. we do not want to log
into our production database to check the list of our subscribers), but we should not start writing a new
feature just to test the one we are working on.
Let’s bite the bullet and write a small query in our test. We will remove it down the line when a better testing
strategy becomes available.
3.8.4.1 Docker
To run Postgres we will use Docker - before launching our test suite we will launch a new Docker container
using Postgres’ official Docker image.
You can follow the instructions on Docker’s website to install it on your machine.
Let’s create a small bash script for it, scripts/init_db.sh, with a few knobs to customise Postgres’ default
settings:
#!/usr/bin/env bash
set -x
set -eo pipefail
12
I do not belong to the “in-memory test database” school of thought: whenever possible you should strive to use the same
database both for your tests and your production environment. I have been burned one time too many by differences between the
in-memory stub and the real database engine to believe it provides any kind of benefit over using “the real thing”.
3.8. STORING DATA: DATABASES 57
DB_PORT="${POSTGRES_PORT:=5432}"
# Check if a custom host has been set, otherwise default to 'localhost'
DB_HOST="${POSTGRES_HOST:=localhost}"
If you run docker ps you should see something along the lines of
N.B. - the port mapping bit could be slightly different if you are not using Linux!
3.8.4.2.1 sqlx-cli sqlx provides a command-line interface, sqlx-cli, to manage database migrations.
3.8.4.2.2 Database Creation The first command we will usually want to run is sqlx database create.
According to the help docs:
58 CHAPTER 3. SIGN UP A NEW SUBSCRIBER
sqlx-database-create
Creates the database specified in your DATABASE_URL
USAGE:
sqlx database create
FLAGS:
-h, --help Prints help information
-V, --version Prints version information
In our case, this is not strictly necessary: our Postgres Docker instance already comes with a default data-
base named newsletter, thanks to the settings we specified when launching it using environment variables.
Nonetheless, you will have to go through the creation step in your CI pipeline and in your production en-
vironment, so worth covering it anyway.
As the help docs imply, sqlx database create relies on the DATABASE_URL environment variable to know
what to do.
DATABASE_URL is expected to be a valid Postgres connection string - the format is as follows:
postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
export DATABASE_URL
sqlx database create
You might run into an annoying issue from time to time: the Postgres container will not be ready to accept
connections when we try to run sqlx database create.
It happened to me often enough to look for a workaround: we need to wait for Postgres to be healthy before
starting to run commands against it. Let’s update our script to:
#!/usr/bin/env bash
set -x
set -eo pipefail
DB_USER="${POSTGRES_USER:=postgres}"
DB_PASSWORD="${POSTGRES_PASSWORD:=password}"
DB_NAME="${POSTGRES_DB:=newsletter}"
DB_PORT="${POSTGRES_PORT:=5432}"
DB_HOST="${POSTGRES_HOST:=localhost}"
13
If you run the script again now it will fail because there is a Docker container with same name already running! You have to
stop/kill it before running the updated version of the script.
3.8. STORING DATA: DATABASES 59
docker run \
-e POSTGRES_USER=${DB_USER} \
-e POSTGRES_PASSWORD=${DB_PASSWORD} \
-e POSTGRES_DB=${DB_NAME} \
-p "${DB_PORT}":5432 \
-d postgres \
postgres -N 1000
DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
export DATABASE_URL
sqlx database create
Problem solved!
The health check uses psql, the command line client for Postgres. Check these instructions on how to install
it on your OS.
Scripts do not come bundled with a manifest to declare their dependencies: it’s unfortunately very common
to launch a script without having installed all the prerequisites. This will usually result in the script crashing
mid-execution, sometimes leaving stuff in our system in a half-broken state.
We can do better in our initialization script: let’s check that both psql and sqlx-cli are installed at the very
beginning.
set -x
set -eo pipefail
exit 1
fi
3.8.4.2.3 Adding A Migration Let’s create our first migration now with
# Assuming you used the default parameters to launch Postgres in Docker!
export DATABASE_URL=postgres://postgres:[email protected]:5432/newsletter
sqlx migrate add create_subscriptions_table
A new top-level directory should have now appeared in your project - migrations. This is where all
migrations for our project will be stored by sqlx’s CLI.
Under migrations you should already have one file called {timestamp}_create_subscriptions_table.sql
- this is where we have to write the SQL code for our first migration.
There is a endless debate when it comes to primary keys: some people prefer to use columns with a business
meaning (e.g. email, a natural key), others feel safer with a synthetic key without any business meaning
(e.g. id, a randomly generated UUID, a surrogate key).
I generally default to a synthetic identifier unless I have a very compelling reason not to - feel free to disagree
with me here.
• we are keeping track of when a subscription is created with subscribed_at (timestamptz is a time-
zone aware date and time type);
• we are enforcing email uniqueness at the database-level with a UNIQUE constraint;
• we are enforcing that all fields should be populated with a NOT NULL constraint on each column;
• we are using TEXT for email and name because we do not have any restriction on their maximum
lengths.
Database constraints are useful as a last line of defence from application bugs but they come at a cost - the
database has to ensure all checks pass before writing new data into the table. Therefore constraints impact
our write-throughput, i.e. the number of rows we can INSERT/UPDATE per unit of time in a table.
3.8. STORING DATA: DATABASES 61
UNIQUE, in particular, introduces an additional B-tree index on our email column: the index has to be up-
dated on every INSERT/UPDATE/DELETE query and it takes space on disk.
In our specific case, I would not be too worried: our mailing list would have to be incredibly popular for
us to encounter issues with our write throughput. Definitely a good problem to have, if it comes to that.
3.8.4.2.4 Running Migrations We can run migrations against our database with
sqlx migrate run
It has the same behaviour of sqlx database create - it will look at the DATABASE_URL environment variable
to understand what database needs to be migrated.
Let’s add it to our scripts/init_db.sh script:
#!/usr/bin/env bash
set -x
set -eo pipefail
DB_USER="${POSTGRES_USER:=postgres}"
DB_PASSWORD="${POSTGRES_PASSWORD:=password}"
DB_NAME="${POSTGRES_DB:=newsletter}"
DB_PORT="${POSTGRES_PORT:=5432}"
DB_HOST="${POSTGRES_HOST:=localhost}"
-e POSTGRES_DB=${DB_NAME} \
-p "${DB_PORT}":5432 \
-d postgres \
postgres -N 1000
fi
export PGPASSWORD="${DB_PASSWORD}"
until psql -h "${DB_HOST}" -U "${DB_USER}" -p "${DB_PORT}" -d "postgres" -c '\q'; do
>&2 echo "Postgres is still unavailable - sleeping"
sleep 1
done
>&2 echo "Postgres is up and running on port ${DB_PORT} - running migrations now!"
DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
export DATABASE_URL
sqlx database create
sqlx migrate run
We have put the docker run command behind a SKIP_DOCKER flag to make it easy to run migrations
against an existing Postgres instance without having to tear it down manually and re-create it with
scripts/init_db.sh. It will also be useful in CI, if Postgres is not spun up by our script.
If you check your database using your favourite graphic interface for Postgres you will now see a sub-
scriptions table alongside a brand new _sqlx_migrations table: this is where sqlx keeps track of
what migrations have been run against your database - it should contain a single row now for our cre-
ate_subscriptions_table migration.
[dependencies]
# [...]
Yeah, there are a lot of feature flags. Let’s go through all of them one by one:
• runtime-tokio-rustls tells sqlx to use the tokio runtime for its futures and rustls as TLS
backend;
• macros gives us access to sqlx::query! and sqlx::query_as!, which we will be using extensively;
• postgres unlocks Postgres-specific functionality (e.g. non-standard SQL types);
• uuid adds support for mapping SQL UUIDs to the Uuid type from the uuid crate. We need it to work
with our id column;
• chrono adds support for mapping SQL timestamptz to the DateTime<T> type from the chrono crate.
We need it to work with our subscribed_at column;
• migrate gives us access to the same functions used under the hood by sqlx-cli to manage migrations.
It will turn out to be useful for our test suite.
These should be enough for what we need to do in this chapter.
We do not need anything fancy for the time being: a single configuration file will do.
3.8.5.2.1 Making Space Right now all our application code lives in a single file, lib.rs.
Let’s quickly split it into multiple sub-modules to avoid chaos now that we are adding new functionality.
We want to land on this folder structure:
src/
configuration.rs
lib.rs
main.rs
routes/
mod.rs
health_check.rs
subscriptions.rs
startup.rs
startup.rs will host our run function, health_check goes into routes/health_check.rs, subscribe and
FormData into routes/subscriptions.rs, configuration.rs starts empty. Both handlers are re-exported
in routes/mod.rs:
//! src/routes/mod.rs
mod health_check;
mod subscriptions;
You might have to add a few pub visibility modifiers here and there, as well as performing a few corrections
to use statements in main.rs and tests/health_check.rs.
Make sure cargo test comes out green before moving forward.
3.8.5.2.2 Reading A Configuration File To manage configuration with config we must represent our
application settings as a Rust type that implements serde’s Deserialize trait.
Let’s create a new Settings struct:
//! src/configuration.rs
#[derive(serde::Deserialize)]
pub struct Settings {}
3.8. STORING DATA: DATABASES 65
#[derive(serde::Deserialize)]
pub struct DatabaseSettings {
pub username: String,
pub password: String,
pub port: u16,
pub host: String,
pub database_name: String,
}
It makes sense: all fields in a type have to be deserialisable in order for the type as a whole to be deserialisable.
We have our configuration type, what now?
First of all, let’s add config to our dependencies with
#! Cargo.toml
# [...]
[dependencies]
config = "0.13"
66 CHAPTER 3. SIGN UP A NEW SUBSCRIBER
# [...]
We want to read our application settings from a configuration file named configuration.yaml:
//! src/configuration.rs
// [...]
Let’s modify our main function to read configuration as its first step:
//! src/main.rs
use std::net::TcpListener;
use zero2prod::startup::run;
use zero2prod::configuration::get_configuration;
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
// Panic if we can't read configuration
let configuration = get_configuration().expect("Failed to read configuration.");
// We have removed the hard-coded `8000` - it's now coming from our settings!
let address = format!("127.0.0.1:{}", configuration.application_port);
let listener = TcpListener::bind(address)?;
run(listener)?.await
}
If you try to launch the application with cargo run it should crash:
Running `target/debug/zero2prod`
#[tokio::test]
async fn subscribe_returns_a_200_for_valid_form_data() {
// Arrange
let app_address = spawn_app();
let configuration = get_configuration().expect("Failed to read configuration");
68 CHAPTER 3. SIGN UP A NEW SUBSCRIBER
// Act
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
let response = client
.post(&format!("{}/subscriptions", &app_address))
.header("Content-Type", "application/x-www-form-urlencoded")
.body(body)
.send()
.await
.expect("Failed to execute request.");
// Assert
assert_eq!(200, response.status().as_u16());
}
#[tokio::test]
async fn subscribe_returns_a_200_for_valid_form_data() {
// [...]
// The connection has to be marked as mutable!
let mut connection = ...
// Assert
assert_eq!(200, response.status().as_u16());
.fetch_one(&mut connection)
.await
.expect("Failed to fetch saved subscription.");
assert_eq!(saved.email, "[email protected]");
assert_eq!(saved.name, "le guin");
}
What is the type of saved? The query! macro returns an anonymous record type: a struct definition is
generated at compile-time after having verified that the query is valid, with a member for each column on
the result (i.e. saved.email for the email column).
If we try to run cargo test we will get an error:
As we discussed before, sqlx reaches out to Postgres at compile-time to check that queries are well-formed.
Just like sqlx-cli commands, it relies on the DATABASE_URL environment variable to know where to find
the database.
We could export DATABASE_URL manually, but we would then run in the same issue every time we boot our
machine and start working on this project. Let’s take the advice of sqlx’s authors - we’ll add a top-level .env
file
DATABASE_URL="postgres://postgres:password@localhost:5432/newsletter"
sqlx will read DATABASE_URL from it and save us the hassle of re-exporting the environment variable every
single time.
It feels a bit dirty to have the database connection parameters in two places (.env and configuration.yaml),
but it is not a major problem: configuration.yaml can be used to alter the runtime behaviour of the ap-
plication after it has been compiled, while .env is only relevant for our development process, build and test
steps.
Commit the .env file to version control - we will need it in CI soon enough!
Let’s try to run cargo test again:
running 3 tests
test health_check_works ... ok
test subscribe_returns_a_400_when_data_is_missing ... ok
test subscribe_returns_a_200_for_valid_form_data ... FAILED
70 CHAPTER 3. SIGN UP A NEW SUBSCRIBER
failures:
failures:
subscribe_returns_a_200_for_valid_form_data
#[derive(serde::Deserialize)]
pub struct FormData {
email: String,
name: String,
}
To execute a query within subscribe we need to get our hands on a database connection.
Let’s figure out how to get one.
//! src/startup.rs
pub fn run(
listener: TcpListener,
// New parameter!
connection: PgConnection
) -> Result<Server, std::io::Error> {
let server = HttpServer::new(|| {
App::new()
.route("/health_check", web::get().to(health_check))
.route("/subscriptions", web::post().to(subscribe))
// Register the connection as part of the application state
.app_data(connection)
})
.listen(listener)?
.run();
Ok(server)
}
| __________________^^^^^^^^^^^^^^^_-
| | |
| | within `[closure@src/startup.rs:8:34: 13:6 PgConnection]`,
| | the trait `std::clone::Clone` is not implemented
| | for `PgConnection`
9 | | App::new()
10 | | .route("/health_check", web::get().to(health_check))
11 | | .route("/subscriptions", web::post().to(subscribe))
12 | | .app_data(connection)
13 | | })
| |_____- within this `[closure@src/startup.rs:8:34: 13:6 PgConnection]`
|
= note: required because it appears within the type
`[closure@src/startup.rs:8:34: 13:6 PgConnection]`
= note: required by `actix_web::server::HttpServer::<F, I, S, B>::new`
HttpServer::new does not take App as argument - it wants a closure that returns an App struct.
This is to support actix-web’s runtime model: actix-web will spin up a worker process for each available
core on your machine.
Each worker runs its own copy of the application built by HttpServer calling the very same closure that
HttpServer::new takes as argument.
That is why connection has to be cloneable - we need to have one for every copy of App.
But, as we said, PgConnection does not implement Clone because it sits on top of a non-cloneable system
resource, a TCP connection with Postgres. What do we do?
We can use web::Data, another actix-web extractor.
web::Data wraps our connection in an Atomic Reference Counted pointer, an Arc: each instance of the
application, instead of getting a raw copy of a PgConnection, will get a pointer to one.
Arc<T> is always cloneable, no matter who T is: cloning an Arc increments the number of active references
and hands over a new copy of the memory address of the wrapped value.
Handlers can then access the application state using the same extractor.
Let’s give it a try:
//! src/startup.rs
use crate::routes::{health_check, subscribe};
use actix_web::dev::Server;
use actix_web::{web, App, HttpServer};
use sqlx::PgConnection;
use std::net::TcpListener;
pub fn run(
listener: TcpListener,
connection: PgConnection
) -> Result<Server, std::io::Error> {
// Wrap the connection in a smart pointer
let connection = web::Data::new(connection);
// Capture `connection` from the surrounding environment
let server = HttpServer::new(move || {
App::new()
74 CHAPTER 3. SIGN UP A NEW SUBSCRIBER
.route("/health_check", web::get().to(health_check))
.route("/subscriptions", web::post().to(subscribe))
// Get a pointer copy and attach it to the application state
.app_data(connection.clone())
})
.listen(listener)?
.run();
Ok(server)
}
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
let configuration = get_configuration().expect("Failed to read configuration.");
let connection = PgConnection::connect(
&configuration.database.connection_string()
)
.await
.expect("Failed to connect to Postgres.");
let address = format!("127.0.0.1:{}", configuration.application_port);
let listener = TcpListener::bind(address)?;
run(listener, connection)?.await
}
Perfect, it compiles.
3.9. PERSISTING A NEW SUBSCRIBER 75
//! src/routes/subscriptions.rs
use sqlx::PgConnection;
// [...]
web::Data, when a new request comes in, computes the TypeId of the type you specified in the signature (in
our case PgConnection) and checks if there is a record corresponding to it in the type-map. If there is one, it
casts the retrieved Any value to the type you specified (TypeId is unique, nothing to worry about) and passes
it to your handler.
It is an interesting technique to perform what in other language ecosystems might be referred to as depend-
ency injection.
Uuid::new_v4(),
form.email,
form.name,
Utc::now()
)
// We use `get_ref` to get an immutable reference to the `PgConnection`
// wrapped by `web::Data`.
.execute(connection.get_ref())
.await;
HttpResponse::Ok().finish()
}
• we are binding dynamic data to our INSERT query. $1 refers to the first argument passed to query!
after the query itself, $2 to the second and so forth. query! verifies at compile-time that the provided
number of arguments matches what the query expects as well as that their types are compatible
(e.g. you can’t pass a number as id);
• we are generating a random Uuid for id;
• we are using the current timestamp in the Utc timezone for subscribed_at.
We have to add two new dependencies as well to our Cargo.toml to fix the obvious compiler errors:
[dependencies]
# [...]
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4.22", default-features = false, features = ["clock"] }
execute wants an argument that implements sqlx’s Executor trait and it turns out, as we should have prob-
ably remembered from the query we wrote in our test, that &PgConnection does not implement Executor -
only &mut PgConnection does.
Why is that the case?
sqlx has an asynchronous interface, but it does not allow you to run multiple queries concurrently over the
same database connection.
Requiring a mutable reference allows them to enforce this guarantee in their API. You can think of a mutable
reference as a unique reference: the compiler guarantees to execute that they have indeed exclusive access to
that PgConnection because there cannot be two active mutable references to the same value at the same time
in the whole program. Quite neat.
Nonetheless it might look like we designed ourselves into a corner: web::Data will never give us mutable
access to the application state.
We could leverage interior mutability - e.g. putting our PgConnection behind a lock (e.g. a Mutex) would
allow us to synchronise access to the underlying TCP socket and get a mutable reference to the wrapped
connection once the lock has been acquired.
We could make it work, but it would not be ideal: we would be constrained to run at most one query at a
time. Not great.
Let’s take a second look at the documentation for sqlx’s Executor trait: what else implements Executor
apart from &mut PgConnection?
Bingo: a shared reference to PgPool.
PgPool is a pool of connections to a Postgres database. How does it bypass the concurrency issue that we
just discussed for PgConnection?
There is still interior mutability at play, but of a different kind: when you run a query against a &PgPool,
sqlx will borrow a PgConnection from the pool and use it to execute the query; if no connection is available,
it will create a new one or wait until one frees up.
This increases the number of concurrent queries that our application can run and it also improves its resili-
ency: a single slow query will not impact the performance of all incoming requests by creating contention
on the connection lock.
Let’s refactor run, main and subscribe to work with a PgPool instead of a single PgConnection:
//! src/main.rs
use zero2prod::configuration::get_configuration;
use zero2prod::startup::run;
use sqlx::PgPool;
use std::net::TcpListener;
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
let configuration = get_configuration().expect("Failed to read configuration.");
// Renamed!
let connection_pool = PgPool::connect(
&configuration.database.connection_string()
78 CHAPTER 3. SIGN UP A NEW SUBSCRIBER
)
.await
.expect("Failed to connect to Postgres.");
let address = format!("127.0.0.1:{}", configuration.application_port);
let listener = TcpListener::bind(address)?;
run(listener, connection_pool)?.await
}
//! src/startup.rs
use crate::routes::{health_check, subscribe};
use actix_web::dev::Server;
use actix_web::{web, App, HttpServer};
use sqlx::PgPool;
use std::net::TcpListener;
pub fn run(
listener: TcpListener, db_pool: PgPool
) -> Result<Server, std::io::Error> {
// Wrap the pool using web::Data, which boils down to an Arc smart pointer
let db_pool = web::Data::new(db_pool);
let server = HttpServer::new(move || {
App::new()
.route("/health_check", web::get().to(health_check))
.route("/subscriptions", web::post().to(subscribe))
.app_data(db_pool.clone())
})
.listen(listener)?
.run();
Ok(server)
}
//! src/routes/subscriptions.rs
// No longer importing PgConnection!
use sqlx::PgPool;
// [...]
HttpResponse::Ok().finish()
}
The compiler is almost happy: cargo check has a warning for us.
sqlx::query may fail - it returns a Result, Rust’s way to model fallible functions.
The compiler is reminding us to handle the error case - let’s follow the advice:
//! src/routes/subscriptions.rs
// [...]
cargo check is satisfied, but the same cannot be said for cargo test:
80 CHAPTER 3. SIGN UP A NEW SUBSCRIBER
All test cases have then to be updated accordingly - an off-screen exercise that I leave to you, my dear reader.
Let’s just have a look together at what subscribe_returns_a_200_for_valid_form_data looks like after
the required changes:
//! tests/health_check.rs
// [...]
#[tokio::test]
async fn subscribe_returns_a_200_for_valid_form_data() {
// Arrange
let app = spawn_app().await;
let client = reqwest::Client::new();
// Act
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
let response = client
.post(&format!("{}/subscriptions", &app.address))
.header("Content-Type", "application/x-www-form-urlencoded")
82 CHAPTER 3. SIGN UP A NEW SUBSCRIBER
.body(body)
.send()
.await
.expect("Failed to execute request.");
// Assert
assert_eq!(200, response.status().as_u16());
assert_eq!(saved.email, "[email protected]");
assert_eq!(saved.name, "le guin");
}
The test intent is much clearer now that we got rid of most of the boilerplate related to establishing the
connection with the database.
TestApp is foundation we will be building on going forward to pull out supporting functionality that is
useful to most of our integration tests.
The moment of truth has finally come: is our updated subscribe implementation enough to turn sub-
scribe_returns_a_200_for_valid_form_data green?
running 3 tests
test health_check_works ... ok
test subscribe_returns_a_400_when_data_is_missing ... ok
test subscribe_returns_a_200_for_valid_form_data ... ok
Yesssssssss!
Success!
Let’s run it again to bathe in the light of this glorious moment!
cargo test
running 3 tests
test health_check_works ... ok
Failed to execute query: error returned from database:
duplicate key value violates unique constraint "subscriptions_email_key"
thread 'subscribe_returns_a_200_for_valid_form_data'
panicked at 'assertion failed: `(left == right)`
left: `200`,
3.10. UPDATING OUR TESTS 83
failures:
failures:
subscribe_returns_a_200_for_valid_form_data
The best place to do this is spawn_app, before launching our actix-web test application.
Let’s look at it again:
//! tests/health_check.rs
use zero2prod::configuration::get_configuration;
use zero2prod::startup::run;
use sqlx::PgPool;
use std::net::TcpListener;
use uuid::Uuid;
// [...]
impl DatabaseSettings {
pub fn connection_string(&self) -> String {
format!(
"postgres://{}:{}@{}:{}/{}",
self.username, self.password, self.host, self.port, self.database_name
)
}
Omitting the database name we connect to the Postgres instance, not a specific logical database.
We can now use that connection to create the database we need and run migrations on it:
//! tests/health_check.rs
// [...]
use sqlx::{Connection, Executor, PgConnection, PgPool};
use zero2prod::configuration::{get_configuration, DatabaseSettings};
// Migrate database
let connection_pool = PgPool::connect(&config.connection_string())
.await
.expect("Failed to connect to Postgres.");
sqlx::migrate!("./migrations")
.run(&connection_pool)
.await
.expect("Failed to migrate the database");
connection_pool
}
sqlx::migrate! is the same macro used by sqlx-cli when executing sqlx migrate run - no need to throw
bash scripts into the mix to achieve the same result.
running 3 tests
test subscribe_returns_a_200_for_valid_form_data ... ok
test subscribe_returns_a_400_when_data_is_missing ... ok
test health_check_works ... ok
You might have noticed that we do not perform any clean-up step at the end of our tests - the logical databases
we create are not being deleted. This is intentional: we could add a clean-up step, but our Postgres instance
is used only for test purposes and it’s easy enough to restart it if, after hundreds of test runs, performance
3.11. SUMMARY 87
3.11 Summary
We covered a large number of topics in this chapter: actix-web extractors and HTML forms,
(de)serialisation with serde, an overview of the available database crates in the Rust ecosystem, the funda-
mentals of sqlx as well as basic techniques to ensure test isolation when dealing with databases.
Take your time to digest the material and go back to review individual sections if necessary.
88 CHAPTER 3. SIGN UP A NEW SUBSCRIBER
Chapter 4
Telemetry
In Chapter 3 we managed to put together a first implementation of POST /subscriptions to fulfill one of
the user stories of our email newsletter project:
As a blog visitor,
I want to subscribe to the newsletter,
So that I can receive email updates when new content is published on the blog.
We have not yet created a web page with a HTML form to actually test the end-to-end flow, but we have a
few black-box integration tests that cover the two basic scenarios we care about at this stage:
• if valid form data is submitted (i.e. both name and email have been provided), the data is saved in our
database;
• if the submitted form is incomplete (e.g. the email is missing, the name is missing or both), the API
returns a 400.
Should we be satisfied and rush to deploy the first version of our application on the coolest cloud provider
out there?
Not yet - we are not yet equipped to properly run our software in a production environment.
We are blind: the application is not instrumented yet and we are not collecting any telemetry data, making
us vulnerable to unknown unknowns.
If most of the previous sentence makes little to no sense to you, do not worry: getting to the bottom of it is
going to be the main focus of this chapter.
89
90 CHAPTER 4. TELEMETRY
I can point at a few blind spots based on the work we have done so far and my past experiences:
• what happens if we lose connection to the database? Does sqlx::PgPool try to automatically recover
or will all database interactions fail from that point onwards until we restart the application?
• what happens if an attacker tries to pass malicious payloads in the body of the POST /subscriptions
request (i.e. extremely large payloads, attempts to perform SQL injection, etc.)?
These are often referred to as known unknowns: shortcomings that we are aware of and we have not yet
managed to investigate or we have deemed to be not relevant enough to spend time on.
Given enough time and effort, we could get rid of most known unknowns.
Unfortunately there are issues that we have not seen before and we are not expecting, unknown unknowns.
Sometimes experience is enough to transform an unknown unknown into a known unknown: if you had
never worked with a database before you might have not thought about what happens when we lose connec-
tion; once you have seen it happen once, it becomes a familiar failure mode to look out for.
More often than not, unknown unknowns are peculiar failure modes of the specific system we are working
on.
They are problems at the crossroads between our software components, the underlying operating systems,
the hardware we are using, our development process peculiarities and that huge source of randomness known
as “the outside world”.
They might emerge when:
• the system is pushed outside of its usual operating conditions (e.g. an unusual spike of traffic);
• multiple components experience failures at the same time (e.g. a SQL transaction is left hanging while
the database is going through a master-replica failover);
• a change is introduced that moves the system equilibrium (e.g. tuning a retry policy);
• no changes have been introduced for a long time (e.g. applications have not been restarted for weeks
and you start to see all sorts of memory leaks);
• etc.
All these scenarios share one key similarity: they are often impossible to reproduce outside of the live envir-
onment.
What can we do to prepare ourselves to deal with an outage or a bug caused by an unknown unknown?
4.2 Observability
We must assume that we will not be there when an unknown unknown issue arises: it might be late at night,
we might be working on something else, etc.
Even if we were paying attention at the very same moment something starts to go wrong, it often isn’t pos-
sible or practical to attach a debugger to a process running in production (assuming you even know in the
first place which process you should be looking at) and the degradation might affect multiple systems at once.
The only thing we can rely on to understand and debug an unknown unknown is telemetry data: inform-
ation about our running applications that is collected automatically and can be later inspected to answer
questions about the state of the system at a certain point in time.
What questions?
4.3. LOGGING 91
Well, if it is an unknown unknown we do not really know in advance what questions we might need to ask
to isolate its root cause - that’s the whole point.
The goal is to have an observable application.
Quoting from Honeycomb’s observability guide
Observability is about being able to ask arbitrary questions about your environment without — and
this is the key part — having to know ahead of time what you wanted to ask.
“arbitrary” is a strong word - as all absolute statements, it might require an unreasonable investment of both
time and money if we are to interpret it literally.
In practice we will also happily settle for an application that is sufficiently observable to enable us to deliver
the level of service we promised to our users.
In a nutshell, to build an observable system we need:
• to instrument our application to collect high-quality telemetry data;
• access to tools and systems to efficiently slice, dice and manipulate the data to find answers to our
questions.
We will touch upon some of the options available to fulfill the second point, but an exhaustive discussion is
outside of the scope of this book.
Let’s focus on the first for the rest of this chapter.
4.3 Logging
Logs are the most common type of telemetry data.
Even developers who have never heard of observability have an intuitive understanding of the usefulness of
logs: logs are what you look at when stuff goes south to understand what is happening, crossing your fingers
extra hard hoping you captured enough information to troubleshoot effectively.
What are logs though?
The format varies, depending on the epoch, the platform and the technologies you are using.
Nowadays a log record is usually a bunch of text data, with a line break to separate the current record from
the next one. For example
The application is starting on port 8080
Handling a request to /index
Handling a request to /index
Returned a 200 OK
log provides five macros: trace, debug, info, warn and error.
They all do the same thing - emit a log a record - but each of them uses a different log level, as the naming
implies.
trace is the lowest level: trace-level logs are often extremely verbose and have a low signal-to-noise ratio
(e.g. emit a trace-level log record every time a TCP packet is received by a web server).
We then have, in increasing order of severity, debug, info, warn and error.
Error-level logs are used to report serious failures that might have user impact (e.g. we failed to handle an
incoming request or a query to the database timed out).
Let’s look at a quick usage example:
fn fallible_operation() -> Result<String, String> { ... }
pub fn main() {
match fallible_operation() {
Ok(success) => {
log::info!("Operation succeeded: {}", success);
}
Err(err) => {
log::error!("Operation failed: {}", err);
}
}
}
use actix_web::middleware::Logger;
use sqlx::PgPool;
use std::net::TcpListener;
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()
// Middlewares are added using the `wrap` method on `App`
.wrap(Logger::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 can now launch the application using cargo run and fire a quick request with curl
http://127.0.0.1:8000/health_check -v.
The request comes back with a 200 but… nothing happens on the terminal we used to launch our applica-
tion.
No logs. Nothing. Blank screen.
///
/// This is used by the `log_enabled!` macro to allow callers to avoid
/// expensive computation of log message arguments if the message would be
/// discarded anyway.
fn enabled(&self, metadata: &Metadata) -> bool;
At the beginning of your main function you can call the set_logger function and pass an implementation of
the Log trait: every time a log record is emitted Log::log will be called on the logger you provided, therefore
making it possible to perform whatever form of processing of log records you deem necessary.
If you do not call set_logger, then all log records will simply be discarded. Exactly what happened to our
application.
Let’s initialise our logger this time.
There are a few Log implementations available on crates.io - the most popular options are listed in the docu-
mentation of log itself.
We will use env_logger - it works nicely if, as in our case, the main goal is printing all logs records to the
terminal.
env_logger::Logger prints log records to the terminal, using the following format:
It looks at the RUST_LOG environment variable to determine what logs should be printed and what logs should
be filtered out.
RUST_LOG=debug cargo run, for example, will surface all logs at debug-level or higher emitted by our applic-
ation or the crates we are using. RUST_LOG=zero2prod, instead, would filter out all records emitted by our
4.4. INSTRUMENTING POST /SUBSCRIPTIONS 95
dependencies.
Let’s modify our main.rs file as required:
// [...]
use env_logger::Env;
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
// `init` does call `set_logger`, so this is all we need to do.
// We are falling back to printing all logs at info-level or above
// if the RUST_LOG environment variable has not been set.
env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();
// [...]
}
Let’s try to launch the application again using cargo run (equivalent to RUST_LOG=info cargo run given our
defaulting logic). Two log records should show up on your terminal (using a new line break with indentation
to make them fit within the page margins)
[2020-09-21T21:28:40Z INFO actix_server::builder] Starting 12 workers
[2020-09-21T21:28:40Z INFO actix_server::builder] Starting
"actix-web-service-127.0.0.1:8000" service on 127.0.0.1:8000
If we make a request with curl http://127.0.0.1:8000/health_check you should see another log record,
emitted by the Logger middleware we added a few paragraphs ago
[2020-09-21T21:28:43Z INFO actix_web::middleware::logger] 127.0.0.1:47244
"GET /health_check HTTP/1.1" 200 0 "-" "curl/7.61.0" 0.000225
Logs are also an awesome tool to explore how the software we are using works.
Try setting RUST_LOG to trace and launching the application again.
You should see a bunch of registering with poller log records coming from mio, a low-level library for
non-blocking IO, as well as a couple of startup log records for each worker spawned up by actix-web (one
for each physical core available on your machine!).
Insightful things can be learned by playing around with trace-level logs.
As it stands, we would only be emitting a log record when the query succeeds. To capture failures we need
to convert that println statement into an error-level log:
//! src/routes/subscriptions.rs
// [...]
We will happily settle for an application that is sufficiently observable to enable us to deliver the level
of service we promised to our users.
98 CHAPTER 4. TELEMETRY
Hey!
I tried subscribing to your newsletter using my main email address, [email protected], but
the website failed with a weird error. Any chance you could look into what happened?
Best,
Tom
P.S. Keep it up, your blog rocks!
Tom landed on our website and received “a weird error” when he pressed the Submit button.
Our application is sufficiently observable if we can triage the issue from the breadcrumbs of information he
has provided us - i.e. the email address he entered.
Can we do it?
Let’s, first of all, confirm the issue: is Tom registered as a subscriber?
We can connect to the database and run a quick query to double-check that there is no record with
[email protected] as email in our subscribers table.
The issue is confirmed. What now?
None of our logs include the subscriber email address, so we cannot search for it. Dead end.
We could ask Tom to provide additional information: all our log records have a timestamp, maybe if he
remembers around what time he tried to subscribe we can dig something out?
This is a clear indication that our current logs are not good enough.
Let’s improve them:
//! src/routes/subscriptions.rs
//! ..
.execute(pool.get_ref())
.await
{
Ok(_) => {
log::info!("New subscriber details have been saved");
HttpResponse::Ok().finish()
},
Err(e) => {
log::error!("Failed to execute query: {:?}", e);
HttpResponse::InternalServerError().finish()
}
}
}
Much better - we now have a log line that is capturing both name and email.1 .
Is it enough to troubleshoot Tom’s issue?
You can clearly see where a single request begins, what happened while we tried to fulfill it, what we returned
as a response, where the next request begins, etc.
It is easy to follow.
But this is not what it looks like when you are handling multiple requests concurrently:
[.. INFO zero2prod] Receiving request for POST /subscriptions
[.. INFO zero2prod] Receiving request for POST /subscriptions
[.. INFO zero2prod] Adding '[email protected]' 'Tom' as a new subscriber
1
Should we log names and emails? If you are operating in Europe, they generally qualify as Personal Identifiable Information
(PII) and their processing must obey the principles and rules laid out in the General Data Protection Regulation (GDPR). We
should have tight controls around who can access that information, how long we are planning to store it for, procedures to delete
it if the user asks to be forgotten, etc. Generally speaking, there are many types of information that would be useful for debugging
purposes but cannot be logged freely (e.g. passwords) - you will either have to do without them or rely on obfuscation (e.g. token-
ization/pseudonymisation) to strike a balance between security, privacy and usefulness.
100 CHAPTER 4. TELEMETRY
Err(e) => {
log::error!(
"request_id {} - Failed to execute query: {:?}",
request_id,
e
);
HttpResponse::InternalServerError().finish()
}
}
}
We can now search for [email protected] in our logs, find the first record, grab the request_id
and then pull down all the other log records associated with that request.
Well, almost all the logs: request_id is created in our subscribe handler, therefore actix_web’s Logger
middleware is completely unaware of it.
That means that we will not know what status code our application has returned to the user when they tried
to subscribe to our newsletter.
What should we do?
We could bite the bullet, remove actix_web’s Logger, write a middleware to generate a random request iden-
tifier for every incoming request and then write our own logging middleware that is aware of the identifier
and includes it in all log lines.
Could it work? Yes.
Should we do it? Probably not.
down as an argument.
What about log records emitted by the crates we are importing into our project? Should we rewrite those as
well?
It is clear that this approach cannot scale.
Let’s take a step back: what does our code look like?
We have an over-arching task (an HTTP request), which is broken down in a set of sub-tasks (e.g. parse input,
make a query, etc.), which might in turn be broken down in smaller sub-routines recursively.
Each of those units of work has a duration (i.e. a beginning and an end).
Each of those units of work has a context associated to it (e.g. name and email of a new subscriber, re-
quest_id) that is naturally shared by all its sub-units of work.
No doubt we are struggling: log statements are isolated events happening at a defined moment in time that
we are stubbornly trying to use to represent a tree-like processing pipeline.
Logs are the wrong abstraction.
What should we use then?
tracing expands upon logging-style diagnostics by allowing libraries and applications to record
structured events with additional information about temporality and causality — unlike a log mes-
sage, a span in tracing has a beginning and end time, may be entered and exited by the flow of execu-
tion, and may exist within a nested tree of similar spans.
[dependencies]
tracing = { version = "0.1", features = ["log"] }
# [...]
The first migration step is as straight-forward as it gets: search and replace all occurrences of the log string
in our function body with tracing.
//! src/routes/subscriptions.rs
// [...]
4.5. STRUCTURED LOGGING 103
That’s it.
If you run the application and fire a POST /subscriptions request you will see exactly the same logs in your
console. Identical.
Pretty cool, isn’t it?
This works thanks to tracing’s log feature flag, which we enabled in Cargo.toml. It ensures that every time
an event or a span are created using tracing’s macros a corresponding log event is emitted, allowing log’s
loggers to pick up on it (env_logger, in our case).
104 CHAPTER 4. TELEMETRY
We can now start to leverage tracing’s Span to better capture the structure of our program.
We want to create a span that represents the whole HTTP request:
//! src/routes/subscriptions.rs
// [...]
// [...]
// `_request_span_guard` is dropped at the end of `subscribe`
// That's when we "exit" the span
}
referred to as Resource Acquisition Is Initialization (RAII): the compiler keeps track of the lifetime of all
variables and when they go out of scope it inserts a call to their destructor, Drop::drop.
The default implementation of the Drop trait simply takes care of releasing the resources owned by that
variable. We can, though, specify a custom Drop implementation to perform other cleanup operations on
drop - e.g. exiting from a span when the Entered guard gets dropped:
//! `tracing`'s source code
if_log_enabled! {{
if let Some(ref meta) = self.span.meta {
self.span.log(
ACTIVITY_LOG_TARGET,
log::Level::Trace,
format_args!("<- {}", meta.name())
);
}
}}
}
}
Inspecting the source code of your dependencies can often expose some gold nuggets - we just found out
that if the log feature flag is enabled tracing will emit a trace-level log when a span exits.
Let’s give it a go immediately:
RUST_LOG=trace cargo run
Notice how all the information we captured in the span’s context is reported in the emitted log line.
We can closely follow the lifetime of our span using the emitted logs:
• Adding a new subscriber is logged when the span is created;
• We enter the span (->);
• We execute the INSERT query;
• We exit the span (<-);
• We finally close the span (--).
Wait, what is the difference between exiting and closing a span?
Glad you asked!
You can enter (and exit) a span multiple times. Closing, instead, is final: it happens when the span itself is
dropped.
This comes pretty handy when you have a unit of work that can be paused and then resumed - e.g. an asyn-
chronous task!
If we launch the application again with RUST_LOG=trace and try a POST /subscriptions request we will
see logs that look somewhat similar to these:
[.. INFO zero2prod] Adding a new subscriber.; request_id=f349b0fe..
[email protected] subscriber_name=le guin
[.. TRACE zero2prod] -> Adding a new subscriber.
[.. INFO zero2prod] Saving new subscriber details in the database
[.. TRACE zero2prod] -> Saving new subscriber details in the database
[.. TRACE zero2prod] <- Saving new subscriber details in the database
[.. TRACE zero2prod] -> Saving new subscriber details in the database
[.. TRACE zero2prod] <- Saving new subscriber details in the database
[.. TRACE zero2prod] -> Saving new subscriber details in the database
[.. TRACE zero2prod] <- Saving new subscriber details in the database
[.. TRACE zero2prod] -> Saving new subscriber details in the database
[.. TRACE zero2prod] -> Saving new subscriber details in the database
[.. TRACE zero2prod] <- Saving new subscriber details in the database
[.. TRACE zero2prod] -- Saving new subscriber details in the database
[.. TRACE zero2prod] <- Adding a new subscriber.
108 CHAPTER 4. TELEMETRY
We can clearly see how many times the query future has been polled by the executor before completing. How
cool is that!?
We embarked in this migration from log to tracing because we needed a better abstraction to instrument
our code effectively. We wanted, in particular, to attach request_id to all logs associated to the same incom-
ing HTTP request.
Although I promised tracing was going to solve our problem, look at those logs: request_id is only prin-
ted on the very first log statement where we attach it explicitly to the span context.
Why is that?
Well, we haven’t completed our migration yet.
Although we moved all our instrumentation code from log to tracing we are still using env_logger to
process everything!
//! src/main.rs
//! [...]
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
env_logger::from_env(Env::default().default_filter_or("info")).init();
// [...]
}
env_logger’s logger implements log’s Log trait - it knows nothing about the rich structure exposed by tra-
cing’s Span!
tracing’s compatibility with log was great to get off the ground, but it is now time to replace env_logger
with a tracing-native solution.
The tracing crate follows the same facade pattern used by log - you can freely use its macros to instrument
your code, but applications are in charge to spell out how that span telemetry data should be processed.
Subscriber is the tracing counterpart of log’s Log: an implementation of the Subscriber trait exposes a
variety of methods to manage every stage of the lifecycle of a Span - creation, enter/exit, closure, etc.
//! `tracing`'s source code
// [...]
}
The quality of tracing’s documentation is breath-taking - I strongly invite you to have a look for yourself at
Subscriber’s docs to properly understand what each of those methods does.
4.5.6 tracing-subscriber
tracing-subscriber does much more than providing us with a few handy subscribers.
It introduces another key trait into the picture, Layer.
Layer makes it possible to build a processing pipeline for spans data: we are not forced to provide an all-
encompassing subscriber that does everything we want; we can instead combine multiple smaller layers to
obtain the processing pipeline we need.
This substantially reduces duplication across in tracing ecosystem: people are focused on adding new capab-
ilities by churning out new layers rather than trying to build the best-possible-batteries-included subscriber.
The cornerstone of the layering approach is Registry.
Registry implements the Subscriber trait and takes care of all the difficult stuff:
Downstream layers can piggyback on Registry’s functionality and focus on their purpose: filtering what
spans should be processed, formatting span data, shipping span data to remote systems, etc.
4.5.7 tracing-bunyan-formatter
We’d like to put together a subscriber that has feature-parity with the good old env_logger.
We will get there by combining three layers3 :
• tracing_subscriber::filter::EnvFilter discards spans based on their log levels and their origins,
just as we did in env_logger via the RUST_LOG environment variable;
• tracing_bunyan_formatter::JsonStorageLayer processes spans data and stores the associated
metadata in an easy-to-consume JSON format for downstream layers. It does, in particular, propagate
context from parent spans to their children;
3
We are using tracing-bunyan-formatter instead of the formatting layer provided by tracing-subscriber because the latter
does not implement metadata inheritance: it would therefore fail to meet our requirements.
110 CHAPTER 4. TELEMETRY
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
// We removed the `env_logger` line we had before!
// [...]
}
If you launch the application with cargo run and fire a request you’ll see these logs (pretty-printed here to
be easier on the eye):
4
Full disclosure - I am the author of tracing-bunyan-formatter.
4.5. STRUCTURED LOGGING 111
{
"msg": "[ADDING A NEW SUBSCRIBER - START]",
"subscriber_name": "le guin",
"request_id": "30f8cce1-f587-4104-92f2-5448e1cc21f6",
"subscriber_email": "[email protected]"
...
}
{
"msg": "[SAVING NEW SUBSCRIBER DETAILS IN THE DATABASE - START]",
"subscriber_name": "le guin",
"request_id": "30f8cce1-f587-4104-92f2-5448e1cc21f6",
"subscriber_email": "[email protected]"
...
}
{
"msg": "[SAVING NEW SUBSCRIBER DETAILS IN THE DATABASE - END]",
"elapsed_milliseconds": 4,
"subscriber_name": "le guin",
"request_id": "30f8cce1-f587-4104-92f2-5448e1cc21f6",
"subscriber_email": "[email protected]"
...
}
{
"msg": "[ADDING A NEW SUBSCRIBER - END]",
"elapsed_milliseconds": 5
"subscriber_name": "le guin",
"request_id": "30f8cce1-f587-4104-92f2-5448e1cc21f6",
"subscriber_email": "[email protected]",
...
}
We made it: everything we attached to the original context has been propagated to all its sub-spans.
tracing-bunyan-formatter also provides duration out-of-the-box: every time a span is closed a JSON mes-
sage is printed to the console with an elapsed_millisecond property attached to it.
The JSON format is extremely friendly when it comes to searching: an engine like ElasticSearch can easily
ingest all these records, infer a schema and index the request_id, name and email fields. It unlocks the full
power of a querying engine to sift through our logs!
This is exponentially better than we had before: to perform complex searches we would have had to use
custom-built regexes, therefore limiting considerably the range of questions that we could easily ask to our
logs.
112 CHAPTER 4. TELEMETRY
4.5.8 tracing-log
If you take a closer look you will realise we lost something along the way: our terminal is only showing logs
that were directly emitted by our application. What happened to actix-web’s log records?
tracing’s log feature flag ensures that a log record is emitted every time a tracing event happens, allowing
log’s loggers to pick them up.
The opposite does not hold true: log does not emit tracing events out of the box and does not provide a
feature flag to enable this behaviour.
If we want it, we need to explicitly register a logger implementation to redirect logs to our tracing subscriber
for processing.
We can use LogTracer, provided by the tracing-log crate.
#! Cargo.toml
# [...]
[dependencies]
tracing-log = "0.1"
# [...]
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
// Redirect all `log`'s events to our subscriber
LogTracer::init().expect("Failed to set logger");
// [...]
}
cargo-udeps scans your Cargo.toml file and checks if all the crates listed under [dependencies] have actu-
ally been used in the project. Check cargo-deps’ trophy case for a long list of popular Rust projects where
cargo-udeps was able to spot unused dependencies and cut down build times.
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
LogTracer::init().expect("Failed to set logger");
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
let subscriber = get_subscriber("zero2prod".into(), "info".into());
init_subscriber(subscriber);
// [...]
}
116 CHAPTER 4. TELEMETRY
We can now move get_subscriber and init_subscriber to a module within our zero2prod library, tele-
metry.
//! src/lib.rs
pub mod configuration;
pub mod routes;
pub mod startup;
pub mod telemetry;
//! src/telemetry.rs
use tracing::subscriber::set_global_default;
use tracing::Subscriber;
use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer};
use tracing_log::LogTracer;
use tracing_subscriber::{layer::SubscriberExt, EnvFilter, Registry};
pub fn get_subscriber(
name: String,
env_filter: String
) -> impl Subscriber + Sync + Send {
// [...]
}
//! src/main.rs
use zero2prod::configuration::get_configuration;
use zero2prod::startup::run;
use zero2prod::telemetry::{get_subscriber, init_subscriber};
use sqlx::postgres::PgPool;
use std::net::TcpListener;
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
let subscriber = get_subscriber("zero2prod".into(), "info".into());
init_subscriber(subscriber);
// [...]
}
Awesome.
4.5. STRUCTURED LOGGING 117
As a rule of thumb, everything we use in our application should be reflected in our integration tests.
Structured logging, in particular, can significantly speed up our debugging when an integration test fails: we
might not have to attach a debugger, more often than not the logs can tell us where something went wrong.
It is also a good benchmark: if you cannot debug it from logs, imagine how difficult would it be to debug in
production!
Let’s change our spawn_app helper function to take care of initialising our tracing stack:
//! tests/health_check.rs
db_pool: connection_pool,
}
}
// [...]
If you try to run cargo test you will be greeted by one success and a long series of test failures:
failures:
---- subscribe_returns_a_400_when_data_is_missing stdout ----
thread 'subscribe_returns_a_400_when_data_is_missing' panicked at
'Failed to set logger: SetLoggerError(())'
Panic in Arbiter thread.
failures:
subscribe_returns_a_200_for_valid_form_data
subscribe_returns_a_400_when_data_is_missing
init_subscriber should only be called once, but it is being invoked by all our tests.
We can use once_cell to rectify it5 :
#! Cargo.toml
# [...]
[dev-dependencies]
once_cell = "1"
# [...]
//! tests/health_check.rs
// [...]
use once_cell::sync::Lazy;
// Ensure that the `tracing` stack is only initialised once using `once_cell`
static TRACING: Lazy<()> = Lazy::new(|| {
let subscriber = get_subscriber("test".into(), "debug".into());
init_subscriber(subscriber);
5
Given that we never refer to TRACING after its initialization, we could have used std::sync::Once with its call_once
method. Unfortunately, as soon as the requirements change (i.e. you need to use it after initialization), you end up reaching for
std::sync::SyncOnceCell, which is not stable yet. once_cell covers both usecases - this seemed like a great opportunity to intro-
duce a useful crate into your toolkit.
4.5. STRUCTURED LOGGING 119
});
// [...]
}
// [...]
cargo test solves the very same problem for println/print statements. By default, it swallows everything
that is printed to console. You can explicitly opt in to look at those print statements using cargo test --
--nocapture.
pub fn get_subscriber<Sink>(
name: String,
env_filter: String,
sink: Sink,
) -> impl Subscriber + Sync + Send
where
// This "weird" syntax is a higher-ranked trait bound (HRTB)
// It basically means that Sink implements the `MakeWriter`
// trait for all choices of the lifetime parameter `'a`
// Check out https://doc.rust-lang.org/nomicon/hrtb.html
// for more details.
120 CHAPTER 4. TELEMETRY
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
let subscriber = get_subscriber(
"zero2prod".into(), "info".into(), std::io::stdout
);
// [...]
}
In our test suite we will choose the sink dynamically according to an environment variable, TEST_LOG. If
TEST_LOG is set, we use std::io::stdout.
If TEST_LOG is not set, we send all logs into the void using std::io::sink.
Our own home-made version of the --nocapture flag.
//! tests/health_check.rs
//! ...
// Ensure that the `tracing` stack is only initialised once using `once_cell`
static TRACING: Lazy<()> = Lazy::new(|| {
let default_filter_level = "info".to_string();
let subscriber_name = "test".to_string();
// We cannot assign the output of `get_subscriber` to a variable based on the
// value TEST_LOG` because the sink is part of the type returned by
// `get_subscriber`, therefore they are not the same type. We could work around
// it, but this is the most straight-forward way of moving forward.
if std::env::var("TEST_LOG").is_ok() {
let subscriber = get_subscriber(
subscriber_name,
default_filter_level,
std::io::stdout
);
init_subscriber(subscriber);
} else {
4.5. STRUCTURED LOGGING 121
// [...]
When you want to see all logs coming out of a certain test case to debug it you can run
# We are using the `bunyan` CLI to prettify the outputted logs
# The original `bunyan` requires NPM, but you can install a Rust-port with
# `cargo install bunyan`
TEST_LOG=true cargo test health_check_works | bunyan
.execute(pool.get_ref())
.instrument(query_span)
.await
{
Ok(_) => HttpResponse::Ok().finish(),
Err(e) => {
tracing::error!("Failed to execute query: {:?}", e);
HttpResponse::InternalServerError().finish()
}
}
}
It is fair to say logging has added some noise to our subscribe function.
Let’s see if we can cut it down a bit.
We will start with request_span: we’d like all operations within subscribe to happen within the context of
request_span.
In other words, we’d like to wrap the subscribe function in a span.
This requirement is fairly common: extracting each sub-task in its own function is a common way to struc-
ture routines to improve readability and make it easier to write tests; therefore we will often want to attach
a span to a function declaration.
tracing caters for this specific usecase with its tracing::instrument procedural macro. Let’s see it in ac-
tion:
//! src/routes/subscriptions.rs
// [...]
#[tracing::instrument(
name = "Adding a new subscriber",
skip(form, pool),
fields(
request_id = %Uuid::new_v4(),
subscriber_email = %form.email,
subscriber_name = %form.name
)
)]
pub async fn subscribe(
form: web::Form<FormData>,
pool: web::Data<PgPool>,
) -> HttpResponse {
let query_span = tracing::info_span!(
"Saving new subscriber details in the database"
);
match sqlx::query!(/* */)
4.5. STRUCTURED LOGGING 123
.execute(pool.get_ref())
.instrument(query_span)
.await
{
Ok(_) => HttpResponse::Ok().finish(),
Err(e) => {
tracing::error!("Failed to execute query: {:?}", e);
HttpResponse::InternalServerError().finish()
}
}
}
#[tracing::instrument] creates a span at the beginning of the function invocation and automatically at-
taches all arguments passed to the function to the context of the span - in our case, form and pool. Often
function arguments won’t be displayable on log records (e.g. pool) or we’d like to specify more explicitly
what should/how they should be captured (e.g. naming each field of form) - we can explicitly tell tracing
to ignore them using the skip directive.
name can be used to specify the message associated to the function span - if omitted, it defaults to the function
name.
We can also enrich the span’s context using the fields directive. It leverages the same syntax we have already
seen for the info_span! macro.
The result is quite nice: all instrumentation concerns are visually separated by execution concerns - the first
are dealt with in a procedural macro that “decorates” the function declaration, while the function body
focuses on the actual business logic.
It is important to point out that tracing::instrument takes care as well to use Instrument::instrument
if it is applied to an asynchronous function.
Let’s extract the query in its own function and use tracing::instrument to get rid of query_span and the
call to the .instrument method:
//! src/routes/subscriptions.rs
// [...]
#[tracing::instrument(
name = "Adding a new subscriber",
skip(form, pool),
fields(
request_id = %Uuid::new_v4(),
subscriber_email = %form.email,
subscriber_name = %form.name
)
)]
pub async fn subscribe(
124 CHAPTER 4. TELEMETRY
form: web::Form<FormData>,
pool: web::Data<PgPool>,
) -> HttpResponse {
match insert_subscriber(&pool, &form).await
{
Ok(_) => HttpResponse::Ok().finish(),
Err(_) => HttpResponse::InternalServerError().finish()
}
}
#[tracing::instrument(
name = "Saving new subscriber details in the database",
skip(form, pool)
)]
pub async fn insert_subscriber(
pool: &PgPool,
form: &FormData,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO subscriptions (id, email, name, subscribed_at)
VALUES ($1, $2, $3, $4)
"#,
Uuid::new_v4(),
form.email,
form.name,
Utc::now()
)
.execute(pool)
.await
.map_err(|e| {
tracing::error!("Failed to execute query: {:?}", e);
e
// Using the `?` operator to return early
// if the function failed, returning a sqlx::Error
// We will talk about error handling in depth later!
})?;
Ok(())
}
The error event does now fall within the query span and we have a better separation of concerns:
• insert_subscriber takes care of the database logic and it has no awareness of the surrounding web
framework - i.e. we are not passing web::Form or web::Data wrappers as input types;
4.5. STRUCTURED LOGGING 125
• subscribe orchestrates the work to be done by calling the required routines and translates their out-
come into the proper response according to the rules and conventions of the HTTP protocol.
I must confess my unbounded love for tracing::instrument: it significantly lowers the effort required to
instrument your code.
It pushes you in the pit of success: the right thing to do is the easiest thing to do.
You do not want secrets (e.g. a password) or personal identifiable information (e.g. the billing address of an
end user) in your logs.
Opt-out is a dangerous default - every time you add a new input to a function using #[tra-
cing::instrument] you need to ask yourself: is it safe to log this? Should I skip it?
Give it enough time and somebody will forget - you now have a security incident to deal with7 .
You can prevent this scenario by introducing a wrapper type that explicitly marks which fields are considered
to be sensitive - secrecy::Secret.
#! Cargo.toml
# [...]
[dependencies]
secrecy = { version = "0.8", features = ["serde"] }
# [...]
6
There is a chance that tracing’s default behaviour will be changed to be opt-in rather than opt-out in the next breaking release
(0.2.x).
7
Some of these security incidents are pretty severe - e.g. Facebook logged by mistake hundreds of millions of plaintext passwords.
126 CHAPTER 4. TELEMETRY
The only secret value we need to worry about, right now, is the database password. Let’s wrap it up:
//! src/configuration.rs
use secrecy::Secret;
// [..]
#[derive(serde::Deserialize)]
pub struct DatabaseSettings {
// [...]
pub password: Secret<String>,
}
Secret does not interfere with deserialization - Secret implements serde::Deserialize by delegating to
the deserialization logic of the wrapped type (if you enable the serde feature flag, as we did).
The compiler is not happy:
error[E0277]: `Secret<std::string::String>` doesn't implement `std::fmt::Display`
--> src/configuration.rs:29:28
|
| self.username, self.password, self.host, self.port
| ^^^^^^^^^^^^^
| `Secret<std::string::String>` cannot be formatted with the default formatter
That is a feature, not a bug - secrecy::Secret does not implement Display therefore we need to explicitly
allow the exposure of the wrapped secret. The compiler error is a great prompt to notice that the entire
database connection string should be marked as Secret as well given that it embeds the database password:
//! src/configuration.rs
use secrecy::ExposeSecret;
// [...]
impl DatabaseSettings {
pub fn connection_string(&self) -> Secret<String> {
Secret::new(format!(
"postgres://{}:{}@{}:{}/{}",
// [...]
self.password.expose_secret(),
4.5. STRUCTURED LOGGING 127
// [...]
))
}
//! src/main.rs
use secrecy::ExposeSecret;
// [...]
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
// [...]
let connection_pool =
PgPool::connect(&configuration.database.connection_string().expose_secret())
.await
.expect("Failed to connect to Postgres.");
// [...]
}
//! tests/health_check.rs
use secrecy::ExposeSecret;
// [...]
// [...]
}
This is it for the time being - going forward we will make sure to wrap sensitive values into Secret as soon
as they are introduced.
4.5.14 Request Id
We have one last job to do: ensure all logs for a particular request, in particular the record with the returned
status code, are enriched with a request_id property. How?
If our goal is to avoid touching actix_web::Logger the easiest solution is adding another middleware, Re-
questIdMiddleware, that is in charge of:
It is designed as a drop-in replacement of actix-web’s Logger, just based on tracing instead of log:
//! src/startup.rs
use crate::routes::{health_check, subscribe};
use actix_web::dev::Server;
use actix_web::web::Data;
use actix_web::{web, App, HttpServer};
use sqlx::PgPool;
use std::net::TcpListener;
use tracing_actix_web::TracingLogger;
pub fn run(
listener: TcpListener, db_pool: PgPool
) -> Result<Server, std::io::Error> {
let db_pool = Data::new(db_pool);
8
Full disclosure - I am the author of tracing-actix-web.
4.5. STRUCTURED LOGGING 129
If you launch the application and fire a request you should see a request_id on all logs as well as re-
quest_path and a few other useful bits of information.
We are almost done - there is one outstanding issue we need to take care of.
Let’s take a closer look at the emitted log records for a POST /subscriptions request:
{
"msg": "[REQUEST - START]",
"request_id": "21fec996-ace2-4000-b301-263e319a04c5",
...
}
{
"msg": "[ADDING A NEW SUBSCRIBER - START]",
"request_id":"aaccef45-5a13-4693-9a69-5",
...
}
#[tracing::instrument(
name = "Adding a new subscriber",
skip(form, pool),
fields(
request_id = %Uuid::new_v4(),
subscriber_email = %form.email,
subscriber_name = %form.name
)
)]
130 CHAPTER 4. TELEMETRY
// [...]
We are still generating a request_id at the function-level which overrides the request_id coming from
TracingLogger.
Let’s get rid of it to fix the issue:
//! src/routes/subscriptions.rs
// [...]
#[tracing::instrument(
name = "Adding a new subscriber",
skip(form, pool),
fields(
subscriber_email = %form.email,
subscriber_name = %form.name
)
)]
pub async fn subscribe(
form: web::Form<FormData>,
pool: web::Data<PgPool>,
) -> HttpResponse {
// [...]
}
// [...]
All good now - we have one consistent request_id for each endpoint of our application.
lysis;
• tracing-error enriches our error types with a SpanTrace to ease troubleshooting.
It is not an exaggeration to state that tracing is a foundational crate in the Rust ecosystem. While log is
the minimum common denominator, tracing is now established as the modern backbone of the whole
diagnostics and instrumentation ecosystem.
4.6 Summary
We started from a completely silent actix-web application and we ended up with high-quality telemetry
data. It is now time to take this newsletter API live!
In the next chapter we will build a basic deployment pipeline for our Rust project.
132 CHAPTER 4. TELEMETRY
Chapter 5
Going Live
We have a working prototype of our newsletter API - it is now time to take it live.
We will learn how to package our Rust application as a Docker container to deploy it on DigitalOcean’s App
Platform.
At the end of the chapter we will have a Continuous Deployment (CD) pipeline: every commit to the main
branch will automatically trigger the deployment of the latest version of the application to our users.
133
134 CHAPTER 5. GOING LIVE
Nonetheless deployments are a prominent concern in the daily life of a software engineer - e.g. it is difficult to
talk about database schema migrations, domain validation and API evolution without taking into account
your deployment process.
We simply cannot ignore the topic in a book called Zero To Production.
5.3.1 Dockerfiles
A Dockerfile is a recipe for your application environment.
They are organised in layers: you start from a base image (usually an OS enriched with a programming
language toolchain) and execute a series of commands (COPY, RUN, etc.), one after the other, to build the
environment you need.
Let’s have a look at the simplest possible Dockerfile for a Rust project:
# We use the latest Rust stable release as base image
FROM rust:1.72.0
# Copy all files from our working environment to our Docker image
COPY . .
# Let's build our binary!
# We'll use the release profile to make it faaaast
RUN cargo build --release
# When `docker run` is executed, launch the binary!
ENTRYPOINT ["./target/release/zero2prod"]
Save it in a file named Dockerfile in the root directory of our git repository:
zero2prod/
.github/
migrations/
scripts/
src/
tests/
.gitignore
Cargo.lock
Cargo.toml
configuration.yaml
Dockerfile
You could use a different path or even a URL (!) as build context depending on your needs.
# [...]
Step 4/5 : RUN cargo build --release
# [...]
error: error communicating with the database:
Cannot assign requested address (os error 99)
--> src/routes/subscriptions.rs:35:5
|
35 | / sqlx::query!(
36 | | r#"
37 | | INSERT INTO subscriptions (id, email, name, subscribed_at)
38 | | VALUES ($1, $2, $3, $4)
... |
43 | | Utc::now()
44 | | )
| |_____^
|
= note: this error originates in a macro
We could allow our image to talk to a database running on our local machine at build time using the --
network flag. This is the strategy we follow in our CI pipeline given that we need the database anyway to run
our integration tests.
Unfortunately it is somewhat troublesome to pull off for Docker builds due to how Docker networking is
implemented on different operating systems (e.g. MacOS) and would significantly compromise how repro-
ducible our builds are.
sqlx-prepare
Generate query metadata to support offline compile-time verification.
USAGE:
sqlx prepare [FLAGS] [-- <args>...]
ARGS:
<args>...
Arguments to be passed to `cargo rustc ...`
FLAGS:
--check
Run in 'check' mode. Exits with 0 if the query metadata
is up-to-date.
Exits with 1 if the query metadata needs updating
--workspace
Generate a single workspace-level `.sqlx` folder
In other words, prepare performs the same work that is usually done when cargo build is invoked but it
saves the outcome of those queries into a directory (.sqlx) which can later be detected by sqlx itself and
used to skip the queries altogether and perform an offline build.
Let’s commit the entire folder to version control, as the command output suggests.
We can then set the SQLX_OFFLINE environment variable to true in our Dockerfile to force sqlx to look at
the saved metadata instead of trying to query a live database:
5.3. A DOCKERFILE FOR OUR APPLICATION 139
FROM rust:1.72.0
WORKDIR /app
RUN apt update && apt install lld clang -y
COPY . .
ENV SQLX_OFFLINE true
RUN cargo build --release
ENTRYPOINT ["./target/release/zero2prod"]
We can use the tag to refer to the image in other commands. In particular, to run it:
docker run zero2prod
docker run will trigger the execution of the command we specified in our ENTRYPOINT statement:
ENTRYPOINT ["./target/release/zero2prod"]
In our case, it will execute our binary therefore launching our API.
Let’s launch our image then!
You should immediately see an error:
thread 'main' panicked at
'Failed to connect to Postgres:
Io(Os {
code: 99,
kind: AddrNotAvailable,
message: "Cannot assign requested address"
})'
//! src/main.rs
//! [...]
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
// [...]
let connection_pool = PgPool::connect(
&configuration.database.connection_string().expose_secret()
)
.await
.expect("Failed to connect to Postgres.");
// [...]
}
We can relax our requirements by using connect_lazy - it will only try to establish a connection when the
pool is used for the first time.
//! src/main.rs
//! [...]
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
// [...]
// No longer async, given that we don't actually try to connect!
let connection_pool = PgPool::connect_lazy(
&configuration.database.connection_string().expose_secret()
)
.expect("Failed to create Postgres connection pool.");
// [...]
}
We can now re-build the Docker image and run it again: you should immediately see a couple of log lines!
Let’s open another terminal and try to make a request to our health check endpoint:
curl http://127.0.0.1:8000/health_check
Not great.
5.3.5 Networking
By default, Docker images do not expose their ports to the underlying host machine. We need to do it
explicitly using the -p flag.
Let’s kill our running image to launch it again using:
5.3. A DOCKERFILE FOR OUR APPLICATION 141
Trying to hit the health check endpoint will trigger the same error message.
We need to dig into our main.rs file to understand why:
//! src/main.rs
use zero2prod::configuration::get_configuration;
use zero2prod::startup::run;
use zero2prod::telemetry::{get_subscriber, init_subscriber};
use sqlx::postgres::PgPool;
use std::net::TcpListener;
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
let subscriber = get_subscriber(
"zero2prod".into(), "info".into(), std::io::stdout
);
init_subscriber(subscriber);
We are using 127.0.0.1 as our host in address - we are instructing our application to only accept connec-
tions coming from the same machine.
However, we are firing a GET request to /health_check from the host machine, which is not seen as local
by our Docker image, therefore triggering the Connection refused error we have just seen.
We need to use 0.0.0.0 as host to instruct our application to accept connections from any network interface,
not just the local one.
We should be careful though: using 0.0.0.0 significantly increases the “audience” of our application, with
some security implications.
The best way forward is to make the host portion of our address configurable - we will keep using 127.0.0.1
for our local development and set it to 0.0.0.0 in our Docker images.
142 CHAPTER 5. GOING LIVE
#[derive(serde::Deserialize)]
pub struct Settings {
pub database: DatabaseSettings,
pub application_port: u16,
}
#[derive(serde::Deserialize)]
pub struct DatabaseSettings {
pub username: String,
pub password: Secret<String>,
pub port: u16,
pub host: String,
pub database_name: String,
}
// [...]
Let’s introduce another struct, ApplicationSettings, to group together all configuration values related to
our application address:
#[derive(serde::Deserialize)]
pub struct Settings {
pub database: DatabaseSettings,
pub application: ApplicationSettings,
}
#[derive(serde::Deserialize)]
pub struct ApplicationSettings {
pub port: u16,
pub host: String,
}
// [...]
host: 127.0.0.1
database:
# [...]
as well as our main.rs, where we will leverage the new configurable host field:
//! src/main.rs
// [...]
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
// [...]
let address = format!(
"{}:{}",
configuration.application.host, configuration.application.port
);
// [...]
}
The host is now read from configuration, but how do we use a different value for different environments?
We need to make our configuration hierarchical.
Let’s have a look at get_configuration, the function in charge of loading our Settings struct:
//! src/configuration.rs
// [...]
We are reading from a file named configuration to populate Settings’s fields. There is no further room
for tuning the values specified in our configuration.yaml.
Let’s take a more refined approach. We will have:
• A base configuration file, for values that are shared across our local and production environment
(e.g. database name);
144 CHAPTER 5. GOING LIVE
• A collection of environment-specific configuration files, specifying values for fields that require cus-
tomisation on a per-environment basis (e.g. host);
• An environment variable, APP_ENVIRONMENT, to determine the running environment (e.g. produc-
tion or local).
All configuration files will live in the same top-level directory, configuration.
The good news is that config, the crate we are using, supports all the above out of the box!
Let’s put it together:
//! src/configuration.rs
// [...]
settings.try_deserialize::<Settings>()
}
impl Environment {
pub fn as_str(&self) -> &'static str {
match self {
5.3. A DOCKERFILE FOR OUR APPLICATION 145
#! configuration/base.yaml
application:
port: 8000
database:
host: "localhost"
port: 5432
username: "postgres"
password: "password"
database_name: "newsletter"
#! configuration/local.yaml
application:
host: 127.0.0.1
#! configuration/production.yaml
application:
host: 0.0.0.0
146 CHAPTER 5. GOING LIVE
We can now instruct the binary in our Docker image to use the production configuration by setting the
APP_ENVIRONMENT environment variable with an ENV instruction:
FROM rust:1.72.0
WORKDIR /app
RUN apt update && apt install lld clang -y
COPY . .
ENV SQLX_OFFLINE true
RUN cargo build --release
ENV APP_ENVIRONMENT production
ENTRYPOINT ["./target/release/zero2prod"]
curl -v http://127.0.0.1:8000/health_check
It works, awesome!
A 500!
Let’s look at the application logs (useful, aren’t they?)
{
"msg": "[SAVING NEW SUBSCRIBER DETAILS IN THE DATABASE - EVENT] \
Failed to execute query: Io(
Os {
code: 99,
kind: AddrNotAvailable,
message: \"Cannot assign requested address\"
}
)
...
}
This should not come as a surprise - we swapped connect with connect_lazy to avoid dealing with the
database straight away.
There are various ways to get a working local setup using Docker containers:
• Run the application container with --network=host, as we are currently doing for the Postgres con-
tainer;
• Use docker-compose;
• Create a user-defined network.
A working local setup does not get us any closer to having a working database connection when deployed on
Digital Ocean. We will therefore let it be for now.
This is extremely convenient: it can take quite a long time to build our image (and it certainly does in Rust!)
and we only need to pay that cost once.
148 CHAPTER 5. GOING LIVE
To actually use the image we only need to pay for its download cost which is directly related to its size.
How big is our image?
We can find out using
docker images zero2prod
Ok, our final image is almost twice as heavy as our base image.
We can do much better than that!
Our first line of attack is reducing the size of the Docker build context by excluding files that are not needed
to build our image.
Docker looks for a specific file in our project to determine what should be ignored - .dockerignore
Let’s create one in the root directory with the following content:
.env
target/
tests/
Dockerfile
scripts/
migrations/
All files that match the patterns specified in .dockerignore are not sent by Docker as part of the build
context to the image, which means they will not be in scope for COPY instructions.
This will massively speed up our builds (and reduce the size of the final image) if we get to ignore heavy
directories (e.g. the target folder for Rust projects).
The next optimisation, instead, leverages one of Rust’s unique strengths.
Rust’s binaries are statically linked3 - we do not need to keep the source code or intermediate compilation
artifacts around to run the binary, it is entirely self-contained.
This plays nicely with multi-stage builds, a useful Docker feature. We can split our build in two stages:
• a builder stage, to generate a compiled binary;
3
rustc statically links all Rust code but dynamically links libc from the underlying system if you are using the Rust standard
library. You can get a fully statically linked binary by targeting linux-musl; check out Rust’s supported platforms and targets for
more information.
5.3. A DOCKERFILE FOR OUR APPLICATION 149
WORKDIR /app
RUN apt update && apt install lld clang -y
COPY . .
ENV SQLX_OFFLINE true
RUN cargo build --release
# Runtime stage
FROM rust:1.72.0 AS runtime
WORKDIR /app
# Copy the compiled binary from the builder environment
# to our runtime environment
COPY --from=builder /app/target/release/zero2prod zero2prod
# We need the configuration file at runtime!
COPY configuration configuration
ENV APP_ENVIRONMENT production
ENTRYPOINT ["./zero2prod"]
Just ~10 MBs bigger than the size of our base image, much better!
We can go one step further: instead of using rust:1.72.0 for our runtime stage we can switch to
rust:1.72.0-slim, a smaller image using the same underlying OS.
# [...]
# Runtime stage
FROM rust:1.72.0-slim AS runtime
# [...]
150 CHAPTER 5. GOING LIVE
That is 3x smaller than what we had at the beginning - not bad at all!
We can go even smaller by shaving off the weight of the whole Rust toolchain and machinery (i.e. rustc,
cargo, etc) - none of that is needed to run our binary.
We can use the bare operating system as base image (debian:bookworm-slim) for our runtime stage:
# [...]
# Runtime stage
FROM debian:bookworm-slim AS runtime
WORKDIR /app
# Install OpenSSL - it is dynamically linked by some of our dependencies
# Install ca-certificates - it is needed to verify TLS certificates
# when establishing HTTPS connections
RUN apt-get update -y \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
# Clean up
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/zero2prod zero2prod
COPY configuration configuration
ENV APP_ENVIRONMENT production
ENTRYPOINT ["./zero2prod"]
cargo, unfortunately, does not provide a mechanism to build your project dependencies starting from its
Cargo.lock file (e.g. cargo build --only-deps).
Once again, we can rely on a community project to expand cargo’s default capability: cargo-chef5 .
Let’s modify our Dockerfile as suggested in cargo-chef’s README:
FROM lukemathwalker/cargo-chef:latest-rust-1.72.0 as chef
WORKDIR /app
RUN apt update && apt install lld clang -y
5
Full disclosure - I am the author of cargo-chef.
152 CHAPTER 5. GOING LIVE
COPY . .
# Compute a lock-like file for our project
RUN cargo chef prepare --recipe-path recipe.json
We are using three stages: the first computes the recipe file, the second caches our dependencies and then
builds our binary, the third is our runtime environment. As long as our dependencies do not change the
recipe.json file will stay the same, therefore the outcome of cargo chef cook --release --recipe-
path recipe.json will be cached, massively speeding up our builds.
We are taking advantage of how Docker layer caching interacts with multi-stage builds: the COPY . . state-
ment in the planner stage will invalidate the cache for the planner container, but it will not invalidate the
cache for the builder container as long as the checksum of the recipe.json returned by cargo chef pre-
pare does not change.
You can think of each stage as its own Docker image with its own caching - they only interact with each other
when using the COPY --from statement.
This will save us a massive amount of time in the next section.
5.4.1 Setup
You have to sign up on Digital Ocean’s website.
Once you have an account install doctl, Digital Ocean’s CLI - you can find instructions in their document-
ation.
Hosting on Digital Ocean’s App Platform is not free - keeping our app and its associated database
up and running costs roughly 20.00 USD/month.
I suggest you to destroy the app at the end of each session - it should keep your spend way below 1.00
USD. I spent 0.20 USD while playing around with it to write this chapter!
Take your time to go through all the specified values and understand what they are used for.
We can use their CLI, doctl, to create the application for the first time:
doctl apps create --spec spec.yaml
Error: POST
https://api.digitalocean.com/v2/apps: 400 GitHub user not
authenticated
e80... zero2prod
It worked!
You can check your app status with
doctl apps list
If you experience an out-of-memory error when building your Docker image on DigitalOcean, check
out this GitHub issue.
Deployed successfully!
You should be able to see the health check logs coming in every ten seconds or so when Digital Ocean’s
platform pings our application to ensure it is running.
With
doctl apps list
you can retrieve the public facing URI of your application. Something along the lines of
https://zero2prod-aaaaa.ondigitalocean.app
Try firing off a health check request now, it should come back with a 200 OK!
Notice that DigitalOcean took care for us to set up HTTPS by provisioning a certificate and redirecting
HTTPS traffic to the port we specified in the application specification. One less thing to worry about.
The POST /subscriptions endpoint is still failing, in the very same way it did locally: we do not have a live
database backing our application in our production environment.
Let’s provision one.
Add this segment to your spec.yaml file:
databases:
# PG = Postgres
- engine: PG
156 CHAPTER 5. GOING LIVE
# Database name
name: newsletter
# Again, let's keep the bill lean
num_nodes: 1
size: db-s-dev-database
# Postgres version
version: "12"
)
// Add in settings from environment variables (with a prefix of APP and
// '__' as separator)
// E.g. `APP_APPLICATION__PORT=5001 would set `Settings.application.port`
.add_source(
config::Environment::with_prefix("APP")
.prefix_separator("_")
.separator("__")
)
.build()?;
settings.try_deserialize::<Settings>()
}
This allows us to customize any value in our Settings struct using environment variables, overriding what
is specified in our configuration files.
Why is that convenient?
It makes it possible to inject values that are too dynamic (i.e. not known a priori) or too sensitive to be stored
in version control.
It also makes it fast to change the behaviour of our application: we do not have to go through a full re-build
if we want to tune one of those values (e.g. the database port). For languages like Rust, where a fresh build
can take ten minutes or more, this can make the difference between a short outage and a substantial service
degradation with customer-visible impact.
Before we move on let’s take care of an annoying detail: environment variables are strings for the config
crate and it will fail to pick up integers if using the standard deserialization routine from serde.
Luckily enough, we can specify a custom deserialization function.
Let’s add a new dependency, serde-aux (serde auxiliary):
#! Cargo.toml
# [...]
[dependencies]
serde-aux = "4"
# [...]
#[derive(serde::Deserialize)]
pub struct ApplicationSettings {
#[serde(deserialize_with = "deserialize_number_from_string")]
158 CHAPTER 5. GOING LIVE
#[derive(serde::Deserialize)]
pub struct DatabaseSettings {
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16,
// [...]
}
// [...]
Our current DatabaseSettings does not handle SSL mode - it was not relevant for local development, but
it is more than desirable to have transport-level encryption for our client/database communication in pro-
duction.
Before trying to add new functionality, let’s make room for it by refactoring DatabaseSettings.
The current version looks like this:
//! src/configuration.rs
// [...]
#[derive(serde::Deserialize)]
pub struct DatabaseSettings {
pub username: String,
pub password: Secret<String>,
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16,
pub host: String,
pub database_name: String,
}
impl DatabaseSettings {
pub fn connection_string(&self) -> Secret<String> {
// [...]
}
// [...]
}
}
We will change its two methods to return a PgConnectOptions instead of a connection string: it will make
it easier to manage all these moving parts.
//! src/configuration.rs
use sqlx::postgres::PgConnectOptions;
// [...]
impl DatabaseSettings {
// Renamed from `connection_string_without_db`
pub fn without_db(&self) -> PgConnectOptions {
PgConnectOptions::new()
.host(&self.host)
.username(&self.username)
.password(&self.password.expose_secret())
.port(self.port)
}
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
// [...]
// [...]
}
//! tests/health_check.rs
// [...]
160 CHAPTER 5. GOING LIVE
// Migrate database
let connection_pool = PgPool::connect_with(config.with_db())
.await
.expect("Failed to connect to Postgres.");
sqlx::migrate!("./migrations")
.run(&connection_pool)
.await
.expect("Failed to migrate the database");
connection_pool
}
#[derive(serde::Deserialize)]
pub struct DatabaseSettings {
// [...]
// Determine if we demand the connection to be encrypted or not
pub require_ssl: bool,
}
impl DatabaseSettings {
pub fn without_db(&self) -> PgConnectOptions {
let ssl_mode = if self.require_ssl {
PgSslMode::Require
} else {
// Try an encrypted connection, fallback to unencrypted if it fails
PgSslMode::Prefer
5.4. DEPLOY TO DIGITALOCEAN APPS PLATFORM 161
};
PgConnectOptions::new()
.host(&self.host)
.username(&self.username)
.password(&self.password.expose_secret())
.port(self.port)
.ssl_mode(ssl_mode)
}
// [...]
}
We want require_ssl to be false when we run the application locally (and for our test suite), but true in
our production environment.
Let’s amend our configuration files accordingly:
#! configuration/local.yaml
application:
host: 127.0.0.1
database:
# New entry!
require_ssl: false
#! configuration/production.yaml
application:
host: 0.0.0.0
database:
# New entry!
require_ssl: true
We can take the opportunity - now that we are using PgConnectOptions - to tune sqlx’s instrumentation:
lower their logs from INFO to TRACE level.
This will eliminate the noise we noticed in the previous chapter.
//! src/configuration.rs
use sqlx::ConnectOptions;
// [...]
impl DatabaseSettings {
// [...]
pub fn with_db(&self) -> PgConnectOptions {
let mut options = self.without_db().database(&self.database_name);
options.log_statements(tracing_log::log::LevelFilter::Trace);
options
}
}
162 CHAPTER 5. GOING LIVE
The scope is set to RUN_TIME to distinguish between environment variables needed during our Docker build
process and those needed when the Docker image is launched.
We are populating the values of the environment variables by interpolating what is exposed by the Digital
Ocean’s platform (e.g. ${newsletter.PORT}) - refer to their documentation for more details.
6
You will have to temporarily disable Trusted Sources to run the migrations from your local machine.
164 CHAPTER 5. GOING LIVE
Chapter 6
But we have cut a few corners along the way: POST /subscriptions is fairly… permissive.
Our input validation is extremely limited: we just ensure that both the name and the email fields are provided,
nothing else.
We can add a new integration test to probe our API with some “troublesome” inputs:
//! tests/health_check.rs
// [...]
#[tokio::test]
async fn subscribe_returns_a_200_when_fields_are_present_but_empty() {
// Arrange
let app = spawn_app().await;
let client = reqwest::Client::new();
let test_cases = vec![
("name=&email=ursula_le_guin%40gmail.com", "empty name"),
("name=Ursula&email=", "empty email"),
("name=Ursula&email=definitely-not-an-email", "invalid email"),
];
165
166 CHAPTER 6. REJECT INVALID SUBSCRIBERS #1
.body(body)
.send()
.await
.expect("Failed to execute request.");
// Assert
assert_eq!(
200,
response.status().as_u16(),
"The API did not return a 200 OK when the payload was {}.",
description
);
}
}
6.1 Requirements
6.1.1 Domain Constraints
It turns out that names are complicated1 .
Trying to nail down what makes a name valid is a fool’s errand. Remember that we chose to collect a name to
use it in the opening line of our emails - we do not need it to match the real identity of a person, whatever that
means in their geography. It would be totally unnecessary to inflict the pain of incorrect or overly prescriptive
validation on our users.
We could thus settle on simply requiring the name field to be non-empty (as in, it must contain at least a
non-whitespace character).
nasty stuff.
Thanks, but no thanks.
What is likely to happen in our case? What should we brace for in the wild range of possible attacks?2
We are building an email newsletter, which leads us to focus on:
• denial of service - e.g. trying to take our service down to prevent other people from signing up. A
common threat for basically any online service;
• data theft - e.g. steal a huge list of email addresses;
• phishing - e.g. use our service to send what looks like a legitimate email to a victim to trick them into
clicking on some links or perform other actions.
We should always keep in mind that software is a living artifact: holistic understanding of a system is the first
victim of the passage of time.
You have the whole system in your head when writing it down for the first time, but the next developer
touching it will not - at least not from the get-go. It is therefore possible for a load-bearing check in an
obscure corner of the application to disappear (e.g. HTML escaping) leaving you exposed to a class of attacks
(e.g. phishing).
Redundancy reduces risk.
Let’s get to the point - what validation should we perform on names to improve our security posture given
the class of threats we identified?
I suggest:
• Enforcing a maximum length. We are using TEXT as type for our email in Postgres, which is virtually
unbounded - well, until disk storage starts to run out. Names come in all shapes and forms, but 256
characters should be enough for the greatest majority of our users4 - if not, we will politely ask them
to enter a nickname.
• Reject names containing troublesome characters. /()"<>\{} are fairly common in URLs, SQL quer-
ies and HTML fragments - not as much in names5 . Forbidding them raises the complexity bar for
SQL injection and phishing attempts.
2
In a more formalised context you would usually go through a threat-modelling exercise.
3
It is commonly referred to as defense in depth.
4
Hubert B. Wolfe + 666 Sr would have been a victim of our maximum length check.
5
Mandatory xkcd comic.
168 CHAPTER 6. REJECT INVALID SUBSCRIBERS #1
//! src/routes/subscriptions.rs
use actix_web::{web, HttpResponse};
use chrono::Utc;
use sqlx::PgPool;
use uuid::Uuid;
#[derive(serde::Deserialize)]
pub struct FormData {
email: String,
name: String,
}
#[tracing::instrument(
name = "Adding a new subscriber",
skip(form, pool),
fields(
subscriber_email = %form.email,
subscriber_name = %form.name
)
)]
pub async fn subscribe(
form: web::Form<FormData>,
pool: web::Data<PgPool>,
) -> HttpResponse {
match insert_subscriber(&pool, &form).await {
Ok(_) => HttpResponse::Ok().finish(),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
// [...]
pool: web::Data<PgPool>,
) -> HttpResponse {
if !is_valid_name(&form.name) {
return HttpResponse::BadRequest().finish();
}
match insert_subscriber(&pool, &form).await {
Ok(_) => HttpResponse::Ok().finish(),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
/// Returns `true` if the input satisfies all our validation constraints
/// on subscriber names, `false` otherwise.
pub fn is_valid_name(s: &str) -> bool {
// `.trim()` returns a view over the input `s` without trailing
// whitespace-like characters.
// `.is_empty` checks if the view contains any character.
let is_empty_or_whitespace = s.trim().is_empty();
// Iterate over all characters in the input `s` to check if any of them matches
// one of the characters in the forbidden array.
let forbidden_characters = ['/', '(', ')', '"', '<', '>', '\\', '{', '}'];
let contains_forbidden_characters = s
.chars()
.any(|g| forbidden_characters.contains(&g));
To compile the new function successfully we will have to add the unicode-segmentation crate to our de-
pendencies:
170 CHAPTER 6. REJECT INVALID SUBSCRIBERS #1
#! Cargo.toml
# [...]
[dependencies]
unicode-segmentation = "1"
# [...]
While it looks like a perfectly fine solution (assuming we add a bunch of tests), functions like is_valid_name
give us a false sense of safety.
But we had to shift from a local approach (let’s look at this function’s parameters) to a global approach (let’s
scan the whole codebase) to make such a claim.
And while it might be feasible for a small project such as ours, examining all the calling sites of a function
(insert_subscriber) to ensure that a certain validation step has been performed beforehand quickly be-
comes unfeasible on larger projects.
If we are to stick with is_valid_name, the only viable approach is validating again form.name inside in-
sert_subscriber - and every other function that requires our name to be non-empty.
That is the only way we can actually make sure that our invariant is in place where we need it.
What happens if insert_subscriber becomes too big and we have to split it out in multiple sub-functions?
If they need the invariant, each of those has to perform validation to be certain it holds.
As you can see, this approach does not scale.
The issue here is that is_valid_name is a validation function: it tells us that, at a certain point in the execu-
tion flow of our program, a set of conditions is verified.
But this information about the additional structure in our input data is not stored anywhere. It is imme-
diately lost.
Other parts of our program cannot reuse it effectively - they are forced to perform another point-in-time
check leading to a crowded codebase with noisy (and wasteful) input checks at every step.
What we need is a parsing function - a routine that accepts unstructured input and, if a set of conditions
holds, returns us a more structured output, an output that structurally guarantees that the invariants we
care about hold from that point onwards.
How?
6.4. TYPE-DRIVEN DEVELOPMENT 171
Using types!
//! src/domain.rs
SubscriberName is a tuple struct - a new type, with a single (unnamed) field of type String.
SubscriberName is a proper new type, not just an alias - it does not inherit any of the methods available on
String and trying to assign a String to a variable of type SubscriberName will trigger a compiler error - e.g.:
The inner field of SubscriberName, according to our current definition, is private: it can only be accessed
from code within our domain module according to Rust’s visibility rules.
As always, trust but verify: what happens if we try to build a SubscriberName in our subscribe request
handler?
//! src/routes/subscriptions.rs
/// [...]
/// [...]
}
It is therefore impossible (as it stands now) to build a SubscriberName instance outside of our domain mod-
ule.
Let’s add a new method to SubscriberName:
//! src/domain.rs
use unicode_segmentation::UnicodeSegmentation;
impl SubscriberName {
/// Returns an instance of `SubscriberName` if the input satisfies all
/// our validation constraints on subscriber names.
/// It panics otherwise.
pub fn parse(s: String) -> SubscriberName {
// `.trim()` returns a view over the input `s` without trailing
// whitespace-like characters.
// `.is_empty` checks if the view contains any character.
let is_empty_or_whitespace = s.trim().is_empty();
// Iterate over all characters in the input `s` to check if any of them
// matches one of the characters in the forbidden array.
let forbidden_characters = ['/', '(', ')', '"', '<', '>', '\\', '{', '}'];
let contains_forbidden_characters = s
.chars()
.any(|g| forbidden_characters.contains(&g));
Yes, you are right - that is a shameless copy-paste of what we had in is_valid_name.
There is more!
parse is the only way to build an instance of SubscriberName outside of the domain module - we checked
this was the case a few paragraphs ago.
We can therefore assert that any instance of SubscriberName will satisfy all our validation constraints.
We have made it impossible for an instance of SubscriberName to violate those constraints.
// [...]
With the new signature we can be sure that new_subscriber.name is non-empty - it is impossible to call
insert_subscriber passing an empty subscriber name.
And we can draw this conclusion just by looking up the definition of the types of the function arguments -
we can once again make a local judgement, no need to go and check all the calling sites of our function.
Take a second to appreciate what just happened: we started with a set of requirements (all subscriber names
must verify some constraints), we identified a potential pitfall (we might forget to validate the input before
calling insert_subscriber) and we leveraged Rust’s type system to eliminate the pitfall, entirely.
We made an incorrect usage pattern unrepresentable, by construction - it will not compile.
This technique is known as type-driven development 6 .
Type-driven development is a powerful approach to encode the constraints of a domain we are trying to
model inside the type system, leaning on the compiler to make sure they are enforced.
The more expressive the type system of our programming language is, the tighter we can constrain our code
to only be able to represent states that are valid in the domain we are working in.
Rust has not invented type-driven development - it has been around for a while, especially in the functional
programming communities (Haskell, F#, OCaml, etc.). Rust “just” provides you with a type-system that is
expressive enough to leverage many of the design patterns that have been pioneered in those languages in the
past decades. The particular pattern we have just shown is often referred to as the “new-type pattern” in the
Rust community.
We will be touching upon type-driven development as we progress in our implementation, but I strongly
invite you to check out some of the resources mentioned in the footnotes of this chapter: they are treasure
chests for any developer.
#[tracing::instrument([...])]
6
“Parse, don’t validate” by Alexis King is a great starting point on type-driven development. “Domain Modelling Made Func-
tional” by Scott Wlaschin is the perfect book to go deeper, with a specific focus around domain modelling - if a book looks like too
much material, definitely check out Scott’s talk.
6.5. OWNERSHIP MEETS INVARIANTS 175
#[tracing::instrument(
name = "Saving new subscriber details in the database",
skip(new_subscriber, pool)
)]
pub async fn insert_subscriber(
pool: &PgPool,
new_subscriber: &NewSubscriber,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO subscriptions (id, email, name, subscribed_at)
VALUES ($1, $2, $3, $4)
"#,
Uuid::new_v4(),
new_subscriber.email,
new_subscriber.name,
Utc::now()
)
.execute(pool)
.await
.map_err(|e| {
tracing::error!("Failed to execute query: {:?}", e);
e
})?;
Ok(())
}
We have an issue here: we do not have any way to actually access the String value encapsulated inside Sub-
scriberName!
We could change SubscriberName’s definition from SubscriberName(String) to SubscriberName(pub
String), but we would lose all the nice guarantees we spent the last two sections talking about:
• other developers would be allowed to bypass parse and build a SubscriberName with an arbitrary
string
let liar = SubscriberName("".to_string());
• other developers might still choose to build a SubscriberName using parse but they would then have
the option to mutate the inner value later to something that does not satisfy anymore the constraints
we care about
let mut started_well = SubscriberName::parse("A valid name".to_string());
started_well.0 = "".to_string();
We can do better - this is the perfect place to take advantage of Rust’s ownership system!
Given a field in a struct we can choose to:
• expose it by value, consuming the struct itself:
impl SubscriberName {
pub fn inner(self) -> String {
// The caller gets the inner string,
// but they do not have a SubscriberName anymore!
// That's because `inner` takes `self` by value,
// consuming it according to move semantics
self.0
}
}
}
}
inner_mut is not what we are looking for here - the loss of control on our invariants would be equivalent to
using SubscriberName(pub String).
Both inner and inner_ref would be suitable, but inner_ref communicates better our intent: give the
caller a chance to read the value without the power to mutate it.
Let’s add inner_ref to SubscriberName - we can then amend insert_subscriber to use it:
//! src/routes/subscriptions.rs
// [...]
#[tracing::instrument([...])]
pub async fn insert_subscriber(
pool: &PgPool,
new_subscriber: &NewSubscriber,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO subscriptions (id, email, name, subscribed_at)
VALUES ($1, $2, $3, $4)
"#,
Uuid::new_v4(),
new_subscriber.email,
// Using `inner_ref`!
new_subscriber.name.inner_ref(),
Utc::now()
)
.execute(pool)
.await
.map_err(|e| {
tracing::error!("Failed to execute query: {:?}", e);
e
178 CHAPTER 6. REJECT INVALID SUBSCRIBERS #1
})?;
Ok(())
}
Boom, it compiles!
6.5.1 AsRef
While our inner_ref method gets the job done, I am obliged to point out that Rust’s standard library ex-
poses a trait that is designed exactly for this type of usage - AsRef.
The definition is quite concise:
pub trait AsRef<T: ?Sized> {
/// Performs the conversion.
fn as_ref(&self) -> &T;
}
AsRef can be used to improve ergonomics - let’s consider a function with this signature:
To invoke it with our SubscriberName we would have to first call inner_ref and then call
do_something_with_a_string_slice:
Nothing too complicated, but it might take you some time to figure out if SubscriberName can give you a
&str as well as how, especially if the type comes from a third-party library.
We can make the experience more seamless by changing do_something_with_a_string_slice’s signature:
// We are constraining T to implement the AsRef<str> trait
// using a trait bound - `T: AsRef<str>`
pub fn do_something_with_a_string_slice<T: AsRef<str>>(s: T) {
let s = s.as_ref();
// [...]
}
#[tracing::instrument([...])]
pub async fn insert_subscriber(
pool: &PgPool,
new_subscriber: &NewSubscriber,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO subscriptions (id, email, name, subscribed_at)
VALUES ($1, $2, $3, $4)
"#,
Uuid::new_v4(),
new_subscriber.email,
// Using `as_ref` now!
new_subscriber.name.as_ref(),
Utc::now()
180 CHAPTER 6. REJECT INVALID SUBSCRIBERS #1
)
.execute(pool)
.await
.map_err(|e| {
tracing::error!("Failed to execute query: {:?}", e);
e
})?;
Ok(())
}
6.6 Panics
…but our tests are not green:
thread 'actix-rt:worker:0' panicked at
' is not a valid subscriber name.', src/domain.rs:39:13
[...]
On the bright side: we are not returning a 200 OK anymore for empty names.
On the not-so-bright side: our API is terminating the request processing abruptly, causing the client to
observe an IncompleteMessage error. Not very graceful.
Let’s change the test to reflect our new expectations: we’d like to see a 400 Bad Request response when the
payload contains invalid data.
6.6. PANICS 181
//! tests/health_check.rs
// [...]
#[tokio::test]
// Renamed!
async fn subscribe_returns_a_400_when_fields_are_present_but_invalid() {
// [...]
assert_eq!(
// Not 200 anymore!
400,
response.status().as_u16(),
"The API did not return a 400 Bad Request when the payload was {}.",
description
);
// [...]
}
Now, let’s look at the root cause - we chose to panic when validation checks in SubscriberName::parse fail:
//! src/domain.rs
// [...]
impl SubscriberName {
pub fn parse(s: String) -> SubscriberName {
// [...]
Panics in Rust are used to deal with unrecoverable errors: failure modes that were not expected or that
we have no way to meaningfully recover from. Examples might include the host machine running out of
memory or a full disk.
Rust’s panics are not equivalent to exceptions in languages such as Python, C# or Java. Although Rust
provides a few utilities to catch (some) panics, it is most definitely not the recommended approach and
should be used sparingly.
burntsushi put it down quite neatly in a Reddit thread a few years ago:
182 CHAPTER 6. REJECT INVALID SUBSCRIBERS #1
[…] If your Rust application panics in response to any user input, then the following should be true:
your application has a bug, whether it be in a library or in the primary application code.
Adopting this viewpoint we can understand what is happening: when our request handler panics actix-
web assumes that something horrible happened and immediately drops the worker that was dealing with that
panicking request.7
If panics are not the way to go, what should we use to handle recoverable errors?
It tells us that inserting a subscriber in the database is a fallible operation - if all goes as planned, we don’t get
anything back (() - the unit type), if something is amiss we will instead receive a sqlx::Error with details
about what went wrong (e.g. a connection issue).
Errors as values, combined with Rust’s enums, are awesome building blocks for a robust error handling story.
If you are coming from a language with exception-based error handling, this is likely to be a game changer8 :
everything we need to know about the failure modes of a function is in its signature.
You will not have to dig in the documentation of your dependencies to understand what exceptions a certain
7
A panic in a request handler does not crash the whole application. actix-web spins up multiple workers to deal with incoming
requests and it is resilient to one or more of them crashing: it will just spawn new ones to replace the ones that failed.
8
Checked exceptions in Java are the only example I am aware of in mainstream languages using exceptions that comes close
enough to the compile-time safety provided by Result.
6.7. ERROR AS VALUES - RESULT 183
impl SubscriberName {
pub fn parse(s: String) -> Result<SubscriberName, ???> {
// [...]
}
}
impl SubscriberName {
pub fn parse(s: String) -> Result<SubscriberName, String> {
// [...]
}
}
Let’s focus on the second error: we cannot return a bare instance of SubscriberName at the end of parse -
we need to choose one of the two Result variants.
The compiler understands the issue and suggests the right edit: use Ok(Self(s)) instead of Self(s). Let’s
follow its advice:
//! src/domain.rs
// [...]
impl SubscriberName {
pub fn parse(s: String) -> Result<SubscriberName, String> {
// [...]
It is complaining about our invocation of the parse method in subscribe: when parse returned a Sub-
scriberName it was perfectly fine to assign its output directly to Subscriber.name.
We are returning a Result now - Rust’s type system forces us to deal with the unhappy path. We cannot
6.8. INSIGHTFUL ASSERTION ERRORS: CLAIMS 185
#[test]
fn dummy_fail() {
let result: Result<&str, &str> = Err("The app crashed due to an IO error");
assert!(result.is_ok());
}
We do not get any detail concerning the error itself - it makes for a somewhat painful debugging experience.
We will be using the claims crate to get more informative error messages:
#! Cargo.toml
# [...]
[dev-dependencies]
186 CHAPTER 6. REJECT INVALID SUBSCRIBERS #1
claims = "0.7"
# [...]
claims provides a fairly comprehensive range of assertions to work with common Rust types - in particular
Option and Result.
If we rewrite our dummy_fail test to use claims
#[test]
fn dummy_fail() {
let result: Result<&str, &str> = Err("The app crashed due to an IO error");
claims::assert_ok!(result);
}
we get
---- dummy_fail stdout ----
thread 'dummy_fail' panicked at 'assertion failed, expected Ok(..),
got Err("The app crashed due to an IO error")'
Much better.
#[cfg(test)]
mod tests {
use crate::domain::SubscriberName;
use claims::{assert_err, assert_ok};
#[test]
fn a_256_grapheme_long_name_is_valid() {
let name = "ё".repeat(256);
assert_ok!(SubscriberName::parse(name));
}
#[test]
fn a_name_longer_than_256_graphemes_is_rejected() {
let name = "a".repeat(257);
assert_err!(SubscriberName::parse(name));
}
6.9. UNIT TESTS 187
#[test]
fn whitespace_only_names_are_rejected() {
let name = " ".to_string();
assert_err!(SubscriberName::parse(name));
}
#[test]
fn empty_string_is_rejected() {
let name = "".to_string();
assert_err!(SubscriberName::parse(name));
}
#[test]
fn names_containing_an_invalid_character_are_rejected() {
for name in &['/', '(', ')', '"', '<', '>', '\\', '{', '}'] {
let name = name.to_string();
assert_err!(SubscriberName::parse(name));
}
}
#[test]
fn a_valid_name_is_parsed_successfully() {
let name = "Ursula Le Guin".to_string();
assert_ok!(SubscriberName::parse(name));
}
}
Unfortunately, it does not compile - cargo highlights all our usages of assert_ok/assert_err with
66 | assert_err!(SubscriberName::parse(name));
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| `SubscriberName` cannot be formatted using `{:?}`
|
= help: the trait `std::fmt::Debug` is not implemented for `SubscriberName`
= note: add `#[derive(Debug)]` or manually implement `std::fmt::Debug`
= note: required by `std::fmt::Debug::fmt`
claims needs our type to implement the Debug trait to provide those nice error messages. Let’s add a #[de-
rive(Debug)] attribute on top of SubscriberName:
//! src/domain.rs
// [...]
#[derive(Debug)]
188 CHAPTER 6. REJECT INVALID SUBSCRIBERS #1
failures:
domain::tests::a_name_longer_than_256_graphemes_is_rejected
domain::tests::empty_string_is_rejected
domain::tests::names_containing_an_invalid_character_are_rejected
domain::tests::whitespace_only_names_are_rejected
All our unhappy-path tests are failing because we are still panicking if our validation constraints are not
satisfied - let’s change it:
//! src/domain.rs
// [...]
impl SubscriberName {
pub fn parse(s: String) -> Result<SubscriberName, String> {
// [...]
All our domain unit tests are now passing - let’s finally address the failing integration test we wrote at the
beginning of the chapter.
How do we change subscribe to return a 400 Bad Request on validation errors? We can have a look at
what we are already doing for our call to insert_subscriber!
6.10. HANDLING A RESULT 189
6.10.1 match
//! src/routes/subscriptions.rs
// [...]
insert_subscriber returns a Result<(), sqlx::Error> while subscribe speaks the language of a REST
API - its output must be of type HttpResponse. To return a HttpResponse to the caller in the error case we
need to convert sqlx::Error into a representation that makes sense within the technical domain of a REST
API - in our case, a 500 Internal Server Error.
That’s where a match comes in handy: we tell the compiler what to do in both scenarios, Ok and Err.
It allows us to return early when something fails using a single character instead of a multi-line block.
Given that ? triggers an early return using an Err variant, it can only be used within a function that returns
a Result. subscribe does not qualify (yet).
cargo test is not green yet, but we are getting a different error:
The test case using an empty name is now passing, but we are failing to return a 400 Bad Request when an
empty email is provided.
Not unexpected - we have not implemented any kind of email validation yet!
We want to have
src/
routes/
[...]
domain/
mod.rs
subscriber_name.rs
subscriber_email.rs
new_subscriber.rs
[...]
Unit tests should be in the same file of the type they refer to. We will end up with:
//! src/domain/mod.rs
mod subscriber_name;
mod subscriber_email;
mod new_subscriber;
//! src/domain/subscriber_name.rs
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug)]
pub struct SubscriberName(String);
6.12. THE SUBSCRIBEREMAIL TYPE 193
impl SubscriberName {
// [...]
}
#[cfg(test)]
mod tests {
// [...]
}
//! src/domain/subscriber_email.rs
//! src/domain/new_subscriber.rs
use crate::domain::subscriber_name::SubscriberName;
No changes should be required to other files in our project - the API of our module has not changed thanks
to our pub use statements in mod.rs.
//! src/domain/subscriber_email.rs
#[derive(Debug)]
pub struct SubscriberEmail(String);
impl SubscriberEmail {
pub fn parse(s: String) -> Result<SubscriberEmail, String> {
// TODO: add validation!
Ok(Self(s))
}
}
194 CHAPTER 6. REJECT INVALID SUBSCRIBERS #1
//! src/domain/mod.rs
mod new_subscriber;
mod subscriber_email;
mod subscriber_name;
We start with tests this time: let’s come up with a few examples of invalid emails that should be rejected.
//! src/domain/subscriber_email.rs
#[derive(Debug)]
pub struct SubscriberEmail(String);
// [...]
#[cfg(test)]
mod tests {
use super::SubscriberEmail;
use claims::assert_err;
#[test]
fn empty_string_is_rejected() {
let email = "".to_string();
assert_err!(SubscriberEmail::parse(email));
}
#[test]
fn email_missing_at_symbol_is_rejected() {
let email = "ursuladomain.com".to_string();
assert_err!(SubscriberEmail::parse(email));
}
#[test]
6.12. THE SUBSCRIBEREMAIL TYPE 195
fn email_missing_subject_is_rejected() {
let email = "@domain.com".to_string();
assert_err!(SubscriberEmail::parse(email));
}
}
Running cargo test domain confirms that all test cases are failing:
failures:
domain::subscriber_email::tests::email_missing_at_symbol_is_rejected
domain::subscriber_email::tests::email_missing_subject_is_rejected
domain::subscriber_email::tests::empty_string_is_rejected
Our parse method will just delegate all the heavy-lifting to validator::validate_email:
//! src/domain/subscriber_email.rs
use validator::validate_email;
#[derive(Debug)]
pub struct SubscriberEmail(String);
impl SubscriberEmail {
pub fn parse(s: String) -> Result<SubscriberEmail, String> {
if validate_email(&s) {
Ok(Self(s))
} else {
Err(format!("{} is not a valid subscriber email.", s))
}
}
}
// [...]
There is a caveat - all our test cases are checking for invalid emails. We should also have at least one test
checking that valid emails are going through.
We could hard-code a known valid email address in a test and check that it is parsed successfully - e.g. ur-
[email protected].
What value would we get from that test case though? It would only re-assure us that a specific email address
is correctly parsed as valid.
[dev-dependencies]
# [...]
# We are not using fake >= 2.4 because it relies on rand 0.8
# which has been recently released and it is not yet used by
6.13. PROPERTY-BASED TESTING 197
// [...]
#[cfg(test)]
mod tests {
// We are importing the `SafeEmail` faker!
// We also need the `Fake` trait to get access to the
// `.fake` method on `SafeEmail`
use fake::faker::internet::en::SafeEmail;
use fake::Fake;
// [...]
#[test]
fn valid_emails_are_parsed_successfully() {
let email = SafeEmail().fake();
claims::assert_ok!(SubscriberEmail::parse(email));
}
}
Every time we run our test suite, SafeEmail().fake() generates a new random valid email which we then
use to test our parsing logic.
This is already a major improvement compared to a hard-coded valid email, but we would have to run our
test suite several times to catch an issue with an edge case. A fast-and-dirty solution would be to add a for
loop to the test, but, once again, we can use this as an occasion to delve deeper and explore one of the available
testing crates designed around property-based testing.
For our project we will go with quickcheck - it is fairly simple to get started with and it does not use too
many macros, which makes for a pleasant IDE experience.
#[cfg(test)]
mod tests {
#[quickcheck_macros::quickcheck]
fn prop(xs: Vec<u32>) -> bool {
/// A property that is always true, regardless
/// of the vector we are applying the function to:
/// reversing it twice should return the original input.
xs == reverse(&reverse(&xs))
}
}
quickcheck calls prop in a loop with a configurable number of iterations (100 by default): on every iteration,
it generates a new Vec<u32> and checks that prop returned true.
If prop returns false, it tries to shrink the generated input to the smallest possible failing example (the
shortest failing vector) to help us debug what went wrong.
In our case, we’d like to have something along these lines:
#[quickcheck_macros::quickcheck]
fn valid_emails_are_parsed_successfully(valid_email: String) -> bool {
SubscriberEmail::parse(valid_email).is_ok()
}
Unfortunately, if we ask for a String type as input we are going to get all sorts of garbage which will fail
validation.
How do we customise the generation routine?
}
}
[dev-dependencies]
# [...]
quickcheck = "0.9.2"
quickcheck_macros = "0.9.1"
Then
//! src/domain/subscriber_email.rs
// [...]
#[cfg(test)]
mod tests {
// We have removed the `assert_ok` import.
use claims::assert_err;
// [...]
#[quickcheck_macros::quickcheck]
fn valid_emails_are_parsed_successfully(valid_email: ValidEmailFixture) -> bool {
SubscriberEmail::parse(valid_email.0).is_ok()
}
}
This is an amazing example of the interoperability you gain by sharing key traits across the Rust ecosystem.
How do we get fake and quickcheck to play nicely together?
Anything that implements Gen must also implement the RngCore trait from rand-core.
You read that right - any type that implements the Rng trait from rand, which is automatically implemented
by all types implementing RngCore!
We can just pass g from Arbitrary::arbitrary as the random number generator for fake_with_rng and
everything just works!
Maybe the maintainers of the two crates are aware of each other, maybe they aren’t, but a community-
sanctioned set of traits in rand-core gives us painless interoperability. Pretty sweet!
You can now run cargo test domain - it should come out green, re-assuring us that our email validation
check is indeed not overly prescriptive.
If you want to see the random inputs that are being generated, add a dbg!(&valid_email.0); statement to
6.14. PAYLOAD VALIDATION 201
the test and run cargo test valid_emails -- --nocapture - tens of valid emails should pop up in your
terminal!
Let’s integrate our shiny SubscriberEmail into the application to benefit from its validation in our /sub-
scriptions endpoint.
We need to start from NewSubscriber:
//! src/domain/new_subscriber.rs
use crate::domain::SubscriberName;
use crate::domain::SubscriberEmail;
Hell should break loose if you try to compile the project now.
Let’s start with the first error reported by cargo check:
error[E0308]: mismatched types
--> src/routes/subscriptions.rs:28:16
|
28 | email: form.0.email,
| ^^^^^^^^^^^^
| expected struct `SubscriberEmail`,
| found struct `std::string::String`
#[tracing::instrument([...])]
pub async fn subscribe(
form: web::Form<FormData>,
pool: web::Data<PgPool>,
) -> HttpResponse {
let name = match SubscriberName::parse(form.0.name) {
Ok(name) => name,
Err(_) => return HttpResponse::BadRequest().finish(),
};
let new_subscriber = NewSubscriber {
// We are trying to assign a string to a field of type SubscriberEmail!
email: form.0.email,
name,
};
match insert_subscriber(&pool, &new_subscriber).await {
Ok(_) => HttpResponse::Ok().finish(),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
We need to mimic what we are already doing for the name field: first we parse form.0.email then we assign
the result (if successful) to NewSubscriber.email.
//! src/routes/subscriptions.rs
// We added `SubscriberEmail`!
use crate::domain::{NewSubscriber, SubscriberEmail, SubscriberName};
// [...]
#[tracing::instrument([...])]
pub async fn subscribe(
form: web::Form<FormData>,
pool: web::Data<PgPool>,
) -> HttpResponse {
let name = match SubscriberName::parse(form.0.name) {
Ok(name) => name,
Err(_) => return HttpResponse::BadRequest().finish(),
};
let email = match SubscriberEmail::parse(form.0.email) {
Ok(email) => email,
Err(_) => return HttpResponse::BadRequest().finish(),
};
let new_subscriber = NewSubscriber { email, name };
6.14. PAYLOAD VALIDATION 203
// [...]
}
This is in our insert_subscriber function, where we perform a SQL INSERT query to store the details of
the new subscriber:
//! src/routes/subscriptions.rs
// [...]
#[tracing::instrument([...])]
pub async fn insert_subscriber(
pool: &PgPool,
new_subscriber: &NewSubscriber,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO subscriptions (id, email, name, subscribed_at)
VALUES ($1, $2, $3, $4)
"#,
Uuid::new_v4(),
// It expects a `&str` but we are passing it
// a `SubscriberEmail` value
new_subscriber.email,
new_subscriber.name.as_ref(),
Utc::now()
)
.execute(pool)
.await
.map_err(|e| {
tracing::error!("Failed to execute query: {:?}", e);
e
})?;
Ok(())
}
204 CHAPTER 6. REJECT INVALID SUBSCRIBERS #1
The solution is right there, on the line below - we just need to borrow the inner field of SubscriberEmail
as a string slice using our implementation of AsRef<str>.
//! src/routes/subscriptions.rs
// [...]
#[tracing::instrument([...])]
pub async fn insert_subscriber(
pool: &PgPool,
new_subscriber: &NewSubscriber,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO subscriptions (id, email, name, subscribed_at)
VALUES ($1, $2, $3, $4)
"#,
Uuid::new_v4(),
// Using `as_ref` now!
new_subscriber.email.as_ref(),
new_subscriber.name.as_ref(),
Utc::now()
)
.execute(pool)
.await
.map_err(|e| {
tracing::error!("Failed to execute query: {:?}", e);
e
})?;
Ok(())
}
running 4 tests
test subscribe_returns_a_400_when_data_is_missing ... ok
test health_check_works ... ok
test subscribe_returns_a_400_when_fields_are_present_but_invalid ... ok
test subscribe_returns_a_200_for_valid_form_data ... ok
#[tracing::instrument([...])]
pub async fn subscribe(
form: web::Form<FormData>,
pool: web::Data<PgPool>,
) -> HttpResponse {
let name = match SubscriberName::parse(form.0.name) {
Ok(name) => name,
Err(_) => return HttpResponse::BadRequest().finish(),
};
let email = match SubscriberEmail::parse(form.0.email) {
Ok(email) => email,
Err(_) => return HttpResponse::BadRequest().finish(),
};
let new_subscriber = NewSubscriber { email, name };
match insert_subscriber(&pool, &new_subscriber).await {
Ok(_) => HttpResponse::Ok().finish(),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
#[tracing::instrument([...])]
pub async fn subscribe(
form: web::Form<FormData>,
206 CHAPTER 6. REJECT INVALID SUBSCRIBERS #1
pool: web::Data<PgPool>,
) -> HttpResponse {
let new_subscriber = match parse_subscriber(form.0) {
Ok(subscriber) => subscriber,
Err(_) => return HttpResponse::BadRequest().finish(),
};
match insert_subscriber(&pool, &new_subscriber).await {
Ok(_) => HttpResponse::Ok().finish(),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
Replace T with FormData, Self with NewSubscriber and Self::Error with String - there you have it, the
signature of our parse_subscriber function!
Let’s try it out:
//! src/routes/subscriptions.rs
// No need to import the TryFrom trait, it is included
// in Rust's prelude since edition 2021!
// [...]
#[tracing::instrument([...])]
pub async fn subscribe(
form: web::Form<FormData>,
pool: web::Data<PgPool>,
) -> HttpResponse {
let new_subscriber = match form.0.try_into() {
Ok(form) => form,
Err(_) => return HttpResponse::BadRequest().finish(),
};
match insert_subscriber(&pool, &new_subscriber).await {
Ok(_) => HttpResponse::Ok().finish(),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
Its signature mirrors the one of TryFrom - the conversion just goes in the other direction!
If you provide a TryFrom implementation, your type automatically gets the corresponding TryInto imple-
mentation, for free.
try_into takes self as first argument, which allows us to do form.0.try_into() instead of going for NewS-
ubscriber::try_from(form.0) - matter of taste, if you want.
6.15 Summary
Validating that the email in the payload of POST /subscriptions complies with the expected format is good,
but it is not enough.
We now have an email that is syntactically valid but we are still uncertain about its existence: does anybody
actually use that email address? Is it reachable?
We have no idea and there is only one way to find out: sending an actual email.
Confirmation emails (and how to write a HTTP client!) will be the topic of the next chapter.
Chapter 7
209
210 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
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.
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;
impl EmailClient {
pub async fn send_email(
&self,
recipient: SubscriberEmail,
subject: &str,
html_content: &str,
text_content: &str
) -> Result<(), String> {
todo!()
}
}
//! src/lib.rs
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!
[dependencies.reqwest]
version = "0.11"
default-features = false
# We need the `json` feature flag to serialize/deserialize JSON payloads
features = ["json", "rustls-tls"]
[dev-dependencies]
# Remove `reqwest`'s entry from this list
# [...]
7.2.2.1 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.
7.2. EMAILCLIENT, OUR EMAIL DELIVERY COMPONENT 215
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 two fields to EmailClient:
• http_client, to store a Client instance;
• base_url, to store the URL of the API we will be making requests to.
//! src/email_client.rs
use crate::domain::SubscriberEmail;
use reqwest::Client;
impl EmailClient {
pub fn new(base_url: String, sender: SubscriberEmail) -> Self {
Self {
http_client: Client::new(),
base_url,
sender
}
}
// [...]
}
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.
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)
}
#[derive(Clone)]
pub struct EmailClient {
http_client: Client,
base_url: String,
sender: SubscriberEmail
}
// [...]
7.2. EMAILCLIENT, OUR EMAIL DELIVERY COMPONENT 217
//! 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 in actix_web::web::Data (an Arc pointer) and pass a pointer to app_data every
time we need to build an App - like we are doing with PgPool:
//! 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())
218 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
.app_data(email_client.clone())
})
.listen(listener)?
.run();
Ok(server)
}
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
// [...]
let configuration = get_configuration().expect("Failed to read configuration.");
let connection_pool = PgPoolOptions::new()
.connect_lazy_with(configuration.database.with_db());
7.2. EMAILCLIENT, OUR EMAIL DELIVERY COMPONENT 219
We are building the dependencies of our application using the values specified in the configuration we re-
trieved 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())
}
}
// [...]
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() -> Result<(), std::io::Error> {
// [...]
let configuration = get_configuration().expect("Failed to read configuration.");
let connection_pool = PgPoolOptions::new()
.connect_lazy_with(configuration.database.with_db());
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:
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;
// [...]
sender_email,
);
// [...]
//! src/email_client.rs
// [...]
#[cfg(test)]
mod tests {
7.2. EMAILCLIENT, OUR EMAIL DELIVERY COMPONENT 223
#[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 EmailCli-
ent::base_url!
[dev-dependencies]
# [...]
wiremock = "0.5"
#[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() {
224 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
// 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;
// Act
let _ = email_client
.send_email(subscriber_email, &subject, &content, &content)
.await;
// Assert
}
}
7.2.3.2 wiremock::MockServer
7.2.3.3 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))
7.2. EMAILCLIENT, OUR EMAIL DELIVERY COMPONENT 225
.expect(1)
.mount(&mock_server)
.await;
When wiremock::MockServer receives a request, it iterates over all the mounted mocks to check if the re-
quest 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.
// 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 ran-
dom?
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.
226 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
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:
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 {
// [...]
// [...]
The server expected one request, but it received none - therefore the test failed.
The time has come to properly flesh out 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>"
}'
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"
}
7.2.4.1 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 {
// [...]
// [...]
7.2. EMAILCLIENT, OUR EMAIL DELIVERY COMPONENT 229
impl EmailClient {
// [...]
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
// [...]
230 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
impl EmailClient {
// [...]
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`
#[derive(serde::Serialize)]
struct SendEmailRequest {
from: String,
to: String,
subject: String,
html_body: String,
text_body: String,
}
7.2. EMAILCLIENT, OUR EMAIL DELIVERY COMPONENT 231
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!
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>
}
232 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
// [...]
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() -> Result<(), std::io::Error> {
// [...]
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
// [...]
7.2. EMAILCLIENT, OUR EMAIL DELIVERY COMPONENT 233
#! 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"
impl EmailClient {
// [...]
.json(&request_body);
Ok(())
}
}
impl EmailClient {
// [...]
| ^
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 {
// [...]
#[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 secrecy::Secret;
use wiremock::matchers::any;
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn send_email_fires_a_request_to_base_url() {
236 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
// Arrange
let mock_server = MockServer::start().await;
let sender = SubscriberEmail::parse(SafeEmail().fake()).unwrap();
let email_client = EmailClient::new(
mock_server.uri(),
sender,
Secret::new(Faker.fake())
);
Mock::given(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.
7.2.5.0.1 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
7.2. EMAILCLIENT, OUR EMAIL DELIVERY COMPONENT 237
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;
// [...]
}
}
#[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;
// [...]
}
}
238 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
#[cfg(test)]
mod tests {
use wiremock::Request;
// [...]
struct SendEmailBodyMatcher;
// [...]
}
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 de-
pendencies:
#! Cargo.toml
# [...]
[dev-dependencies]
# [...]
serde_json = "1"
#[cfg(test)]
mod tests {
// [...]
struct SendEmailBodyMatcher;
#[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;
// [...]
}
}
240 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
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 {
// [...]
If you run the test again with cargo test send_email you will get something that looks like this:
})
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,
}
impl EmailClient {
// [...]
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 {
// [...]
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!
#[cfg(test)]
mod tests {
// [...]
use wiremock::matchers::any;
use claims::assert_ok;
// [...]
#[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())
);
// Act
let outcome = email_client
.send_email(subscriber_email, &subject, &content, &content)
.await;
// Assert
assert_ok!(outcome);
}
}
//! src/email_client.rs
// [...]
#[cfg(test)]
7.2. EMAILCLIENT, OUR EMAIL DELIVERY COMPONENT 245
mod tests {
// [...]
use claims::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())
);
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);
}
}
//! 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:
impl EmailClient {
//[...]
pub async fn send_email(
//[...]
) -> Result<(), reqwest::Error> {
7.2. EMAILCLIENT, OUR EMAIL DELIVERY COMPONENT 247
//[...]
self.http_client
.post(&url)
.header(
"X-Postmark-Server-Token",
self.authorization_token.expose_secret()
)
.json(&request_body)
.send()
.await?
.error_for_status()?;
Ok(())
}
}
//[...]
7.2.6.2 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())
);
// Act
let outcome = email_client
.send_email(subscriber_email, &subject, &content, &content)
.await;
// Assert
assert_err!(outcome);
}
}
This is far from ideal: if the server starts misbehaving we might start to accumulate several “hanging” re-
quests.
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
// [...]
7.2. EMAILCLIENT, OUR EMAIL DELIVERY COMPONENT 249
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).
#[cfg(test)]
mod tests {
// [...]
// [...]
}
#[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
}
}
7.2. EMAILCLIENT, OUR EMAIL DELIVERY COMPONENT 251
Way less visual noise - the intent of the test is front and center.
Go ahead and refactor the other three!
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
252 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
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() -> Result<(), std::io::Error> {
// [...]
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
#[cfg(test)]
mod tests {
// [...]
fn email_client(base_url: String) -> EmailClient {
EmailClient::new(
base_url,
7.3. SKELETON AND PRINCIPLES FOR A MAINTAINABLE TEST SUITE 253
email(),
Secret::new(Faker.fake()),
// Much lower than 10s!
std::time::Duration::from_millis(200),
)
}
}
//! tests/health_check.rs
// [...]
All tests should succeed - and the overall execution time should be down to less than a second for the whole
test suite.
are caught in the continuous integration pipeline and never reach users. The team is therefore empowered
to iterate faster and release more often.
Tests act as documentation as well.
The test suite is often the best starting point when deep-diving in an unknown code base - it shows you how
the code is supposed to behave and what scenarios are considered relevant enough to have dedicated tests
for.
“Write a test suite!” should definitely be on your to-do list if you want to make your project more welcoming
to new contributors.
There are other positive side-effects often associated with good tests - modularity, decoupling. These are
harder to quantify, as we have yet to agree as an industry on what “good code” looks like.
// Ensure that the `tracing` stack is only initialised once using `once_cell`
static TRACING: Lazy<()> = Lazy::new(|| {
// [...]
});
#[tokio::test]
async fn health_check_works() {
// [...]
}
#[tokio::test]
async fn subscribe_returns_a_200_for_valid_form_data() {
// [...]
}
256 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
#[tokio::test]
async fn subscribe_returns_a_400_when_data_is_missing() {
// [...]
}
#[tokio::test]
async fn subscribe_returns_a_400_when_fields_are_present_but_invalid() {
// [...]
}
health_check-fc23645bf877da35
health_check-fc23645bf877da35.d
The trailing hashes will likely be different on your machine, but there should be two entries starting with
health_check-*.
What happens if you try to run it?
./target/debug/deps/health_check-fc23645bf877da35
running 4 tests
test health_check_works ... ok
test subscribe_returns_a_400_when_fields_are_present_but_invalid ... ok
test subscribe_returns_a_400_when_data_is_missing ... ok
test subscribe_returns_a_200_for_valid_form_data ... ok
// [...]
helpers is bundled in the health_check test executable as a sub-module and we get access to the functions
it exposes in our test cases.
This approach works fairly well to start out, but it leads to annoying function is never used warnings
down the line.
The issue is that helpers is bundled as a sub-module, it is not invoked as a third-party crate: cargo com-
piles each test executable in isolation and warns us if, for a specific test file, one or more public functions in
helpers have never been invoked. This is bound to happen as your test suite grows - not all test files will use
all your helper methods.
The second option takes full advantage of that each file under tests is its own executable - we can create
1
Refer to the test organization chapter in the Rust book for more details.
258 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
First, we gain clarity: we are structuring api in the very same way we would structure a binary crate. Less
magic - it builds on the same knowledge of the module system you built while working on application code.
If you run cargo build --tests you should be able to spot
Running target/debug/deps/api-0a1bfb817843fdcf
running 0 tests
in the output - cargo compiled api as a test executable, looking for test cases.
There is no need to define a main function in main.rs - the Rust test framework adds one for us behind the
scenes2 .
We can now add sub-modules in main.rs:
//! tests/api/main.rs
mod helpers;
mod health_check;
mod subscriptions;
// Ensure that the `tracing` stack is only initialised once using `once_cell`
2
You can actually override the default test framework and plug your own. Look at libtest-mimic as an example!
7.3. SKELETON AND PRINCIPLES FOR A MAINTAINABLE TEST SUITE 259
// Public!
pub async fn spawn_app() -> TestApp {
// [...]
}
//! tests/api/health_check.rs
use crate::helpers::spawn_app;
#[tokio::test]
async fn health_check_works() {
// [...]
}
//! tests/api/subscriptions.rs
use crate::helpers::spawn_app;
#[tokio::test]
async fn subscribe_returns_a_200_for_valid_form_data() {
// [...]
}
#[tokio::test]
async fn subscribe_returns_a_400_when_data_is_missing() {
// [...]
}
#[tokio::test]
async fn subscribe_returns_a_400_when_fields_are_present_but_invalid() {
// [...]
}
260 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
3
See this article as an example with some numbers (1.9x speedup!). You should always benchmark the approach on your specific
codebase before committing.
7.3. SKELETON AND PRINCIPLES FOR A MAINTAINABLE TEST SUITE 261
// [...]
Most of the code we have here is extremely similar to what we find in our main entrypoint:
//! src/main.rs
use sqlx::postgres::PgPoolOptions;
use std::net::TcpListener;
use zero2prod::configuration::get_configuration;
use zero2prod::email_client::EmailClient;
use zero2prod::startup::run;
use zero2prod::telemetry::{get_subscriber, init_subscriber};
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
262 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
Every time we add a dependency or modify the server constructor, we have at least two places to modify - we
have recently gone through the motions with EmailClient. It’s mildly annoying.
More importantly though, the startup logic in our application code is never tested.
As the codebase evolves, they might start to diverge subtly, leading to different behaviour in our tests com-
pared to our production environment.
We will first extract the logic out of main and then figure out what hooks we need to leverage the same code
paths in our test code.
From a structural perspective, our startup logic is a function taking Settings as input and returning an
instance of our application as output.
It follows that our main function should look like this:
7.3. SKELETON AND PRINCIPLES FOR A MAINTAINABLE TEST SUITE 263
//! src/main.rs
use zero2prod::configuration::get_configuration;
use zero2prod::startup::build;
use zero2prod::telemetry::{get_subscriber, init_subscriber};
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
let subscriber = get_subscriber(
"zero2prod".into(), "info".into(), std::io::stdout
);
init_subscriber(subscriber);
We first perform some binary-specific logic (i.e. telemetry initialisation), then we build a set of configuration
values from the supported sources (files + environment variables) and use it to spin up an application. Linear.
Let’s define that build function then:
//! src/startup.rs
// [...]
// New imports!
use crate::configuration::Settings;
use sqlx::postgres::PgPoolOptions;
pub fn run(
listener: TcpListener,
db_pool: PgPool,
email_client: EmailClient,
) -> Result<Server, std::io::Error> {
// [...]
}
Nothing too surprising - we have just moved around the code that was previously living in main.
Let’s make it test-friendly now!
// [...]
TestApp {
// How do we get these?
address: todo!(),
db_pool: todo!()
}
}
// [...]
It almost works - the approach falls short at the very end: we have no way to retrieve the random address as-
signed by the OS to the application and we don’t really know how to build a connection pool to the database,
needed to perform assertions on side-effects impacting the persisted state.
Let’s deal with the connection pool first: we can extract the initialisation logic from build into a stand-alone
function and invoke it twice.
//! src/startup.rs
// [...]
use crate::configuration::DatabaseSettings;
pub fn get_connection_pool(
configuration: &DatabaseSettings
) -> PgPool {
PgPoolOptions::new().connect_lazy_with(configuration.with_db())
}
7.3. SKELETON AND PRINCIPLES FOR A MAINTAINABLE TEST SUITE 267
//! tests/api/helpers.rs
// [...]
use zero2prod::startup::{build, get_connection_pool};
// [...]
// [...]
You’ll have to add a #[derive(Clone)] to all the structs in src/configuration.rs to make the compiler
happy, but we are done with the database connection pool.
How do we get the application address instead?
actix_web::dev::Server, the type returned by build, does not allow us to retrieve the application port.
We need to do a bit more legwork in our application code - we will wrap actix_web::dev::Server in a new
type that holds on to the information we want.
//! src/startup.rs
// [...]
// A new type to hold the newly built server and its port
pub struct Application {
port: u16,
server: Server,
}
impl Application {
// We have converted the `build` function into a constructor for
// `Application`.
pub async fn build(configuration: Settings) -> Result<Self, std::io::Error> {
let connection_pool = get_connection_pool(&configuration.database);
.sender()
.expect("Invalid sender email address.");
let timeout = configuration.email_client.timeout();
let email_client = EmailClient::new(
configuration.email_client.base_url,
sender_email,
configuration.email_client.authorization_token,
timeout,
);
// [...]
//! tests/api/helpers.rs
// [...]
// New import!
use zero2prod::startup::Application;
.await
.expect("Failed to build application.");
// Get the port before spawning the application
let address = format!("http://127.0.0.1:{}", application.port());
let _ = tokio::spawn(application.run_until_stopped());
TestApp {
address,
db_pool: get_connection_pool(&configuration.database),
}
}
// [...]
//! src/main.rs
// [...]
// New import!
use zero2prod::startup::Application;
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
// [...]
let application = Application::build(configuration).await?;
application.run_until_stopped().await?;
Ok(())
}
#[tokio::test]
270 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
async fn subscribe_returns_a_200_for_valid_form_data() {
// Arrange
let app = spawn_app().await;
let client = reqwest::Client::new();
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
// Act
let response = client
.post(&format!("{}/subscriptions", &app.address))
.header("Content-Type", "application/x-www-form-urlencoded")
.body(body)
.send()
.await
.expect("Failed to execute request.");
// Assert
assert_eq!(200, response.status().as_u16());
assert_eq!(saved.email, "[email protected]");
assert_eq!(saved.name, "le guin");
}
#[tokio::test]
async fn subscribe_returns_a_400_when_data_is_missing() {
// Arrange
let app = spawn_app().await;
let client = reqwest::Client::new();
let test_cases = vec![
("name=le%20guin", "missing the email"),
("email=ursula_le_guin%40gmail.com", "missing the name"),
("", "missing both name and email"),
];
.send()
.await
.expect("Failed to execute request.");
// Assert
assert_eq!(
400,
response.status().as_u16(),
// Additional customised error message on test failure
"The API did not fail with 400 Bad Request when the payload was {}.",
error_message
);
}
}
#[tokio::test]
async fn subscribe_returns_a_400_when_fields_are_present_but_invalid() {
// Arrange
let app = spawn_app().await;
let client = reqwest::Client::new();
let test_cases = vec![
("name=&email=ursula_le_guin%40gmail.com", "empty name"),
("name=Ursula&email=", "empty email"),
("name=Ursula&email=definitely-not-an-email", "invalid email"),
];
// Assert
assert_eq!(
400,
response.status().as_u16(),
"The API did not return a 400 Bad Request when the payload was {}.",
description
);
}
272 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
We have the same calling code in each test - we should pull it out and add a helper method to our TestApp
struct:
//! tests/api/helpers.rs
// [...]
impl TestApp {
pub async fn post_subscriptions(&self, body: String) -> reqwest::Response {
reqwest::Client::new()
.post(&format!("{}/subscriptions", &self.address))
.header("Content-Type", "application/x-www-form-urlencoded")
.body(body)
.send()
.await
.expect("Failed to execute request.")
}
}
// [...]
//! tests/api/subscriptions.rs
use crate::helpers::spawn_app;
#[tokio::test]
async fn subscribe_returns_a_200_for_valid_form_data() {
// [...]
// Act
let response = app.post_subscriptions(body.into()).await;
// [...]
}
#[tokio::test]
async fn subscribe_returns_a_400_when_data_is_missing() {
// [...]
for (invalid_body, error_message) in test_cases {
let response = app.post_subscriptions(invalid_body.into()).await;
// [...]
}
7.4. REFOCUS 273
#[tokio::test]
async fn subscribe_returns_a_400_when_fields_are_present_but_invalid() {
// [...]
for (body, description) in test_cases {
let response = app.post_subscriptions(body.into()).await;
// [...]
}
}
We could add another method for the health check endpoint, but it’s only used once - there is no need right
now.
7.3.10 Summary
We started with a single file test suite, we finished with a modular test suite and a robust set of helpers.
Just like application code, test code is never finished: we will have to keep working on it as the project evolves,
but we have laid down solid foundations to keep moving forward without losing momentum.
We are now ready to tackle the remaining pieces of functionality needed to dispatch a confirmation email.
7.4 Refocus
The first item is done, time to move on to the remaining two on the list.
We had a sketch of how the two handlers should work:
274 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
This gives us a fairly precise picture of how the application is going to work once we are done with the
implementation.
It does not help us much to figure out how to get there.
Where should we start from?
Should we immediately tackle the changes to /subscriptions?
Should we get /subscriptions/confirm out of the way?
We need to find an implementation route that can be rolled out with zero downtime.
Each replica of our application is registered with the load balancer as a backend.
Every time somebody sends a request to our API, they hit our load balancer which is then in charge of
choosing one of the available backends to fulfill the incoming request.
Load balancers usually support adding (and removing) backends dynamically.
This enables a few interesting patterns.
7.5.2.2.1 Horizontal Scaling We can add more capacity when experiencing a traffic spike by spinning
up more replicas of our application (i.e. horizontal scaling).
It helps to spread the load until the work expected of a single instance becomes manageable.
We will get back to this topic later in the book when discussing metrics and autoscaling.
7.5.2.2.2 Health Checks We can ask the load balancer to keep an eye on the health of the registered
backends.
Oversimplifying, health checking can be:
• Passive - the load balancer looks at the distribution of status codes/latency for each backend to determ-
ine if they are healthy or not;
• Active - the load balancer is configured to send a health check request to each backend on a schedule.
If a backend fails to respond with a success status code for a long enough time period it is marked as
unhealthy and removed.
This is a critical capability to achieve self-healing in a cloud-native environment: the platform can detect if
an application is not behaving as expected and automatically remove it from the list of available backends to
mitigate or nullify the impact on users5 .
This deployment strategy is called rolling update: we run the old and the new version of the application
side by side, serving live traffic with both.
Throughout the process we always have three or more healthy backends: users should not experience any
kind of service degradation (assuming version B is not buggy).
follows:
Let’s go over the possible scenarios to convince ourselves that we cannot possibly deploy confirmation emails
all at once without incurring downtime.
We could first migrate the database and then deploy the new version.
This implies that the current version is running against the migrated database for some time: our current
implementation of POST /subscriptions does not know about status and it tries to insert new rows into
subscriptions without populating it. Given that status is constrained to be NOT NULL (i.e. it’s mandatory),
all inserts would fail - we would not be able to accept new subscribers until the new version of the application
is deployed.
Not good.
We could first deploy the new version and then migrate the database.
We get the opposite scenario: the new version of the application is running against the old database schema.
When POST /subscriptions is called, it tries to insert a row into subscriptions with a status field that
does not exist - all inserts fail and we cannot accept new subscribers until the database is migrated.
Once again, not good.
The same applies to database migrations and deployments: if we want to evolve the database schema we
cannot change the application behaviour at the same time.
Think of it as a database refactoring: we are laying down the foundations in order to build the behaviour we
need later on.
Creating migrations/20210307181858_add_status_to_subscriptions.sql
We can now edit the migration script to add status as an optional column to subscriptions:
280 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
Run the migration against your local database (SKIP_DOCKER=true ./scripts/init_db.sh): we can now
run our test suite to make sure that the code works as is even against the new database schema.
It should pass: go ahead and migrate the production database.
to
//! src/routes/subscriptions.rs
// [...]
Tests should pass - deploy the new version of the application to production.
The latest version of the application ensures that status is populated for all new subscribers.
To mark status as NOT NULL we just need to backfill the value for historical records: we’ll then be free to
7.6. DATABASE MIGRATIONS 281
Creating migrations/20210307184428_make_status_not_null_in_subscriptions.sql
We can migrate our local database, run our test suite and then deploy our production database.
We made it, we added status as a new mandatory column!
Creating migrations/20210307185410_create_subscription_tokens_table.sql
The migration is similar to the very first one we wrote to add subscriptions:
-- Create Subscription Tokens Table
CREATE TABLE subscription_tokens(
subscription_token TEXT NOT NULL,
subscriber_id uuid NOT NULL
REFERENCES subscriptions (id),
PRIMARY KEY (subscription_token)
);
282 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
Pay attention to the details here: the subscriber_id column in subscription_tokens is a foreign key.
For each row in subscription_tokens there must exist a row in subscriptions whose id field has the
same value of subscriber_id, otherwise the insertion fails. This guarantees that all tokens are attached to a
legitimate subscriber.
Migrate the production database again - we are done!
We need to spin up a mock server to stand in for Postmark’s API and intercept outgoing requests, just like
we did when we built the email client.
Let’s edit spawn_app accordingly:
//! tests/api/helpers.rs
// New import!
use wiremock::MockServer;
// [...]
// [...]
TestApp {
// [...],
email_server,
}
}
#[tokio::test]
async fn subscribe_sends_a_confirmation_email_for_valid_data() {
// Arrange
let app = spawn_app().await;
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
Mock::given(path("/email"))
.and(method("POST"))
284 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&app.email_server)
.await;
// Act
app.post_subscriptions(body.into()).await;
// Assert
// Mock asserts on drop
}
Notice that, on failure, wiremock gives us a detailed breakdown of what happened: we expected an incoming
request, we received none.
Let’s fix that.
#[tracing::instrument([...])]
pub async fn subscribe(
form: web::Form<FormData>, pool: web::Data<PgPool>
) -> HttpResponse {
let new_subscriber = match form.0.try_into() {
Ok(form) => form,
Err(_) => return HttpResponse::BadRequest().finish(),
};
match insert_subscriber(&pool, &new_subscriber).await {
Ok(_) => HttpResponse::Ok().finish(),
Err(_) => HttpResponse::InternalServerError().finish(),
7.7. SENDING A CONFIRMATION EMAIL 285
}
}
We can therefore access it in our handler using web::Data, just like we did for pool:
//! src/routes/subscriptions.rs
// New import!
use crate::email_client::EmailClient;
// [...]
#[tracing::instrument(
name = "Adding a new subscriber",
skip(form, pool, email_client),
fields(
subscriber_email = %form.email,
subscriber_name = %form.name
)
)]
pub async fn subscribe(
form: web::Form<FormData>,
pool: web::Data<PgPool>,
// Get the email client from the app context
email_client: web::Data<EmailClient>,
) -> HttpResponse {
286 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
// [...]
if insert_subscriber(&pool, &new_subscriber).await.is_err() {
return HttpResponse::InternalServerError().finish();
}
// Send a (useless) email to the new subscriber.
// We are ignoring email delivery errors for now.
if email_client
.send_email(
new_subscriber.email,
"Welcome!",
"Welcome to our newsletter!",
"Welcome to our newsletter!",
)
.await
.is_err()
{
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Ok().finish()
}
It is trying to send an email but it is failing because we haven’t setup a mock in that test. Let’s fix it:
//! tests/api/subscriptions.rs
// [...]
#[tokio::test]
async fn subscribe_returns_a_200_for_valid_form_data() {
// Arrange
let app = spawn_app().await;
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
// New section!
Mock::given(path("/email"))
.and(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&app.email_server)
7.7. SENDING A CONFIRMATION EMAIL 287
.await;
// Act
let response = app.post_subscriptions(body.into()).await;
// Assert
assert_eq!(200, response.status().as_u16());
// [...]
}
#[tokio::test]
async fn subscribe_sends_a_confirmation_email_with_a_link() {
// Arrange
let app = spawn_app().await;
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
Mock::given(path("/email"))
.and(method("POST"))
.respond_with(ResponseTemplate::new(200))
// We are not setting an expectation here anymore
// The test is focused on another aspect of the app
// behaviour.
.mount(&app.email_server)
.await;
288 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
// Act
app.post_subscriptions(body.into()).await;
// Assert
// Get the first intercepted request
let email_request = &app.email_server.received_requests().await.unwrap()[0];
// Parse the body as JSON, starting from raw bytes
let body: serde_json::Value = serde_json::from_slice(&email_request.body)
.unwrap();
}
We can use linkify to scan text and return an iterator of extracted links.
//! tests/api/subscriptions.rs
// [...]
#[tokio::test]
async fn subscribe_sends_a_confirmation_email_with_a_link() {
// [...]
let body: serde_json::Value = serde_json::from_slice(&email_request.body)
.unwrap();
// Extract the link from one of the request fields.
let get_link = |s: &str| {
let links: Vec<_> = linkify::LinkFinder::new()
.links(s)
.filter(|l| *l.kind() == linkify::LinkKind::Url)
.collect();
assert_eq!(links.len(), 1);
links[0].as_str().to_owned()
};
If we run the test suite, we should see the new test case failing:
failures:
thread 'subscriptions::subscribe_sends_a_confirmation_email_with_a_link'
panicked at 'assertion failed: `(left == right)`
left: `0`,
right: `1`', tests/api/subscriptions.rs:71:9
#[tracing::instrument([...])]
pub async fn subscribe(/* */) -> HttpResponse {
// [...]
let confirmation_link =
"https://there-is-no-such-domain.com/subscriptions/confirm";
if email_client
.send_email(
new_subscriber.email,
"Welcome!",
&format!(
"Welcome to our newsletter!<br />\
Click <a href=\"{}\">here</a> to confirm your subscription.",
confirmation_link
),
&format!(
"Welcome to our newsletter!\nVisit {} to confirm your subscription.",
confirmation_link
),
)
.await
.is_err()
{
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Ok().finish()
290 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
7.7.2.3 Refactor
Our request handler is getting a bit busy - there is a lot of code dealing with our confirmation email now.
Let’s extract it into a separate function:
//! src/routes/subscriptions.rs
// [...]
#[tracing::instrument([...])]
pub async fn subscribe(/* */) -> HttpResponse {
let new_subscriber = match form.0.try_into() {
Ok(form) => form,
Err(_) => return HttpResponse::BadRequest().finish(),
};
if insert_subscriber(&pool, &new_subscriber).await.is_err() {
return HttpResponse::InternalServerError().finish();
}
if send_confirmation_email(&email_client, new_subscriber)
.await
.is_err()
{
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Ok().finish()
}
#[tracing::instrument(
name = "Send a confirmation email to a new subscriber",
skip(email_client, new_subscriber)
)]
pub async fn send_confirmation_email(
email_client: &EmailClient,
new_subscriber: NewSubscriber,
) -> Result<(), reqwest::Error> {
let confirmation_link =
"https://there-is-no-such-domain.com/subscriptions/confirm";
let plain_body = format!(
"Welcome to our newsletter!\nVisit {} to confirm your subscription.",
confirmation_link
);
let html_body = format!(
7.7. SENDING A CONFIRMATION EMAIL 291
subscribe is once again focused on the overall flow, without bothering with details of any of its steps.
#[tokio::test]
async fn subscribe_returns_a_200_for_valid_form_data() {
// Arrange
let app = spawn_app().await;
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
Mock::given(path("/email"))
.and(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&app.email_server)
.await;
// Act
let response = app.post_subscriptions(body.into()).await;
292 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
// Assert
assert_eq!(200, response.status().as_u16());
assert_eq!(saved.email, "[email protected]");
assert_eq!(saved.name, "le guin");
}
The name is a bit of a lie - it is checking the status code and performing some assertions against the state
stored in the database.
Let’s split it into two separate test cases:
//! tests/api/subscriptions.rs
// [...]
#[tokio::test]
async fn subscribe_returns_a_200_for_valid_form_data() {
// Arrange
let app = spawn_app().await;
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
Mock::given(path("/email"))
.and(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&app.email_server)
.await;
// Act
let response = app.post_subscriptions(body.into()).await;
// Assert
assert_eq!(200, response.status().as_u16());
}
#[tokio::test]
async fn subscribe_persists_the_new_subscriber() {
// Arrange
let app = spawn_app().await;
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
Mock::given(path("/email"))
.and(method("POST"))
7.7. SENDING A CONFIRMATION EMAIL 293
.respond_with(ResponseTemplate::new(200))
.mount(&app.email_server)
.await;
// Act
app.post_subscriptions(body.into()).await;
// Assert
let saved = sqlx::query!("SELECT email, name FROM subscriptions",)
.fetch_one(&app.db_pool)
.await
.expect("Failed to fetch saved subscription.");
assert_eq!(saved.email, "[email protected]");
assert_eq!(saved.name, "le guin");
}
We can now modify the second test case to check the status as well.
//! tests/api/subscriptions.rs
// [...]
#[tokio::test]
async fn subscribe_persists_the_new_subscriber() {
// [...]
// Assert
let saved = sqlx::query!("SELECT email, name, status FROM subscriptions",)
.fetch_one(&app.db_pool)
.await
.expect("Failed to fetch saved subscription.");
assert_eq!(saved.email, "[email protected]");
assert_eq!(saved.name, "le guin");
assert_eq!(saved.status, "pending_confirmation");
}
right: `"pending_confirmation"`'
#[tracing::instrument([...])]
pub async fn insert_subscriber([...]) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"INSERT INTO subscriptions (id, email, name, subscribed_at, status)
VALUES ($1, $2, $3, $4, 'confirmed')"#,
// [...]
)
// [...]
}
#[tracing::instrument([...])]
pub async fn insert_subscriber([...]) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"INSERT INTO subscriptions (id, email, name, subscribed_at, status)
VALUES ($1, $2, $3, $4, 'pending_confirmation')"#,
// [...]
)
// [...]
}
We have done most of the groundwork on POST /subscriptions - time to shift our focus to the other half
of the journey, GET /subscriptions/confirm.
We want to build up the skeleton of the endpoint - we need to register the handler against the path in
src/startup.rs and reject incoming requests without the required query parameter, subscription_token.
This will allow us to then build the happy path without having to write a massive amount of code all at once
- baby steps!
//! tests/api/main.rs
mod health_check;
mod helpers;
mod subscriptions;
// New module!
mod subscriptions_confirm;
//! tests/api/subscriptions_confirm.rs
use crate::helpers::spawn_app;
#[tokio::test]
async fn confirmations_without_token_are_rejected_with_a_400() {
// Arrange
let app = spawn_app().await;
// Act
let response = reqwest::get(&format!("{}/subscriptions/confirm", app.address))
.await
.unwrap();
// Assert
assert_eq!(response.status().as_u16(), 400);
}
//! src/routes/mod.rs
mod health_check;
mod subscriptions;
// New module!
mod subscriptions_confirm;
//! src/routes/subscriptions_confirm.rs
use actix_web::HttpResponse;
#[tracing::instrument(
name = "Confirm a pending subscriber",
)]
pub async fn confirm() -> HttpResponse {
HttpResponse::Ok().finish()
}
//! src/startup.rs
// [...]
use crate::routes::confirm;
It worked!
Time to turn that 200 OK in a 400 Bad Request.
We want to ensure that there is a subscription_token query parameter: we can rely on another one
actix-web’s extractors - Query.
//! src/routes/subscriptions_confirm.rs
use actix_web::{HttpResponse, web};
7.7. SENDING A CONFIRMATION EMAIL 297
#[derive(serde::Deserialize)]
pub struct Parameters {
subscription_token: String
}
#[tracing::instrument(
name = "Confirm a pending subscriber",
skip(_parameters)
)]
pub async fn confirm(_parameters: web::Query<Parameters>) -> HttpResponse {
HttpResponse::Ok().finish()
}
The Parameters struct defines all the query parameters that we expect to see in the incoming request. It
needs to implement serde::Deserialize to enable actix-web to build it from the incoming request path.
It is enough to add a function parameter of type web::Query<Parameter> to confirm to instruct actix-web
to only call the handler if the extraction was successful. If the extraction failed a 400 Bad Request is auto-
matically returned to the caller.
Our test should now pass.
#[tokio::test]
async fn the_link_returned_by_subscribe_returns_a_200_if_called() {
// Arrange
let app = spawn_app().await;
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
298 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
Mock::given(path("/email"))
.and(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&app.email_server)
.await;
app.post_subscriptions(body.into()).await;
let email_request = &app.email_server.received_requests().await.unwrap()[0];
let body: serde_json::Value = serde_json::from_slice(&email_request.body)
.unwrap();
// Extract the link from one of the request fields.
let get_link = |s: &str| {
let links: Vec<_> = linkify::LinkFinder::new()
.links(s)
.filter(|l| *l.kind() == linkify::LinkKind::Url)
.collect();
assert_eq!(links.len(), 1);
links[0].as_str().to_owned()
};
let raw_confirmation_link = &get_link(&body["HtmlBody"].as_str().unwrap());
let confirmation_link = Url::parse(raw_confirmation_link).unwrap();
// Let's make sure we don't call random APIs on the web
assert_eq!(confirmation_link.host_str().unwrap(), "127.0.0.1");
// Act
let response = reqwest::get(confirmation_link)
.await
.unwrap();
// Assert
assert_eq!(response.status().as_u16(), 200);
}
It fails with
thread subscriptions_confirm::the_link_returned_by_subscribe_returns_a_200_if_called
panicked at 'assertion failed: `(left == right)`
left: `"there-is-no-such-domain.com"`,
right: `"127.0.0.1"`'
There is a fair amount of code duplication going on here, but we will take care of it in due time.
Our primary focus is getting the test to pass now.
7.7. SENDING A CONFIRMATION EMAIL 299
#[tracing::instrument([...])]
pub async fn send_confirmation_email([...]) -> Result<(), reqwest::Error> {
let confirmation_link = "https://there-is-no-such-domain.com/subscriptions/confirm";
// [...]
}
The domain and the protocol are going to vary according to the environment the application is running
into: it will be http://127.0.0.1 for our tests, it should be a proper DNS record with HTTPS when our
application is running in production.
The easiest way to get it right is to pass the domain in as a configuration value.
Let’s add a new field to ApplicationSettings:
//! src/configuration.rs
// [...]
#[derive(serde::Deserialize, Clone)]
pub struct ApplicationSettings {
#[serde(deserialize_with = "deserialize_number_from_string")]
pub port: u16,
pub host: String,
// New field!
pub base_url: String
}
# configuration/local.yaml
application:
base_url: "http://127.0.0.1"
# [...]
#! spec.yaml
# [...]
services:
- name: zero2prod
# [...]
envs:
# We use DO's APP_URL to inject the dynamically
# provisioned base url as an environment variable
- key: APP_APPLICATION__BASE_URL
300 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
scope: RUN_TIME
value: ${APP_URL}
# [...]
# [...]
Remember to apply the changes to DigitalOcean every time we touch spec.yaml: grab your app
identifier via doctl apps list --format ID and then run doctl apps update $APP_ID --spec
spec.yaml.
We now need to register the value in the application context - you should be familiar with the process at this
point:
//! src/startup.rs
// [...]
impl Application {
pub async fn build(configuration: Settings) -> Result<Self, std::io::Error> {
// [...]
let server = run(
listener,
connection_pool,
email_client,
// New parameter!
configuration.application.base_url,
)?;
// [...]
}
fn run(
listener: TcpListener,
db_pool: PgPool,
email_client: EmailClient,
// New parameter!
base_url: String,
7.7. SENDING A CONFIRMATION EMAIL 301
#[tracing::instrument(
skip(form, pool, email_client, base_url),
[...]
)]
pub async fn subscribe(
// [...]
// New parameter!
base_url: web::Data<ApplicationBaseUrl>,
) -> HttpResponse {
// [...]
// Pass the application url
if send_confirmation_email(
&email_client,
new_subscriber,
&base_url.0
)
.await
.is_err()
{
return HttpResponse::InternalServerError().finish();
}
// [...]
}
#[tracing::instrument(
skip(email_client, new_subscriber, base_url)
[...]
302 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
)]
pub async fn send_confirmation_email(
// [...]
// New parameter!
base_url: &str,
) -> Result<(), reqwest::Error> {
// Build a confirmation link with a dynamic root
let confirmation_link = format!("{}/subscriptions/confirm", base_url);
// [...]
}
The host is correct, but the reqwest::Client in our test is failing to establish a connection. What is going
wrong?
If you look closely, you’ll notice port: None - we are sending our request to
http://127.0.0.1/subscriptions/confirm without specifying the port our test server is listening
on.
The tricky bit, here, is the sequence of events: we pass in the application_url configuration value before
spinning up the server, therefore we do not know what port it is going to listen to (given that the port is
7.7. SENDING A CONFIRMATION EMAIL 303
TestApp {
address: format!("http://localhost:{}", application_port),
port: application_port,
db_pool: get_connection_pool(&configuration.database),
email_server,
}
}
We can then use it in the test logic to edit the confirmation link:
//! tests/api/subscriptions_confirm.rs
// [...]
#[tokio::test]
async fn the_link_returned_by_subscribe_returns_a_200_if_called() {
// [...]
let mut confirmation_link = Url::parse(raw_confirmation_link).unwrap();
assert_eq!(confirmation_link.host_str().unwrap(), "127.0.0.1");
// Let's rewrite the URL to include the port
confirmation_link.set_port(Some(app.port)).unwrap();
// [...]
}
304 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
We get a 400 Bad Request back because our confirmation link does not have a subscription_token query
parameter attached.
Let’s fix it by hard-coding one for the time being:
//! src/routes/subscriptions.rs
// [...]
7.7.5.3 Refactor
The logic to extract the two confirmation links from the outgoing email request is duplicated across two of
our tests - we will likely add more that rely on it as we flesh out the remaining bits and pieces of this feature.
It makes sense to extract it in its own helper function.
//! tests/api/helpers.rs
// [...]
impl TestApp {
// [...]
/// Extract the confirmation links embedded in the request to the email API.
pub fn get_confirmation_links(
&self,
7.7. SENDING A CONFIRMATION EMAIL 305
email_request: &wiremock::Request
) -> ConfirmationLinks {
let body: serde_json::Value = serde_json::from_slice(
&email_request.body
).unwrap();
We are adding it as a method on TestApp in order to get access to the application port, which we need to
inject into the links.
It could as well have been a free function taking both wiremock::Request and TestApp (or u16) as paramet-
ers - a matter of taste.
We can now massively simplify our two test cases:
//! tests/api/subscriptions.rs
// [...]
#[tokio::test]
async fn subscribe_sends_a_confirmation_email_with_a_link() {
// Arrange
let app = spawn_app().await;
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
306 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
Mock::given(path("/email"))
.and(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&app.email_server)
.await;
// Act
app.post_subscriptions(body.into()).await;
// Assert
let email_request = &app.email_server.received_requests().await.unwrap()[0];
let confirmation_links = app.get_confirmation_links(&email_request);
//! tests/api/subscriptions_confirm.rs
// [...]
#[tokio::test]
async fn the_link_returned_by_subscribe_returns_a_200_if_called() {
// Arrange
let app = spawn_app().await;
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
Mock::given(path("/email"))
.and(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&app.email_server)
.await;
app.post_subscriptions(body.into()).await;
let email_request = &app.email_server.received_requests().await.unwrap()[0];
let confirmation_links = app.get_confirmation_links(&email_request);
// Act
let response = reqwest::get(confirmation_links.html)
.await
.unwrap();
// Assert
assert_eq!(response.status().as_u16(), 200);
7.7. SENDING A CONFIRMATION EMAIL 307
#[tokio::test]
async fn clicking_on_the_confirmation_link_confirms_a_subscriber() {
// Arrange
let app = spawn_app().await;
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
Mock::given(path("/email"))
.and(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&app.email_server)
.await;
app.post_subscriptions(body.into()).await;
let email_request = &app.email_server.received_requests().await.unwrap()[0];
let confirmation_links = app.get_confirmation_links(&email_request);
// Act
reqwest::get(confirmation_links.html)
.await
.unwrap()
.error_for_status()
.unwrap();
// Assert
let saved = sqlx::query!("SELECT email, name, status FROM subscriptions",)
.fetch_one(&app.db_pool)
.await
.expect("Failed to fetch saved subscription.");
308 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
assert_eq!(saved.email, "[email protected]");
assert_eq!(saved.name, "le guin");
assert_eq!(saved.status, "confirmed");
}
Let’s refactor send_confirmation_email to take the token as a parameter - it will make it easier to add the
generation logic upstream.
//! src/routes/subscriptions.rs
// [...]
#[tracing::instrument([...])]
pub async fn subscribe([...]) -> HttpResponse {
// [...]
if send_confirmation_email(
&email_client,
new_subscriber,
&base_url.0,
// New parameter!
"mytoken"
)
.await
.is_err() {
7.7. SENDING A CONFIRMATION EMAIL 309
return HttpResponse::InternalServerError().finish();
}
// [...]
}
#[tracing::instrument(
name = "Send a confirmation email to a new subscriber",
skip(email_client, new_subscriber, base_url, subscription_token)
)]
pub async fn send_confirmation_email(
email_client: &EmailClient,
new_subscriber: NewSubscriber,
base_url: &str,
// New parameter!
subscription_token: &str
) -> Result<(), reqwest::Error> {
let confirmation_link = format!(
"{}/subscriptions/confirm?subscription_token={}",
base_url,
subscription_token
);
// [...]
}
Our subscription tokens are not passwords: they are single-use and they do not grant access to protected
information.6 We need them to be hard enough to guess while keeping in mind that the worst-case scenario
is an unwanted newsletter subscription landing in someone’s inbox.
Given our requirements it should be enough to use a cryptographically secure pseudo-random number gen-
erator - a CSPRNG, if you are into obscure acronyms.
Every time we need to generate a subscription token we can sample a sufficiently-long sequence of alphanu-
meric characters.
[dependencies]
# [...]
# We need the `std_rng` to get access to the PRNG we want
rand = { version = "0.8", features=["std_rng"] }
6
You could say that our token is a nonce.
310 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
//! src/routes/subscriptions.rs
use rand::distributions::Alphanumeric;
use rand::{thread_rng, Rng};
// [...]
Using 25 characters we get roughly ~10^45 possible tokens - it should be more than enough for our use case.
To check if a token is valid in GET /subscriptions/confirm we need POST /subscriptions to store the
newly minted tokens in the database.
The table we added for this purpose, subscription_tokens, has two columns: subscription_token and
subscriber_id.
We are currently generating the subscriber identifier in insert_subscriber but we never return it to the
caller:
#[tracing::instrument([...])]
pub async fn insert_subscriber([...]) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"[...]"#,
// The subscriber id, never returned or bound to a variable
Uuid::new_v4(),
// [...]
)
// [...]
}
Ok(subscriber_id)
}
#[tracing::instrument(
name = "Store subscription token in the database",
skip(subscription_token, pool)
)]
pub async fn store_token(
pool: &PgPool,
subscriber_id: Uuid,
subscription_token: &str,
312 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
//! src/routes/subscriptions_confirm.rs
use actix_web::{HttpResponse, web};
#[derive(serde::Deserialize)]
pub struct Parameters {
subscription_token: String
}
#[tracing::instrument(
name = "Confirm a pending subscriber",
skip(_parameters)
)]
pub async fn confirm(_parameters: web::Query<Parameters>) -> HttpResponse {
HttpResponse::Ok().finish()
}
We need to:
• get a reference to the database pool;
• retrieve the subscriber id associated with the token (if one exists);
• change the subscriber status to confirmed.
Nothing we haven’t done before - let’s get cracking!
use actix_web::{web, HttpResponse};
use sqlx::PgPool;
use uuid::Uuid;
7.7. SENDING A CONFIRMATION EMAIL 313
#[derive(serde::Deserialize)]
pub struct Parameters {
subscription_token: String,
}
#[tracing::instrument(
name = "Confirm a pending subscriber",
skip(parameters, pool)
)]
pub async fn confirm(
parameters: web::Query<Parameters>,
pool: web::Data<PgPool>,
) -> HttpResponse {
let id = match get_subscriber_id_from_token(
&pool,
¶meters.subscription_token
).await {
Ok(id) => id,
Err(_) => return HttpResponse::InternalServerError().finish(),
};
match id {
// Non-existing token!
None => HttpResponse::Unauthorized().finish(),
Some(subscriber_id) => {
if confirm_subscriber(&pool, subscriber_id).await.is_err() {
return HttpResponse::InternalServerError().finish();
}
HttpResponse::Ok().finish()
}
}
}
#[tracing::instrument(
name = "Mark subscriber as confirmed",
skip(subscriber_id, pool)
)]
pub async fn confirm_subscriber(
pool: &PgPool,
subscriber_id: Uuid
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"UPDATE subscriptions SET status = 'confirmed' WHERE id = $1"#,
subscriber_id,
)
314 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
.execute(pool)
.await
.map_err(|e| {
tracing::error!("Failed to execute query: {:?}", e);
e
})?;
Ok(())
}
#[tracing::instrument(
name = "Get subscriber_id from token",
skip(subscription_token, pool)
)]
pub async fn get_subscriber_id_from_token(
pool: &PgPool,
subscription_token: &str,
) -> Result<Option<Uuid>, sqlx::Error> {
let result = sqlx::query!(
"SELECT subscriber_id FROM subscription_tokens \
WHERE subscription_token = $1",
subscription_token,
)
.fetch_optional(pool)
.await
.map_err(|e| {
tracing::error!("Failed to execute query: {:?}", e);
e
})?;
Ok(result.map(|r| r.subscriber_id))
}
Running target/debug/deps/api-5a717281b98f7c41
running 10 tests
[...]
The first query might complete successfully, but the second one might never be executed.
There are three possible states for our database after an invocation of POST /subscriptions:
The more queries you have, the worse it gets to reason about the possible end states of our database.
Relational databases (and a few others) provide a mechanism to mitigate this issue: transactions.
Transactions are a way to group together related operations in a single unit of work.
The database guarantees that all operations within a transaction will succeed or fail together: the database
will never be left in a state where the effect of only a subset of the queries in a transaction is visible.
Going back to our example, if we wrap the two INSERT queries in a transaction we now have two possible
end states:
If any of the queries within a transaction fails the database rolls back: all changes performed by previous
queries are reverted, the operation is aborted.
You can also explicitly trigger a rollback with the ROLLBACK statement.
316 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
Transactions are a deep topic: they not only provide a way to convert multiple statements into an all-or-
nothing operation, they also hide the effect of uncommitted changes from other queries that might be run-
ning, concurrently, against the same tables.
As your needs evolves, you will often want to explicitly choose the isolation level of your transactions to
fine-tune the concurrency guarantees provided by the database on your operations. Getting a good grip on
the different kinds of concurrency-related issues (e.g. dirty reads, phantom reads, etc.) becomes more and
more important as your system grows in scale and complexity.
I can’t recommend “Designing Data Intensive Applications” enough if you want to learn more about these
topics.
//! src/routes/subscriptions.rs
use sqlx::{Postgres, Transaction, Executor};
// [...]
#[tracing::instrument([...])]
pub async fn subscribe([...]) -> HttpResponse {
// [...]
let mut transaction = match pool.begin().await {
Ok(transaction) => transaction,
Err(_) => return HttpResponse::InternalServerError().finish(),
};
7.8. DATABASE TRANSACTIONS 317
#[tracing::instrument(
name = "Saving new subscriber details in the database",
skip(new_subscriber, transaction)
)]
pub async fn insert_subscriber(
transaction: &mut Transaction<'_, Postgres>,
new_subscriber: &NewSubscriber,
) -> Result<Uuid, sqlx::Error> {
let subscriber_id = Uuid::new_v4();
let query = sqlx::query!([...]);
transaction.execute(query)
// [...]
}
#[tracing::instrument(
name = "Store subscription token in the database",
skip(subscription_token, transaction)
)]
pub async fn store_token(
transaction: &mut Transaction<'_, Postgres>,
subscriber_id: Uuid,
subscription_token: &str,
) -> Result<(), sqlx::Error> {
let query = sqlx::query!([..]);
transaction.execute(query)
// [...]
}
318 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
If you run cargo test now you will see something funny: some of our tests are failing!
Why is that happening?
As we discussed, a transaction has to either be committed or rolled back.
Transaction exposes two dedicated methods: Transaction::commit, to persist changes, and
Transaction::rollback, to abort the whole operation.
We are not calling either - what happens in that case?
We can look at sqlx’s source code to understand better.
In particular, Transaction’s Drop implementation:
impl<'c, DB> Drop for Transaction<'c, DB>
where
DB: Database,
{
fn drop(&mut self) {
if self.open {
// starts a rollback operation
self.open is an internal boolean flag attached to the connection used to begin the transaction and run the
queries attached to it.
When a transaction is created, using begin, it is set to true until either rollback or commit are called:
impl<'c, DB> Transaction<'c, DB>
where
DB: Database,
{
pub(crate) fn begin(
conn: impl Into<MaybePoolConnection<'c, DB>>,
) -> BoxFuture<'c, Result<Self, Error>> {
let mut conn = conn.into();
Box::pin(async move {
DB::TransactionManager::begin(&mut conn).await?;
7.8. DATABASE TRANSACTIONS 319
Ok(Self {
connection: conn,
open: true,
})
})
}
Ok(())
}
Ok(())
}
}
In other words: if commit or rollback have not been called before the Transaction object goes out of scope
(i.e. Drop is invoked), a rollback command is queued to be executed as soon as an opportunity arises.7
That is why our tests are failing: we are using a transaction but we are not explicitly committing the changes.
When the connection goes back into the pool, at the end of our request handler, all changes are rolled back
and our test expectations are not met.
We can fix it by adding a one-liner to subscribe:
//! src/routes/subscriptions.rs
use sqlx::{Postgres, Transaction};
// [...]
#[tracing::instrument([...])]
pub async fn subscribe([...]) -> HttpResponse {
// [...]
let mut transaction = match pool.begin().await {
Ok(transaction) => transaction,
Err(_) => return HttpResponse::InternalServerError().finish(),
7
Rust does not currently support asynchronous destructors, a.k.a. AsyncDrop. There have been some discussions on the topic,
but there is no consensus yet. This is a constraint on sqlx: when Transaction goes out of scope it can enqueue a rollback operation,
but it cannot execute it immediately! Is it ok? Is it a sound API? There are different views - see diesel’s async issue for an overview.
My personal view is that the benefits brought by sqlx to the table offset the risks, but you should make an informed decision taking
into account the tradeoffs of your application and use case.
320 CHAPTER 7. REJECT INVALID SUBSCRIBERS #2
};
let subscriber_id = match insert_subscriber(
&mut transaction,
&new_subscriber
).await {
Ok(subscriber_id) => subscriber_id,
Err(_) => return HttpResponse::InternalServerError().finish(),
};
let subscription_token = generate_subscription_token();
if store_token(&mut transaction, subscriber_id, &subscription_token)
.await
.is_err()
{
return HttpResponse::InternalServerError().finish();
}
if transaction.commit().await.is_err() {
return HttpResponse::InternalServerError().finish();
}
// [...]
}
7.9 Summary
This chapter was a long journey, but you have come a long way as well!
The skeleton of our application has started to shape up, starting with our test suite. Features are moving
along as well: we now have a functional subscription flow, with a proper confirmation email.
More importantly: we are getting into the rhythm of writing Rust code.
The very end of the chapter has been a long pair programming session where we have made significant pro-
gress without introducing many new concepts.
This is a great moment to go off and explore a bit on your own: improve on what we built so far!
There are plenty of opportunities:
• What happens if a user tries to subscribe twice? Make sure that they receive two confirmation emails;
• What happens if a user clicks on a confirmation link twice?
• What happens if the subscription token is well-formatted but non-existent?
• Add validation on the incoming token, we are currently passing the raw user input straight into a
query (thanks sqlx for protecting us from SQL injections <3);
• Use a proper templating solution for our emails (e.g. tera);
• Anything that comes to your mind!
7.9. SUMMARY 321
Error Handling
To send a confirmation email we had to stitch together multiple operations: validation of user input, email
dispatch, various database queries.
They all have one thing in common: they may fail.
In Chapter 6 we discussed the building blocks of error handling in Rust - Result and the ? operator.
We left many questions unanswered: how do errors fit within the broader architecture of our application?
What does a good error look like? Who are errors for? Should we use a library? Which one?
An in-depth analysis of error handling patterns in Rust will be the sole focus of this chapter.
323
324 CHAPTER 8. ERROR HANDLING
.await
.map_err(|e| {
tracing::error!("Failed to execute query: {:?}", e);
e
})?;
Ok(())
}
We are trying to insert a row into the subscription_tokens table in order to store a newly-generated token
against a subscriber_id.
execute is a fallible operation: we might have a network issue while talking to the database, the row we are
trying to insert might violate some table constraints (e.g. uniqueness of the primary key), etc.
Rust leverages the type system to communicate that an operation may not succeed: the return type of
execute is Result, an enum.
The caller is then forced by the compiler to express how they plan to handle both scenarios - success and
failure.
If our only goal was to communicate to the caller that an error happened, we could use a simpler definition
for Result:
pub enum ResultSignal<Success> {
Ok(Success),
Err
}
There would be no need for a generic Error type - we could just check that execute returned the Err variant,
e.g.
let outcome = transaction.execute(query).await;
if outcome == ResultSignal::Err {
// Do something if it failed
}
8.1. WHAT IS THE PURPOSE OF ERRORS? 325
This works if there is only one failure mode. Truth is, operations can fail in multiple ways and we might
want to react differently depending on what happened.
Let’s look at the skeleton of sqlx::Error, the error type for execute:
//! sqlx-core/src/error.rs
Err(()) might be enough for the caller to determine what to do - e.g. return a 500 Internal Server Error
to the user.
The implementation details may vary, the purpose stays the same: help a human understand what is going
wrong.
That’s exactly what we are doing in the initial code snippet:
326 CHAPTER 8. ERROR HANDLING
//! src/routes/subscriptions.rs
// [...]
If the query fails, we grab the error and emit a log event. We can then go and inspect the error logs when
investigating the database issue.
They receive an HTTP response with no body and a 500 Internal Server Error status code.
The status code fulfills the same purpose of the error type in store_token: it is a machine-parsable piece
8.1. WHAT IS THE PURPOSE OF ERRORS? 327
of information that the caller (e.g. the browser) can use to determine what to do next (e.g. retry the request
assuming it’s a transient failure).
What about the human behind the browser? What are we telling them?
Not much, the response body is empty.
That is actually a good implementation: the user should not have to care about the internals of the API they
are calling - they have no mental model of it and no way to determine why it is failing. That’s the realm of
the operator.
We are omitting those details by design.
In other circumstances, instead, we need to convey additional information to the human user. Let’s look at
our input validation for the same endpoint:
//! src/routes/subscriptions.rs
#[derive(serde::Deserialize)]
pub struct FormData {
email: String,
name: String,
}
We received an email address and a name as data attached to the form submitted by the user. Both fields are
going through an additional round of validation - SubscriberName::parse and SubscriberEmail::parse.
Those two methods are fallible - they return a String as error type to explain what has gone wrong:
//! src/domain/subscriber_email.rs
// [...]
impl SubscriberEmail {
pub fn parse(s: String) -> Result<SubscriberEmail, String> {
if validate_email(&s) {
Ok(Self(s))
} else {
Err(format!("{} is not a valid subscriber email.", s))
}
}
328 CHAPTER 8. ERROR HANDLING
It is, I must admit, not the most useful error message: we are telling the user that the email address they
entered is wrong, but we are not helping them to determine why.
In the end, it doesn’t matter: we are not sending any of that information to the user as part of the response
of the API - they are getting a 400 Bad Request with no body.
//! src/routes/subscription.rs
// [...]
This is a poor error: the user is left in the dark and cannot adapt their behaviour as required.
8.1.3 Summary
Let’s summarise what we uncovered so far.
Errors serve two1 main purposes:
• Control flow (i.e. determine what do next);
• Reporting (e.g. investigate, after the fact, what went wrong on).
We can also distinguish errors based on their location:
• Internal (i.e. a function calling another function within our application);
• At the edge (i.e. an API request that we failed to fulfill).
Control flow is scripted: all information required to take a decision on what to do next must be accessible
to a machine.
We use types (e.g. enum variants), methods and fields for internal errors.
We rely on status codes for errors at the edge.
Error reports, instead, are primarily consumed by humans.
The content has to be tuned depending on the audience.
An operator has access to the internals of the system - they should be provided with as much context as
possible on the failure mode.
A user sits outside the boundary of the application2 : they should only be given the amount of information
1
We are borrowing the terminology introduced by Jane Lusby in “Error handling Isn’t All About Errors”, a talk from RustConf
2020. If you haven’t watched it yet, close the book and open YouTube - you will not regret it.
2
It is good to keep in mind that the line between a user and an operator can be blurry - e.g. a user might have access to the source
code or they might be running the software on their own hardware. They might have to wear the operator’s hat at times. For similar
scenarios there should be configuration knobs (e.g. --verbose or an environment variable for a CLI) to clearly inform the software
of the human intent so that it can provide diagnostics at the right level of detail and abstraction.
8.2. ERROR REPORTING FOR OPERATORS 329
We will spend the rest of the chapter improving our error handling strategy for each of the cells in the table.
#[tokio::test]
async fn subscribe_fails_if_there_is_a_fatal_database_error() {
// Arrange
let app = spawn_app().await;
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
// Sabotage the database
sqlx::query!("ALTER TABLE subscription_tokens DROP COLUMN subscription_token;",)
.execute(&app.db_pool)
.await
.unwrap();
// Act
let response = app.post_subscriptions(body.into()).await;
// Assert
assert_eq!(response.status().as_u16(), 500);
}
The test passes straight away - let’s look at the log emitted by the application3 .
# sqlx logs are a bit spammy, cutting them out to reduce noise
export RUST_LOG="sqlx=error,info"
3
In an ideal scenario we would actually be writing a test to verify the properties of the logs emitted by our application. This is
somewhat cumbersome to do today - I am looking forward to revising this chapter when better tooling becomes available (or I get
nerd-sniped into writing it).
330 CHAPTER 8. ERROR HANDLING
export TEST_LOG=true
cargo t subscribe_fails_if_there_is_a_fatal_database_error | bunyan
We don’t learn a lot more than that: both exception.details and exception.message are empty.
The situation does not get much better if we look at the next log, emitted by tracing_actix_web:
ERROR: [HTTP REQUEST - EVENT] Error encountered while
processing the incoming HTTP request: ""
exception.details="",
exception.message="",
target=tracing_actix_web::middleware
No actionable information whatsoever. Logging “Oops! Something went wrong!” would have been just as
useful.
We need to keep looking, all the way to the last remaining error log:
ERROR: [STORE SUBSCRIPTION TOKEN IN THE DATABASE - EVENT] Failed to execute query:
Database(PgDatabaseError {
severity: Error,
code: "42703",
message:
"column 'subscription_token' of relation
'subscription_tokens' does not exist",
...
})
target=zero2prod::routes::subscriptions
Something went wrong when we tried talking to the database - we were expecting to see a
subscription_token column in the subscription_tokens table but, for some reason, it was not there.
This is actually useful!
Is it the cause of the 500 though?
Difficult to say just by looking at the logs - a developer will have to clone the codebase, check where that log
line is coming from and make sure that it’s indeed the cause of the issue.
It can be done, but it takes time: it would be much easier if the [HTTP REQUEST - END] log record reported
something useful about the underlying root cause in exception.details and exception.message.
.is_err()
{
return HttpResponse::InternalServerError().finish();
}
// [...]
}
The useful error log we found is indeed the one emitted by that tracing::error call - the error message
includes the sqlx::Error returned by execute.
We propagate the error upwards using the ? operator, but the chain breaks in subscribe - we discard the
error we received from store_token and build a bare 500 response.
We need to start leveraging the error handling machinery exposed by actix_web - in particular,
actix_web::Error. According to the documentation:
actix_web::Error is used to carry errors from std::error through actix_web in a convenient way.
It sounds exactly like what we are looking for. How do we build an instance of actix_web::Error?
The documentation states that
|
= note: define and implement a trait or new type instead
We just bumped into Rust’s orphan rule: it is forbidden to implement a foreign trait for a foreign type, where
foreign stands for “from another crate”.
This restriction is meant to preserve coherence: imagine if you added a dependency that defined its own
implementation of ResponseError for sqlx::Error - which one should the compiler use when the trait
methods are invoked?
Orphan rule aside, it would still be a mistake for us to implement ResponseError for sqlx::Error.
We want to return a 500 Internal Server Error when we run into a sqlx::Error while trying to persist a
subscriber token.
In another circumstance we might wish to handle a sqlx::Error differently.
We should follow the compiler’s suggestion: define a new type to wrap sqlx::Error.
//! src/routes/subscriptions.rs
// [...]
|
59 | pub trait ResponseError: fmt::Debug + fmt::Display {
| ------------
| required by this bound in `ResponseError`
|
= help: the trait `std::fmt::Display` is not implemented for `StoreTokenError`
When working with errors, we can reason about the two traits as follows: Debug returns as much information
as possible while Display gives us a brief description of the failure we encountered, with the essential amount
of context.
f,
"A database error was encountered while \
trying to store a subscription token."
)
}
}
It compiles!
We can now leverage it in our request handler:
//! src/routes/subscriptions.rs
// [...]
...
INFO: [HTTP REQUEST - END]
exception.details= StoreTokenError(
Database(
PgDatabaseError {
severity: Error,
code: "42703",
message:
"column 'subscription_token' of relation
'subscription_tokens' does not exist",
...
}
)
)
exception.message=
"A database failure was encountered while
8.2. ERROR REPORTING FOR OPERATORS 337
Much better!
The log record emitted at the end of request processing now contains both an in-depth and brief description
of the error that caused the application to return a 500 Internal Server Error to the user.
It is enough to look at this log record to get a pretty accurate picture of everything that matters for this
request.
The Error trait is, first and foremost, a way to semantically mark our type as being an error. It helps a reader
of our codebase to immediately spot its purpose.
It is also a way for the Rust community to standardise on the minimum requirements for a good error:
• it should provide different representations (Debug and Display), tuned to different audiences;
• it should be possible to look at the underlying cause of the error, if any (source).
338 CHAPTER 8. ERROR HANDLING
8.2.2.2 Error::source
source is useful when writing code that needs to handle a variety of errors: it provides a structured way
to navigate the error chain without having to know anything about the specific error type you are working
with.
If we look at our log record, the causal relationship between StoreTokenError and sqlx::Error is somewhat
implicit - we infer one is the cause of the other because it is a part of it.
5
Check out the relevant chapter in the Rust book for an in-depth introduction to trait objects.
6
The Error trait provides a downcast_ref which can be used to obtain a concrete type back from dyn Error, assuming you
know what type to downcast to. There are legitimate usecases for downcasting, but if you find yourself reaching for it too often it
might be a sign that something is not quite right in your design/error handling strategy.
8.2. ERROR REPORTING FOR OPERATORS 339
...
INFO: [HTTP REQUEST - END]
exception.details= StoreTokenError(
Database(
PgDatabaseError {
severity: Error,
code: "42703",
message:
"column 'subscription_token' of relation
'subscription_tokens' does not exist",
...
}
)
)
exception.message=
"A database failure was encountered while
trying to store a subscription token.",
target=tracing_actix_web::root_span_builder,
http.status_code=500
Caused by:
error returned from database: column 'subscription_token'
of relation 'subscription_tokens' does not exist"
exception.message=
340 CHAPTER 8. ERROR HANDLING
exception.details is easier to read and still conveys all the relevant information we had there before.
Using source we can write a function that provides a similar representation for any type that implements
Error:
//! src/routes/subscriptions.rs
// [...]
fn error_chain_fmt(
e: &impl std::error::Error,
f: &mut std::fmt::Formatter<'_>,
) -> std::fmt::Result {
writeln!(f, "{}\n", e)?;
let mut current = e.source();
while let Some(cause) = current {
writeln!(f, "Caused by:\n\t{}", cause)?;
current = cause.source();
}
Ok(())
}
It iterates over the whole chain of errors7 that led to the failure we are trying to print.
We can then change our implementation of Debug for StoreTokenError to use it:
//! src/routes/subscriptions.rs
// [...]
The result is identical - and we can reuse it when working with other errors if we want a similar Debug rep-
resentation.
7
There is a chain method on Error that fulfills the same purpose - it has not been stabilised yet.
8.3. ERRORS FOR CONTROL FLOW 341
// Nuke it!
impl ResponseError for StoreTokenError {}
To enforce a proper separation of concerns we need to introduce another error type, SubscribeError. We
will use it as failure variant for subscribe and it will own the HTTP-related logic (ResponseError’s imple-
mentation).
//! src/routes/subscriptions.rs
// [...]
#[derive(Debug)]
struct SubscribeError {}
If you run cargo check you will see an avalanche of '?' couldn't convert the error to
'SubscribeError' - we need to implement conversions from the error types returned by our functions and
SubscribeError.
#[derive(Debug)]
pub enum SubscribeError {
ValidationError(String),
DatabaseError(sqlx::Error),
StoreTokenError(StoreTokenError),
SendEmailError(reqwest::Error),
}
We can then leverage the ? operator in our handler by providing a From implementation for each of wrapped
error types:
//! src/routes/subscriptions.rs
// [...]
We can now clean up our request handler by removing all those match / if fallible_function().is_err()
lines:
//! src/routes/subscriptions.rs
// [...]
We are still using the default implementation of ResponseError - it always returns 500.
This is where enums shine: we can use a match statement for control flow - we behave differently depending
on the failure scenario we are dealing with.
//! src/routes/subscriptions.rs
use actix_web::http::StatusCode;
// [...]
...
INFO: [HTTP REQUEST - END]
exception.details="StoreTokenError(
A database failure was encountered while trying to
store a subscription token.
Caused by:
error returned from database: column 'subscription_token'
of relation 'subscription_tokens' does not exist)"
exception.message="Failed to create a new subscriber.",
target=tracing_actix_web::root_span_builder,
http.status_code=500
We are still getting a great representation for the underlying StoreTokenError in exception.details, but it
shows that we are now using the derived Debug implementation for SubscribeError. No loss of information
though.
The same cannot be said for exception.message - no matter the failure mode, we always get Failed to
create a new subscriber. Not very useful.
Debug is easily sorted: we implemented the Error trait for SubscribeError, including source, and we can
use again the helper function we wrote earlier for StoreTokenError.
We have a problem when it comes to Display - the same DatabaseError variant is used for errors en-
countered when:
• acquiring a new Postgres connection from the pool;
• inserting a subscriber in the subscribers table;
• committing the SQL transaction.
When implementing Display for SubscribeError we have no way to distinguish which of those three cases
we are dealing with - the underlying error type is not enough.
Let’s disambiguate by using a different enum variant for each operation:
//! src/routes/subscriptions.rs
// [...]
346 CHAPTER 8. ERROR HANDLING
match self {
SubscribeError::ValidationError(_) => StatusCode::BAD_REQUEST,
SubscribeError::PoolError(_)
| SubscribeError::TransactionCommitError(_)
| SubscribeError::InsertSubscriberError(_)
| SubscribeError::StoreTokenError(_)
| SubscribeError::SendEmailError(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
//! src/routes/subscriptions.rs
// [..]
The type alone is not enough to distinguish which of the new variants should be used; we cannot implement
From for sqlx::Error.
We have to use map_err to perform the right conversion in each case.
//! src/routes/subscriptions.rs
// [..]
...
INFO: [HTTP REQUEST - END]
exception.details="Failed to store the confirmation token
for a new subscriber.
Caused by:
A database failure was encountered while trying to store
a subscription token.
Caused by:
error returned from database: column 'subscription_token'
of relation 'subscription_tokens' does not exist"
exception.message="Failed to store the confirmation token for a new subscriber.",
target=tracing_actix_web::root_span_builder,
http.status_code=500
[dependencies]
# [...]
thiserror = "1"
It provides a derive macro to generate most of the code we just wrote by hand.
Let’s see it in action:
//! src/routes/subscriptions.rs
// [...]
#[derive(thiserror::Error)]
pub enum SubscribeError {
#[error("{0}")]
ValidationError(String),
#[error("Failed to acquire a Postgres connection from the pool")]
PoolError(#[source] sqlx::Error),
8.3. ERRORS FOR CONTROL FLOW 349
Within the context of #[derive(thiserror::Error)] we get access to other attributes to achieve the beha-
viour we are looking for:
• #[error(/* */)] defines the Display representation of the enum variant it is applied to. E.g.
Display will return Failed to send a confirmation email. when invoked on an instance of
SubscribeError::SendEmailError. You can interpolate values in the final representation - e.g. the
{0} in #[error("{0}")] on top of ValidationError is referring to the wrapped String field, mim-
icking the syntax to access fields on tuple structs (i.e. self.0).
• #[from] automatically derives an implementation of From for the type it has been applied to into the
top-level error type (e.g. impl From<StoreTokenError> for SubscribeError {/* */}). The field
350 CHAPTER 8. ERROR HANDLING
annotated with #[from] is also used as error source, saving us from having to use two annotations on
the same field (e.g. #[source] #[from] reqwest::Error).
I want to call your attention on a small detail: we are not using either #[from] or #[source] for the
ValidationError variant. That is because String does not implement the Error trait, therefore it can-
not be returned in Error::source - the same limitation we encountered before when implementing
Error::source manually, which led us to return None in the ValidationError case.
They should be able to determine what response to return to a user (via ResponseError). That’s it.
The caller of subscribe does not understand the intricacies of the subscription flow: they don’t
know enough about the domain to behave differently for a SendEmailError compared to a
TransactionCommitError (by design!). subscribe should return an error type that speaks at the
right level of abstraction.
The ideal error type would look like this:
//! src/routes/subscriptions.rs
#[derive(thiserror::Error)]
pub enum SubscribeError {
#[error("{0}")]
ValidationError(String),
#[error(/* */)]
UnexpectedError(/* */),
}
ValidationError maps to a 400 Bad Request, UnexpectedError maps to an opaque 500 Internal Server
Error.
We bumped into a type that fulfills those requirements when looking at the Error trait from Rust’s standard
8.4. AVOID “BALL OF MUD” ERROR ENUMS 351
#[derive(thiserror::Error)]
pub enum SubscribeError {
#[error("{0}")]
ValidationError(String),
// Transparent delegates both `Display`'s and `source`'s implementation
// to the type wrapped by `UnexpectedError`.
#[error(transparent)]
UnexpectedError(#[from] Box<dyn std::error::Error>),
}
We just need to adapt subscribe to properly convert our errors before using the ? operator:
//! src/routes/subscriptions.rs
// [...]
8
We are wrapping dyn std::error::Error into a Box because the size of trait objects is not known at compile-time: trait objects
can be used to store different types which will most likely have a different layout in memory. To use Rust’s terminology, they are
unsized - they do not implement the Sized marker trait. A Box stores the trait object itself on the heap, while we store the pointer
to its heap location in SubscribeError::UnexpectedError - the pointer itself has a known size at compile-time - problem solved,
we are Sized again.
352 CHAPTER 8. ERROR HANDLING
.await
.map_err(|e| SubscribeError::UnexpectedError(Box::new(e)))?;
// [...]
store_token(/* */)
.await
.map_err(|e| SubscribeError::UnexpectedError(Box::new(e)))?;
transaction
.commit()
.await
.map_err(|e| SubscribeError::UnexpectedError(Box::new(e)))?;
send_confirmation_email(/* */)
.await
.map_err(|e| SubscribeError::UnexpectedError(Box::new(e)))?;
// [...]
}
Let’s change the test we have used so far to check the quality of our log messages: let’s trigger a failure in
insert_subscriber instead of store_token.
//! tests/api/subscriptions.rs
// [...]
#[tokio::test]
async fn subscribe_fails_if_there_is_a_fatal_database_error() {
// [...]
// Break `subscriptions` instead of `subscription_tokens`
sqlx::query!("ALTER TABLE subscriptions DROP COLUMN email;",)
.execute(&app.db_pool)
.await
.unwrap();
// [..]
}
The test passes, but we can see that our logs have regressed:
INFO: [HTTP REQUEST - END]
exception.details:
"error returned from database: column 'email' of
relation 'subscriptions' does not exist"
exception.message:
"error returned from database: column 'email' of
8.4. AVOID “BALL OF MUD” ERROR ENUMS 353
//! src/routes/subscriptions.rs
// [...]
#[derive(thiserror::Error)]
pub enum SubscribeError {
#[error("Failed to insert new subscriber in the database.")]
InsertSubscriberError(#[source] sqlx::Error),
// [...]
}
That is to be expected: we are forwarding the raw error now to Display (via #[error(transparent)]), we
are not attaching any additional context to it in subscribe.
We can fix it - let’s add a new String field to UnexpectedError to attach contextual information to the
opaque error we are storing:
//! src/routes/subscriptions.rs
// [...]
#[derive(thiserror::Error)]
pub enum SubscribeError {
#[error("{0}")]
ValidationError(String),
#[error("{1}")]
UnexpectedError(#[source] Box<dyn std::error::Error>, String),
}
We need to adjust our mapping code in subscribe accordingly - we will reuse the error descriptions we had
354 CHAPTER 8. ERROR HANDLING
// [..]
}
Caused by:
error returned from database: column 'email' of
relation 'subscriptions' does not exist"
exception.message="Failed to insert new subscriber in the database."
[dependencies]
# [...]
anyhow = "1"
anyhow::Error is a wrapper around a dynamic error type. anyhow::Error works a lot like Box<dyn
std::error::Error>, but with these differences:
• anyhow::Error requires that the error is Send, Sync, and 'static.
• anyhow::Error guarantees that a backtrace is available, even if the underlying error type does
not provide one.
• anyhow::Error is represented as a narrow pointer — exactly one word in size instead of two.
The additional constraints (Send, Sync and 'static) are not an issue for us.
We appreciate the more compact representation and the option to access a backtrace, if we were to be inter-
ested in it.
Let’s replace Box<dyn std::error::Error> with anyhow::Error in SubscribeError:
//! src/routes/subscriptions.rs
// [...]
9
It turns out that we are speaking of the same person that authored serde, syn, quote and many other foundational crates in
the Rust ecosystem - @dtolnay. Consider sponsoring their OSS work.
356 CHAPTER 8. ERROR HANDLING
#[derive(thiserror::Error)]
pub enum SubscribeError {
#[error("{0}")]
ValidationError(String),
#[error(transparent)]
UnexpectedError(#[from] anyhow::Error),
}
We got rid of the second String field as well in SubscribeError::UnexpectedError - it is no longer neces-
sary.
anyhow::Error provides the capability to enrich an error with additional context out of the box.
//! src/routes/subscriptions.rs
use anyhow::Context;
// [...]
send_confirmation_email(/* */)
.await
.context("Failed to send a confirmation email.")?;
// [...]
}
context is provided by the Context trait - anyhow implements it for Result10 , giving us access to a fluent
API to easily work with fallible functions of all kinds.
Do you expect the caller to behave differently based on the failure mode they encountered?
Use an error enumeration, empower them to match on the different variants. Bring in thiserror to write
less boilerplate.
Do you expect the caller to just give up when a failure occurs? Is their main concern reporting the error to
an operator or a user?
Use an opaque error, do not give the caller programmatic access to the error inner details. Use anyhow or
eyre if you find their API convenient.
The misunderstanding arises from the observation that most Rust libraries return an error enum instead of
Box<dyn std::error::Error> (e.g. sqlx::Error).
Library authors cannot (or do not want to) make assumptions on the intent of their users. They steer away
from being opinionated (to an extent) - enums give users more control, if they need it.
Freedom comes at a price - the interface is more complex, users need to sift through 10+ variants trying to
figure out which (if any) deserve special handling.
Reason carefully about your usecase and the assumptions you can afford to make in order to design the
most appropriate error type - sometimes Box<dyn std::error::Error> or anyhow::Error are the most
appropriate choice, even for libraries.
10
This is a common pattern in the Rust community, known as extension trait, to provide additional methods for types exposed
by the standard library (or other common crates in the ecosystem).
358 CHAPTER 8. ERROR HANDLING
We do not need to see the same information three times - we are emitting unnecessary log records which,
instead of helping, make it more confusing for operators to understand what is happening (are those logs
reporting the same error? Am I dealing with three different errors?).
As a rule of thumb,
If your function is propagating the error upstream (e.g. using the ? operator), it should not log the error. It
can, if it makes sense, add more context to it.
If the error is propagated all the way up to the request handler, delegate logging to a dedicated middleware -
tracing_actix_web::TracingLogger in our case.
The log record emitted by actix_web is going to be removed in the next release. Let’s ignore it for now.
//! src/routes/subscriptions.rs
// [...]
8.6 Summary
We used this chapter to learn error handling patterns “the hard way” - building an ugly but working proto-
type first, refining it later using popular crates from the ecosystem.
You should now have:
• a solid grasp on the different purposes fulfilled by errors in an application;
• the most appropriate tools to fulfill them.
Internalise the mental model we discussed (Location as columns, Purpose as rows):
Practice what you learned: we worked on the subscribe request handler, tackle confirm as an exercise to
verify your understanding of the concepts we covered. Improve the response returned to the user when
validation of form data fails.
You can look at the code in the GitHub repository as a reference implementation.
Some of the themes we discussed in this chapter (e.g. layering and abstraction boundaries) will make an-
other appearance when talking about the overall layout and structure of our application. Something to look
forward to!
Chapter 9
Our project is not yet a viable newsletter service: it cannot send out a new episode!
We will use this chapter to bootstrap newsletter delivery using a naive implementation.
It will be an opportunity to deepen our understanding of techniques we touched upon in previous chapters
while building the foundation for tackling more advanced topics (e.g. authentication/authorization, fault
tolerance).
It looks simple, at least on the surface. The devil, as always, is in the details.
For example, in Chapter 7 we refined our domain model of a subscriber - we now have confirmed and
unconfirmed subscribers.
Which ones should receive our newsletter issues?
That user story, as it stands, cannot help us - it was written before we started to make the distinction!
Make a habit of revisiting user stories throughout the lifecycle of a project.
When you spend time working on a problem you end up deepening your understanding of its domain. You
often acquire a more precise language that can be used to refine earlier attempts of describing the desired
functionality.
For this specific case: we only want newsletter issues to be sent to confirmed subscribers. Let’s amend the
user story accordingly:
361
362 CHAPTER 9. NAIVE NEWSLETTER DELIVERY
In Chapter 7 we selected Postmark as our email delivery service. If we are not calling Postmark, we are not
sending an email out.
We can build on this fact to orchestrate a scenario that allows us to verify our business rule: if all subscribers
are unconfirmed, no request is fired to Postmark when we publish a newsletter issue.
//! tests/api/newsletter.rs
use crate::helpers::{spawn_app, TestApp};
use wiremock::matchers::{any, method, path};
use wiremock::{Mock, ResponseTemplate};
#[tokio::test]
async fn newsletters_are_not_delivered_to_unconfirmed_subscribers() {
// Arrange
let app = spawn_app().await;
create_unconfirmed_subscriber(&app).await;
Mock::given(any())
.respond_with(ResponseTemplate::new(200))
// We assert that no request is fired at Postmark!
.expect(0)
.mount(&app.email_server)
.await;
// Act
// Assert
assert_eq!(response.status().as_u16(), 200);
// Mock verifies on Drop that we haven't sent the newsletter email
}
/// Use the public API of the application under test to create
/// an unconfirmed subscriber.
async fn create_unconfirmed_subscriber(app: &TestApp) {
let body = "name=le%20guin&email=ursula_le_guin%40gmail.com";
It fails, as expected:
thread 'newsletter::newsletters_are_not_delivered_to_unconfirmed_subscribers'
panicked at 'assertion failed: `(left == right)`
left: `404`,
right: `200`'
There is no handler in our API for POST /newsletters: actix-web returns a 404 Not Found instead of the
364 CHAPTER 9. NAIVE NEWSLETTER DELIVERY
We stay true to the black-box approach we described in Chapter 3: when possible, we drive the application
state by calling its public API.
That is what we are doing in create_unconfirmed_subscriber:
//! tests/api/newsletter.rs
// [...]
We use the API client we built as part of TestApp to make a POST call to the /subscriptions endpoint.
With mount, the behaviour we specify remains active as long as the underlying MockServer is up and running.
With mount_as_scoped, instead, we get back a guard object - a MockGuard.
MockGuard has a custom Drop implementation: when it goes out of scope, wiremock instructs the underlying
MockServer to stop honouring the specified mock behaviour. In other words, we stop returning 200 to POST
/email at the end of create_unconfirmed_subscriber.
The mock behaviour needed for our test helper stays local to the test helper itself.
One more thing happens when a MockGuard is dropped - we eagerly check that expectations on the scoped
mock are verified.
This creates a useful feedback loop to keep our test helpers clean and up-to-date.
We have already witnessed how black-box testing pushes us to write an API client for our own application
to keep our tests concise.
Over time, you build more and more helper functions to drive the application state - just like we just did
with create_unconfirmed_subscriber. These helpers rely on mocks but, as the application evolves, some
of those mocks end up no longer being necessary - a call gets removed, you stop using a certain provider, etc.
Eager evaluation of expectations for scoped mocks helps us to keep helper code in check and proactively
clean up where possible.
//! src/routes/mod.rs
// [...]
// New module!
mod newsletters;
//! src/routes/newsletters.rs
use actix_web::HttpResponse;
// Dummy implementation
pub async fn publish_newsletter() -> HttpResponse {
HttpResponse::Ok().finish()
366 CHAPTER 9. NAIVE NEWSLETTER DELIVERY
//! src/startup.rs
// [...]
use crate::routes::{confirm, health_check, publish_newsletter, subscribe};
.await
.error_for_status()
.unwrap();
//! tests/api/newsletter.rs
// [...]
#[tokio::test]
async fn newsletters_are_delivered_to_confirmed_subscribers() {
// Arrange
let app = spawn_app().await;
create_confirmed_subscriber(&app).await;
Mock::given(path("/email"))
.and(method("POST"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&app.email_server)
368 CHAPTER 9. NAIVE NEWSLETTER DELIVERY
.await;
// Act
let newsletter_request_body = serde_json::json!({
"title": "Newsletter title",
"content": {
"text": "Newsletter body as plain text",
"html": "<p>Newsletter body as HTML</p>",
}
});
let response = reqwest::Client::new()
.post(&format!("{}/newsletters", &app.address))
.json(&newsletter_request_body)
.send()
.await
.expect("Failed to execute request.");
// Assert
assert_eq!(response.status().as_u16(), 200);
// Mock verifies on Drop that we have sent the newsletter email
}
It fails, as it should:
thread 'newsletter::newsletters_are_delivered_to_confirmed_subscribers' panicked at
Verifications failed:
- Mock #1.
Expected range of matching incoming requests: == 1
Number of matched incoming requests: 0
• Retrieve the newsletter issue details from the body of the incoming API call;
• Fetch the list of all confirmed subscribers from the database;
• Iterate through the whole list:
– Get the subscriber email;
– Send an email out via Postmark.
Let’s do it!
9.5. BODY SCHEMA 369
We can encode our requirements using structs that derive serde::Deserialize, just like we did in POST
/subscriptions with FormData.
//! src/routes/newsletters.rs
// [...]
#[derive(serde::Deserialize)]
pub struct BodyData {
title: String,
content: Content
}
#[derive(serde::Deserialize)]
pub struct Content {
html: String,
text: String
}
serde does not have any issue with our nested layout given that all field types in BodyData implement
serde::Deserialize. We can then use an actix-web extractor to parse BodyData out of the incoming re-
quest body. There is just one question to answer: what serialization format are we using?
For POST /subscriptions, given that we were dealing with HTML forms, we used
application/x-www-form-urlencoded as Content-Type.
For POST /newsletters we are not tied to a form embedded in a web page: we will use JSON, a common
choice when building REST APIs.
The corresponding extractor is actix_web::web::Json:
//! src/routes/newsletters.rs
// [...]
use actix_web::web;
Trust but verify: let’s add a new test case that throws invalid data at our POST /newsletters endpoint.
//! tests/api/newsletter.rs
// [...]
#[tokio::test]
async fn newsletters_returns_400_for_invalid_data() {
// Arrange
let app = spawn_app().await;
let test_cases = vec![
(
serde_json::json!({
"content": {
"text": "Newsletter body as plain text",
"html": "<p>Newsletter body as HTML</p>",
}
}),
"missing title",
),
(
serde_json::json!({"title": "Newsletter!"}),
"missing content",
),
];
// Assert
assert_eq!(
400,
response.status().as_u16(),
"The API did not fail with 400 Bad Request when the payload was {}.",
error_message
);
}
}
9.5. BODY SCHEMA 371
The new test passes - you can add a few more cases if you want to.
Let’s seize the occasion to refactor a bit and remove some code duplication - we can extract the logic to fire a
request to POST /newsletters into a shared helper method on TestApp, as we did for POST /subscriptions:
//! tests/api/helpers.rs
// [...]
impl TestApp {
// [...]
pub async fn post_newsletters(
&self,
body: serde_json::Value
) -> reqwest::Response {
reqwest::Client::new()
.post(&format!("{}/newsletters", &self.address))
.json(&body)
.send()
.await
.expect("Failed to execute request.")
}
}
//! tests/api/newsletter.rs
// [...]
#[tokio::test]
async fn newsletters_are_not_delivered_to_unconfirmed_subscribers() {
// [...]
let response = app.post_newsletters(newsletter_request_body).await;
// [...]
}
#[tokio::test]
async fn newsletters_are_delivered_to_confirmed_subscribers() {
// [...]
let response = app.post_newsletters(newsletter_request_body).await;
// [...]
}
#[tokio::test]
async fn newsletters_returns_400_for_invalid_data() {
// [...]
for (invalid_body, error_message) in test_cases {
let response = app.post_newsletters(invalid_body).await;
// [...]
372 CHAPTER 9. NAIVE NEWSLETTER DELIVERY
}
}
struct ConfirmedSubscriber {
email: String,
}
//! src/routes/newsletters.rs
// [...]
SQL queries may fail and so does get_confirmed_subscribers - we need to change the return type of
publish_newsletter.
We need to return a Result with an appropriate error type, just like we did in the last chapter:
//! src/routes/newsletters.rs
// [...]
use actix_web::ResponseError;
use sqlx::PgPool;
use crate::routes::error_chain_fmt;
use actix_web::http::StatusCode;
#[derive(thiserror::Error)]
pub enum PublishError {
#[error(transparent)]
UnexpectedError(#[from] anyhow::Error),
}
}
}
Using what we learned in Chapter 8 it doesn’t take that much to roll out a new error type!
Let me remark that we are future-proofing our code a bit: we modelled PublishError as an enumeration,
but we only have one variant at the moment. A struct (or actix_web::error::InternalError) would have
been more than enough for the time being.
cargo check should succeed now.
.send_email(
subscriber.email,
&body.title,
&body.content.html,
&body.content.text,
)
.await?;
}
Ok(HttpResponse::Ok().finish())
}
It almost works:
error[E0308]: mismatched types
--> src/routes/newsletters.rs
|
48 | subscriber.email,
| ^^^^^^^^^^^^^^^^
| expected struct `SubscriberEmail`,
| found struct `std::string::String`
We are not performing any validation on the data we retrieve from the database -
ConfirmedSubscriber::email is of type String.
EmailClient::send_email, instead, expects a validated email address - a SubscriberEmail instance.
We can try the naive solution first - change ConfirmedSubscriber::email to be of type SubscriberEmail.
//! src/routes/newsletters.rs
// [...]
use crate::domain::SubscriberEmail;
struct ConfirmedSubscriber {
email: SubscriberEmail,
}
9.8. VALIDATION OF STORED DATA 377
sqlx doesn’t like it - it does not know how to convert a TEXT column into SubscriberEmail.
We could scan sqlx’s documentation for a way to implement support for custom type - a lot of trouble for
a minor upside.
We can follow a similar approach to the one we deployed for our POST /subscriptions endpoint - we use
two structs:
• one encodes the data layout we expect on the wire (FormData);
• the other one is built by parsing the raw representation, using our domain types (NewSubscriber).
For our query, it looks like this:
//! src/routes/newsletters.rs
// [...]
378 CHAPTER 9. NAIVE NEWSLETTER DELIVERY
struct ConfirmedSubscriber {
email: SubscriberEmail,
}
The emails of all new subscribers go through the validation logic in SubscriberEmail::parse - it was a big
focus topic for us in Chapter 6.
You might argue, then, that all the emails stored in our database are necessarily valid - there is no need to
account validation failures here. It is safe to just unwrap them all, knowing it will never panic.
9.8. VALIDATION OF STORED DATA 379
This reasoning is sound assuming our software never changes. But we are optimising for high deployment
frequency!
Data stored in our Postgres instance creates a temporal coupling between old and new versions of our
application.
The emails we are retrieving from our database were marked as valid by a previous version of our application.
The current version might disagree.
We might discover, for example, that our email validation logic is too lenient - some invalid emails are slip-
ping through the cracks, leading to issues when attempting to deliver newsletters. We implement a stricter
validation routine, deploy the patched version and, suddenly, email delivery does not work at all!
get_confirmed_subscribers panics when processing stored emails that were previously considered valid,
but no longer are.
What should we do, then?
Should we skip validation entirely when retrieving data from the database?
There is no one-size-fits-all answer.
You need to evaluate the issue on a case by case basis given the requirements of your domain.
Sometimes it is unacceptable to process invalid records - the routine should fail and an operator must inter-
vene to rectify the corrupt records.
Sometimes we need to process all historical records (e.g. analytics) and we should make minimal assumptions
about the data - String is our safest bet.
In our case, we can meet half-way: we can skip invalid emails when fetching the list of recipients for our next
newsletter issue. We will emit a warning for every invalid address we find, allowing an operator to identify
the issue and correct the stored records at a certain point in the future.
//! src/routes/newsletters.rs
// [...]
async fn get_confirmed_subscribers(
pool: &PgPool,
) -> Result<Vec<ConfirmedSubscriber>, anyhow::Error> {
// [...]
}
})
.collect();
Ok(confirmed_subscribers)
}
filter_map is a handy combinator - it returns a new iterator containing only the items for which our closure
returned a Some variant.
async fn get_confirmed_subscribers(
pool: &PgPool,
// We are returning a `Vec` of `Result`s in the happy case.
// This allows the caller to bubble up errors due to network issues or other
// transient failures using the `?` operator, while the compiler
// forces them to handle the subtler mapping error.
// See http://sled.rs/errors.html for a deep-dive about this technique.
) -> Result<Vec<Result<ConfirmedSubscriber, anyhow::Error>>, anyhow::Error> {
// [...]
}
}
}
Ok(HttpResponse::Ok().finish())
}
This is caused by our type change for email in ConfirmedSubscriber, from String to SubscriberEmail.
Let’s implement Display for our new type:
//! src/domain/subscriber_email.rs
// [...]
Progress! Different compiler error, this time from the borrow checker!
error[E0382]: borrow of partially moved value: `subscriber`
--> src/routes/newsletters.rs
|
52 | subscriber.email,
| ---------------- value partially moved here
...
58 | .with_context(|| {
| ^^ value borrowed here after partial move
59 | format!("Failed to send newsletter issue to {}", subscriber.email)
| ----------
borrow occurs due to use in closure
9.8. VALIDATION OF STORED DATA 383
We could just slap a .clone() on the first usage and call it a day.
But let’s try to be sophisticated: do we really need to take ownership of SubscriberEmail in
EmailClient::send_email?
//! src/email_client.rs
// [...]
We just need to be able to call as_ref on it - a &SubscriberEmail would work just fine.
Let’s change the signature accordingly:
//! src/email_client.rs
// [...]
There are a few calling sites that need to be updated - the compiler is gentle enough to point them out. I’ll
leave the fixes to you, the reader, as an exercise.
The test suite should pass when you are done.
async fn get_confirmed_subscribers(
pool: &PgPool,
) -> Result<Vec<Result<ConfirmedSubscriber, anyhow::Error>>, anyhow::Error> {
struct Row {
email: String,
}
//! src/routes/newsletters.rs
// [...]
Not so fast.
We said it at the very beginning - the approach we took is the simplest possible to get something up and
running.
Is it good enough, though?
1. Security
Our POST /newsletters endpoint is unprotected - anyone can fire a request to it and broadcast to
our entire audience, unchecked.
2. You Only Get One Shot
As soon you hit POST /newsletters, your content goes out to your entire mailing list. No chance to
edit or review it in draft mode before giving the green light for publishing.
3. Performance
We are sending emails out one at a time.
We wait for the current one to be dispatched successfully before moving on to the next in line.
This is not a massive issue if you have 10 or 20 subscribers, but it becomes noticeable shortly after-
wards: latency is going to be horrible for newsletters with a sizeable audience.
4. Fault Tolerance
If we fail to dispatch one email we bubble up the error using ? and return a 500 Internal Server
Error to the caller.
The remaining emails are never sent, nor we retry to dispatch the failed one.
5. Retry Safety
Many things can go wrong when communicating over the network. What should a consumer of our
386 CHAPTER 9. NAIVE NEWSLETTER DELIVERY
API do if they experience a timeout or a 500 Internal Server Error when calling our service?
They cannot retry - they risk sending the newsletter issue twice to the entire mailing list.
Number 2. and 3. are annoying, but we could live with them for a while.
Number 4. and 5. are fairly serious limitations, with a visible impact on our audience.
Number 1. is simply non-negotiable: we must protect the endpoint before releasing our API.
9.10 Summary
We built a prototype of our newsletter delivery logic: it satisfies our functional requirements, but it is not
yet ready for prime time.
The shortcomings of our MVP will become the focus of the next chapters, in priority order: we will tackle
authentication/authorization first before moving on to fault tolerance.
Chapter 10
This chapter, like others in the book, chooses to “do it wrong” first for teaching purposes. Make sure
to read until the end if you don’t want to pick up bad security habits!
10.1 Authentication
We need a way to verify who is calling POST /newsletters.
Only a handful of people, the ones in charge of the content, should be able to send emails out to the entire
mailing list.
We need to find a way to verify the identity of API callers - we must authenticate them.
How?
By asking for something they are uniquely positioned to provide.
There are various approaches, but they all boil down to three categories:
1. Something they know (e.g. passwords, PINs, security questions);
2. Something they have (e.g. a smartphone, using an authenticator app);
3. Something they are (e.g. fingerprints, Apple’s Face ID).
Each approach has its weaknesses.
387
388 CHAPTER 10. SECURING OUR API
10.1.1 Drawbacks
10.1.1.1 Something They Know
Passwords must be long - short ones are vulnerable to brute-force attacks.
Passwords must be unique - publicly available information (e.g. date of birth, names of family members, etc.)
should not give an attacker any chance to “guess” a password.
Passwords should not be reused across multiple services - if any of them gets compromised you risk granting
access to all the other services sharing the same password.
On average, a person has 100 or more online accounts - they cannot be asked to remember hundreds of long
unique passwords by heart.
Password managers help, but they are not mainstream yet and the user experience is often sub-optimal.
According to the specification, we need to partition our API into protection spaces or realms - resources
within the same realm are protected using the same authentication scheme and set of credentials.
We only have a single endpoint to protect - POST /newsletters. We will therefore have a single realm, named
publish.
The API must reject all requests missing the header or using invalid credentials - the response must use the
401 Unauthorized status code and include a special header, WWW-Authenticate, containing a challenge.
The challenge is a string explaining to the API caller what type of authentication scheme we expect to see for
the relevant realm.
In our case, using basic authentication, it should be:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="publish"
#[tokio::test]
async fn requests_missing_authorization_are_rejected() {
// Arrange
let app = spawn_app().await;
1
base64-encoding ensures that all the characters in the output are ASCII, but it does not provide any kind of protection: decod-
ing requires no secrets. In other words, encoding is not encryption!
390 CHAPTER 10. SECURING OUR API
.await
.expect("Failed to execute request.");
// Assert
assert_eq!(401, response.status().as_u16());
assert_eq!(r#"Basic realm="publish""#, response.headers()["WWW-Authenticate"]);
}
struct Credentials {
username: String,
password: Secret<String>,
}
To extract the credentials we will need to deal with the base64 encoding.
Let’s add the base64 crate as a dependency:
10.2. PASSWORD-BASED AUTHENTICATION 391
[dependencies]
# [...]
base64 = "0.21"
//! src/routes/newsletters.rs
use base64::Engine;
// [...]
Ok(Credentials {
username,
password: Secret::new(password)
392 CHAPTER 10. SECURING OUR API
})
}
Take a moment to go through the code, line by line, and fully understand what is happening. Many opera-
tions that could go wrong!
Having the RFC open, side to side with the book, helps!
#[derive(thiserror::Error)]
pub enum PublishError {
// New error variant!
#[error("Authentication failed")]
AuthError(#[source] anyhow::Error),
#[error(transparent)]
UnexpectedError(#[from] anyhow::Error),
}
Our status code assertion is now happy, the header one not yet:
thread 'newsletter::requests_missing_authorization_are_rejected' panicked at
'no entry found for key "WWW-Authenticate"'
10.2. PASSWORD-BASED AUTHENTICATION 393
So far it has been enough to specify which status code to return for each error - now we need something
more, a header.
We need to change our focus from ResponseError::status_code to ResponseError::error_response:
//! src/routes/newsletters.rs
// [...]
use actix_web::http::{StatusCode, header};
use actix_web::http::header::{HeaderMap, HeaderValue};
thread 'newsletter::newsletters_are_not_delivered_to_unconfirmed_subscribers'
panicked at 'assertion failed: `(left == right)`
left: `401`,
right: `200`'
394 CHAPTER 10. SECURING OUR API
thread 'newsletter::newsletters_are_delivered_to_confirmed_subscribers'
panicked at 'assertion failed: `(left == right)`
left: `401`,
right: `200`'
POST /newsletters is now rejecting all unauthenticated requests, including the ones we were making in
our happy-path black-box tests.
We can stop the bleeding by providing a random combination of username and password:
//! tests/api/helpers.rs
// [...]
impl TestApp {
pub async fn post_newsletters(
&self,
body: serde_json::Value
) -> reqwest::Response {
reqwest::Client::new()
.post(&format!("{}/newsletters", &self.address))
// Random credentials!
// `reqwest` does all the encoding/formatting heavy-lifting for us.
.basic_auth(Uuid::new_v4().to_string(), Some(Uuid::new_v4().to_string()))
.json(&body)
.send()
.await
.expect("Failed to execute request.")
}
// [...]
}
-- migrations/20210815112026_create_users_table.sql
CREATE TABLE users(
user_id uuid PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL
);
We can then update our handler to query it every time we perform authentication:
//! src/routes/newsletters.rs
use secrecy::ExposeSecret;
// [...]
async fn validate_credentials(
credentials: Credentials,
pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
let user_id: Option<_> = sqlx::query!(
r#"
SELECT user_id
FROM users
WHERE username = $1 AND password = $2
"#,
credentials.username,
credentials.password.expose_secret()
)
.fetch_optional(pool)
.await
.context("Failed to perform a query to validate auth credentials.")
.map_err(PublishError::UnexpectedError)?;
user_id
.map(|row| row.user_id)
.ok_or_else(|| anyhow::anyhow!("Invalid username or password."))
.map_err(PublishError::AuthError)
}
It would be a good idea to record who is calling POST /newsletters - let’s add a tracing span around our
handler:
//! src/routes/newsletters.rs
// [...]
#[tracing::instrument(
name = "Publish a newsletter issue",
skip(body, pool, email_client, request),
fields(username=tracing::field::Empty, user_id=tracing::field::Empty)
)]
pub async fn publish_newsletter(/* */) -> Result<HttpResponse, PublishError> {
let credentials = basic_authentication(request.headers())
.map_err(PublishError::AuthError)?;
tracing::Span::current().record(
"username",
&tracing::field::display(&credentials.username)
);
let user_id = validate_credentials(credentials, &pool).await?;
tracing::Span::current().record("user_id", &tracing::field::display(&user_id));
// [...]
}
We now need to update our happy-path tests to specify a username-password pair that is accepted by
validate_credentials.
We will generate a test user for every instance of our test application. We have not yet implemented a sign-up
flow for newsletter editors, therefore we cannot go for a fully black-box approach - for the time being we will
inject the test user details directly into the database:
//! tests/api/helpers.rs
// [...]
Uuid::new_v4(),
Uuid::new_v4().to_string(),
Uuid::new_v4().to_string(),
)
.execute(pool)
.await
.expect("Failed to create test users.");
}
TestApp will provide a helper method to retrieve its username and password
//! tests/api/helpers.rs
// [...]
impl TestApp {
// [...]
which we will then be calling from our post_newsletters method, instead of using random credentials:
//! tests/api/helpers.rs
// [...]
impl TestApp {
// [...]
.await
.expect("Failed to execute request.")
}
}
If we had such a function f, we could avoid storing the raw password altogether: when a user signs up, we
compute f(password) and store it in our database. password is discarded.
When the same user tries to sign in, we compute f(psw_candidate) and check that it matches the
f(password) value we stored during sign-up. The raw password is never persisted.
Even that might not be enough. It is often sufficient for an attacker to be able to recover some properties of
the input (e.g. length) from the output to mount, for example, a targeted brute-force attack.
We need something stronger - there should be no relationship between how similar two inputs x and y are
and how similar the corresponding outputs f(x) and f(y) are.
We want a cryptographic hash function.
Hash functions map strings from the input space to fixed-length outputs.
The adjective cryptographic refers to the uniformity property we were just discussing, also known as ava-
lanche effect: a tiny difference in inputs leads to outputs so different to the point of looking uncorrelated.
There is a caveat: hash functions are not injective2 , there is a tiny risk of collisions - if f(x) == f(y) there is
a high probability (not 100%!) that x == y.
[dependencies]
# [...]
sha3 = "0.9"
-- migrations/20210815112028_rename_password_column.sql
ALTER TABLE users RENAME password TO password_hash;
2
Assuming that the input space is finite (i.e. password length is capped), it is theoretically possible to find a perfect hash function
- f(x) == f(y) implies x == y.
400 CHAPTER 10. SECURING OUR API
sqlx::query! spotted that one of our queries is using a column that no longer exists in the current schema.
Compile-time verification of SQL queries is quite neat, isn’t it?
async fn validate_credentials(
credentials: Credentials,
pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
let user_id: Option<_> = sqlx::query!(
r#"
SELECT user_id
FROM users
WHERE username = $1 AND password = $2
"#,
credentials.username,
credentials.password.expose_secret()
)
// [...]
}
Digest::digest returns a fixed-length array of bytes, while our password_hash column is of type TEXT, a
string.
We could change the schema of the users table to store password_hash as binary. Alternatively, we can
encode the bytes returned by Digest::digest in a string using the hexadecimal format.
The application code should compile now. The test suite, instead, requires a bit more work.
402 CHAPTER 10. SECURING OUR API
The test_user helper method was recovering a set of valid credentials by querying the users table - this is
no longer viable now that we are storing hashes instead of raw passwords!
//! tests/api/helpers.rs
//! [...]
impl TestApp {
// [...]
We need TestApp to store the randomly generated password in order for us to access it in our helper methods.
Let’s start by creating a new helper struct, TestUser:
//! tests/api/helpers.rs
//! [...]
use sha3::Digest;
10.2. PASSWORD-BASED AUTHENTICATION 403
impl TestUser {
pub fn generate() -> Self {
Self {
user_id: Uuid::new_v4(),
username: Uuid::new_v4().to_string(),
password: Uuid::new_v4().to_string()
}
}
//! tests/api/helpers.rs
//! [...]
// [...]
let test_app = TestApp {
// [...]
test_user: TestUser::generate()
};
test_app.test_user.store(&test_app.db_pool).await;
test_app
}
impl TestApp {
// [..]
pub async fn post_newsletters(
&self,
body: serde_json::Value
) -> reqwest::Response {
reqwest::Client::new()
.post(&format!("{}/newsletters", &self.address))
.basic_auth(&self.test_user.username, Some(&self.test_user.password))
// [...]
}
}
Let’s imagine that the attack wants to crack a specific password hash in our database.
The attacker does not even need to retrieve the original password. To authenticate successfully they just
need to find an input string s whose SHA3-256 hash matches the password they are trying to crack - in
other words, a collision.
This is known as a preimage attack.
The math is a bit tricky, but a brute-force attack has an exponential time complexity - 2^n, where n is the
hash length in bits.
If n > 128, it is considered unfeasible to compute.
Unless a vulnerability is found in SHA-3, we do not need to worry about preimage attacks against SHA3-
256.
10.2. PASSWORD-BASED AUTHENTICATION 405
Assuming a hash rate of ~10^9 per second, it would take us ~10^15 seconds to hash all password candidates.
The approximate age of the universe is 4 * 10^17 seconds.
Even if we were to parallelise our search using a million GPUs, it would still take ~10^9 seconds - roughly 30
years4 .
Furthermore, most passwords are far from being random, even when reused - common words, full names,
dates, names of popular sport teams, etc.
An attacker could easily design a simple algorithm to generate thousands of plausible passwords - but they
do not have to. They can look at a password dataset from one of the many security breaches from the last
decade to find the most common passwords in the wild.
In a couple of minutes they can pre-compute the SHA3-256 hash of the most commonly used 10 million
passwords. Then they start scanning our database looking for a match.
All the cryptographic hash functions we mentioned so far are designed to be fast.
Fast enough to enable anybody to pull off a dictionary attack without having to use specialised hardware.
3
When looking into brute-force attacks you will often see mentions of rainbow tables - an efficient data structure to pre-compute
and lookup hashes.
4
This back-of-the-envelope calculation should make it clear that using a randomly-generated password provides you, as a user,
with a significant level of protection against brute-force attacks even if the server is using fast hashing algorithms for password storage.
Consistent usage of a password manager is indeed one of the easiest ways to boost your security profile.
406 CHAPTER 10. SECURING OUR API
We need something much slower, but with the same set of mathematical properties of cryptographic hash
functions.
10.2.3.6 Argon2
The Open Web Application Security Project (OWASP)5 provides useful guidance on safe password storage
- with a whole section on how to choose the correct hashing algorithm:
All these options - Argon2, bcrypt, scrypt, PBKDF2 - are designed to be computationally demanding.
They also expose configuration parameters (e.g. work factor for bcrypt) to further slow down hash computa-
tion: application developers can tune a few knobs to keep up with hardware speed-ups - no need to migrate
to newer algorithms every couple of years.
Let’s replace SHA-3 with Argon2id, as recommended by OWASP.
The Rust Crypto organization got us covered once again - they provide a pure-Rust implementation,
argon2.
[dependencies]
# [...]
argon2 = { version = "0.4", features = ["std"] }
5
OWASP is, generally speaking, a treasure trove of great educational material about security for web applications. You
should get as familiar as possible with OWASP’s material, especially if you do not have an application security specialist in your
team/organization to support you. On top of the cheatsheet we linked, make sure to browse their Application Security Verification
Standard.
10.2. PASSWORD-BASED AUTHENTICATION 407
impl<'key> Argon2<'key> {
/// Create a new Argon2 context.
pub fn new(algorithm: Algorithm, version: Version, params: Params) -> Self {
// [...]
}
// [...]
}
Algorithm is an enum: it lets us select which variant of Argon2 we want to use - Argon2d, Argon2i, Ar-
gon2id. To comply with OWASP’s recommendation we will go for Algorithm::Argon2id.
Version fulfills a similar purpose - we will go for the most recent, Version::V0x13.
//! argon2/params.rs
// [...]
output_len, instead, determines the length of the returned hash - if omitted, it will default to 32 bytes. That
is equal to 256 bits, the same hash length we were getting via SHA3-256.
We know enough, at this point, to build one:
//! src/routes/newsletters.rs
use argon2::{Algorithm, Argon2, Version, Params};
// [...]
async fn validate_credentials(
credentials: Credentials,
pool: &PgPool,
408 CHAPTER 10. SECURING OUR API
//! password_hash/traits.rs
It is a re-export from the password-hash crate, a unified interface to work with password hashes backed by
a variety of algorithm (currently Argon2, PBKDF2 and scrypt).
10.2.3.7 Salting
Argon2 is a lot slower than SHA-3, but this is not enough to make a dictionary attack unfeasible. It takes
longer to hash the most common 10 million passwords, but not prohibitively long.
What if, though, the attacker had to rehash the whole dictionary for every user in our database?
It becomes a lot more challenging!
That is what salting accomplishes. For each user, we generate a unique random string - the salt.
The salt is prepended to the user password before generating the hash. PasswordHasher::hash_password
takes care of the prepending business for us.
10.2. PASSWORD-BASED AUTHENTICATION 409
-- migrations/20210815112111_add_salt_to_users.sql
ALTER TABLE users ADD COLUMN salt TEXT NOT NULL;
We can no longer compute the hash before querying the users table - we need to retrieve the salt first.
Let’s shuffle operations around:
//! src/routes/newsletters.rs
// [...]
use argon2::PasswordHasher;
async fn validate_credentials(
credentials: Credentials,
pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
let hasher = argon2::Argon2::new(/* */);
let row: Option<_> = sqlx::query!(
r#"
SELECT user_id, password_hash, salt
FROM users
WHERE username = $1
"#,
credentials.username,
)
.fetch_optional(pool)
.await
.context("Failed to perform a query to retrieve stored credentials.")
.map_err(PublishError::UnexpectedError)?;
6
This is why OWASP recommends an additional layer of defence - peppering. All hashes stored in the database are encrypted
using a shared secret, only known to the application. Encryption, though, brings its own set of challenges: where are we going to
store the key? How do we rotate it? The answer usually involves a Hardware Security Module (HSM) or a secret vault, such as AWS
CloudHSM, AWS KMS or Hashicorp Vault. A thorough overview of key management is beyond the scope of this book.
410 CHAPTER 10. SECURING OUR API
"Unknown username."
)));
}
};
if password_hash != expected_password_hash {
Err(PublishError::AuthError(anyhow::anyhow!(
"Invalid password."
)))
} else {
Ok(user_id)
}
}
Output provides other methods to obtain a string representation - e.g. Output::b64_encode. It would work,
as long as we are happy to change the assumed encoding for hashes stored in our database.
Given that a change is necessary, we can shoot for something better than base64-encoding.
If we store a base64-encoded representation of the hash, we are making a strong implicit assumption: all
values stored in the password_hash column have been computed using the same load parameters.
As we discussed a few sections ago, hardware capabilities evolve over time: application developers are expec-
ted to keep up by increasing the computational cost of hashing using higher load parameters.
What happens when you have to migrate your stored passwords to a newer hashing configuration?
To keep authenticating old users we must store, next to each hash, the exact set of load parameters used to
compute it.
This allows for a seamless migration between two different load configurations: when an old user authen-
ticates, we verify password validity using the stored load parameters; we then recompute the password hash
using the new load parameters and update the stored information accordingly.
We could go for the naive approach - add three new columns to our users table: t_cost, m_cost and p_cost.
It would work, as long as the algorithm remains Argon2id.
What happens if a vulnerability is found in Argon2id and we are forced to migrate away from it?
We’d probably want to add an algorithm column, as well as new columns to store the load parameters of
Argon2id’s replacement.
It can be done, but it is tedious.
Luckily enough, there is a better solution: the PHC string format. The PHC string format provides a stand-
ard representation for a password hash: it includes the hash itself, the salt, the algorithm and all its associated
parameters.
Using the PHC string format, an Argon2id password hash looks like this:
# ${algorithm}${algorithm version}${,-separated algorithm parameters}${hash}${salt}
$argon2id$v=19$m=65536,t=2,p=1$
gZiV/M1gPc22ElAH/Jh1Hw$CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno
The argon2 crate exposes PasswordHash, a Rust implementation of the PHC format:
//! argon2/lib.rs
// [...]
Storing password hashes in PHC string format spares us from having to initialise the Argon2 struct using
explicit parameters7 .
7
I have not delved too deep into the source code of the different hash algorithms that implement PasswordVerifier, but I do
wonder why verify_password needs to take &self as a parameter. Argon2 has absolutely no use for it, but it forces us to go through
412 CHAPTER 10. SECURING OUR API
By passing the expected hash via PasswordHash, Argon2 can automatically infer what load parameters and
salt should be used to verify if the password candidate is a match8 .
Let’s update our implementation:
//! src/routes/newsletters.rs
use argon2::{Argon2, PasswordHash, PasswordVerifier};
// [...]
async fn validate_credentials(
credentials: Credentials,
pool: &PgPool,
) -> Result<uuid::Uuid, PublishError> {
let row: Option<_> = sqlx::query!(
r#"
SELECT user_id, password_hash
FROM users
WHERE username = $1
"#,
credentials.username,
)
.fetch_optional(pool)
.await
.context("Failed to perform a query to retrieve stored credentials.")
.map_err(PublishError::UnexpectedError)?;
None => {
return Err(PublishError::AuthError(anyhow::anyhow!(
"Unknown username."
)))
}
};
Argon2::default()
.verify_password(
credentials.password.expose_secret().as_bytes(),
&expected_password_hash
)
.context("Invalid password.")
.map_err(PublishError::AuthError)?;
Ok(user_id)
}
It compiles successfully.
You might have also noticed that we no longer deal with the salt directly - PHC string format takes care of it
for us, implicitly.
We can get rid of the salt column entirely:
sqlx migrate add remove_salt_from_users
-- migrations/20210815112222_remove_salt_from_users.sql
ALTER TABLE users DROP COLUMN salt;
Caused by:
password hash string invalid
Let’s look at the password generation code for our test user:
//! tests/api/helpers.rs
// [...]
impl TestUser {
// [...]
async fn store(&self, pool: &PgPool) {
let password_hash = sha3::Sha3_256::digest(
self.password.as_bytes()
);
let password_hash = format!("{:x}", password_hash);
// [...]
}
}
impl TestUser {
// [...]
async fn store(&self, pool: &PgPool) {
let salt = SaltString::generate(&mut rand::thread_rng());
// We don't care about the exact Argon2 parameters here
// given that it's for testing purposes!
let password_hash = Argon2::default()
.hash_password(self.password.as_bytes(), &salt)
.unwrap()
10.2. PASSWORD-BASED AUTHENTICATION 415
.to_string();
// [...]
}
}
.context("Invalid password.")
.map_err(PublishError::AuthError)?;
Ok(user_id)
}
// We extracted the db-querying logic in its own function with its own span.
#[tracing::instrument(name = "Get stored credentials", skip(username, pool))]
async fn get_stored_credentials(
username: &str,
pool: &PgPool,
) -> Result<Option<(uuid::Uuid, Secret<String>)>, anyhow::Error> {
let row = sqlx::query!(
r#"
SELECT user_id, password_hash
FROM users
WHERE username = $1
"#,
username,
)
.fetch_optional(pool)
.await
.context("Failed to perform a query to retrieve stored credentials.")?
.map(|row| (row.user_id, Secret::new(row.password_hash)));
Ok(row)
}
We can now look at the logs from one of our integration tests:
TEST_LOG=true cargo test --quiet --release \
newsletters_are_delivered | grep "VERIFY PASSWORD" | bunyan
Roughly 10ms.
This is likely to cause issues under load - the infamous blocking problem.
async/await in Rust is built around a concept called cooperative scheduling.
How does it work?
Let’s look at an example:
async fn my_fn() {
a().await;
b().await;
c().await;
10.2. PASSWORD-BASED AUTHENTICATION 417
Every time poll is called, it tries to make progress by reaching the next state. E.g. if a.await() has returned,
we start awaiting b()9 .
We have a different state in MyFnFuture for each .await in our async function body.
This is why .await calls are often named yield points - our future progresses from the previous .await to
the next one and then yields control back to the executor.
The executor can then choose to poll the same future again or to prioritise making progress on another
task. This is how async runtimes, like tokio, manage to make progress concurrently on multiple tasks - by
continuously parking and resuming each of them.
In a way, you can think of async runtimes as great jugglers.
The underlying assumption is that most async tasks are performing some kind of input-output (IO) work
- most of their execution time will be spent waiting on something else to happen (e.g. the operating system
notifying us that there is data ready to be read on a socket), therefore we can effectively perform many more
tasks concurrently than we what we would achieve by dedicating a parallel unit of execution (e.g. one thread
per OS core) to each task.
This model works great assuming tasks cooperate by frequently yielding control back to the executor.
In other words, poll is expected to be fast - it should return in less than 10-100 microseconds10 . If a call
to poll takes longer (or, even worse, never returns), then the async executor cannot make progress on any
other task - this is what people refer to when they say that “a task is blocking the executor/the async thread”.
You should always be on the lookout for CPU-intensive workloads that are likely to take longer than 1ms -
password hashing is a perfect example.
To play nicely with tokio, we must offload our CPU-intensive task to a separate threadpool using
tokio::task::spawn_blocking. Those threads are reserved for blocking operations and do not interfere
with the scheduling of async tasks.
9
Our example is oversimplified, on purpose. In reality, each of those states will have sub-states in turn - one for each .await in
the body of the function we are calling. A future can turn into a deeply nested state machine!
10
This heuristic is reported in “Async: What is blocking?” by Alice Rhyl, one of tokio’s maintainers. An article I’d strongly
suggest you to read to understand better the underlying mechanics of tokio and async/await in general!
418 CHAPTER 10. SECURING OUR API
We are launching a computation on a separate thread - the thread itself might outlive the async task we are
spawning it from. To avoid the issue, spawn_blocking requires its argument to have a 'static lifetime -
10.2. PASSWORD-BASED AUTHENTICATION 419
which is preventing us from passing references to the current function context into the closure.
You might argue - “We are using move || {}, the closure should be taking ownership of
expected_password_hash!”.
You would be right! But that is not enough.
Let’s look again at how PasswordHash is defined:
pub struct PasswordHash<'a> {
pub algorithm: Ident<'a>,
pub salt: Option<Salt<'a>>,
// [...]
}
Ok(user_id)
}
#[tracing::instrument(
name = "Verify password hash",
skip(expected_password_hash, password_candidate)
)]
fn verify_password_hash(
expected_password_hash: Secret<String>,
420 CHAPTER 10. SECURING OUR API
password_candidate: Secret<String>,
) -> Result<(), PublishError> {
let expected_password_hash = PasswordHash::new(
expected_password_hash.expose_secret()
)
.context("Failed to parse hash in PHC string format.")
.map_err(PublishError::UnexpectedError)?;
Argon2::default()
.verify_password(
password_candidate.expose_secret().as_bytes(),
&expected_password_hash
)
.context("Invalid password.")
.map_err(PublishError::AuthError)
}
It compiles!
We are missing all the properties that are inherited from the root span of the corresponding request -
e.g. request_id, http.method, http.route, etc. Why?
Let’s look at tracing’s documentation:
Spans form a tree structure — unless it is a root span, all spans have a parent, and may have one or
more children. When a new span is created, the current span becomes the new span’s parent.
The current span is the one returned by tracing::Span::current() - let’s check its documentation:
Returns a handle to the span considered by the Collector to be the current span.
If the collector indicates that it does not track the current span, or that the thread from which this
function is called is not currently inside a span, the returned span will be disabled.
10.2. PASSWORD-BASED AUTHENTICATION 421
“Current span” actually means “active span for the current thread”.
That is why we are not inheriting any property: we are spawning our computation on a separate thread and
tracing::info_span! does not find any active Span associated with it when it executes.
We can work around the issue by explicitly attaching the current span to the newly spawn thread:
//! src/routes/newsletters.rs
// [...]
You can verify that it works - we are now getting all the properties we care about.
It is a bit verbose though - let’s write a helper function:
//! src/telemetry.rs
use tokio::task::JoinHandle;
// [...]
//! src/routes/newsletters.rs
use crate::telemetry::spawn_blocking_with_tracing;
// [...]
We can now easily reach for it every time we need to offload some CPU-intensive computation to a dedicated
threadpool.
#[tokio::test]
async fn non_existing_user_is_rejected() {
// Arrange
let app = spawn_app().await;
// Random credentials
let username = Uuid::new_v4().to_string();
let password = Uuid::new_v4().to_string();
.send()
.await
.expect("Failed to execute request.");
// Assert
assert_eq!(401, response.status().as_u16());
assert_eq!(
r#"Basic realm="publish""#,
response.headers()["WWW-Authenticate"]
);
}
Roughly 1ms.
Let’s add another test: this time we pass a valid username with an incorrect password.
//! tests/api/newsletter.rs
// [...]
#[tokio::test]
async fn invalid_password_is_rejected() {
// Arrange
let app = spawn_app().await;
let username = &app.test_user.username;
// Random password
let password = Uuid::new_v4().to_string();
assert_ne!(app.test_user.password, password);
// Assert
assert_eq!(401, response.status().as_u16());
assert_eq!(
r#"Basic realm="publish""#,
response.headers()["WWW-Authenticate"]
);
}
This one should pass as well. How long does the request take to fail?
TEST_LOG=true cargo test --quiet --release \
invalid_password_is_rejected | grep "HTTP REQUEST" | bunyan
We can now perform a timing attack against the admin login page to narrow down the list to those who have
access.
Even in our fictional example, user enumeration is not enough, on its own, to escalate our privileges.
But it can be used as a stepping stone to narrow down a set of targets for a more precise attack.
How do we prevent it?
Two strategies:
1. Remove the timing difference between an auth failure due to an invalid password and an auth failure
due to a non-existent username;
2. Limit the number of failed auth attempts for a given IP/username.
The second is generally valuable as a protection against brute-force attacks, but it requires holding some state
- we will leave it for later.
Let’s focus on the first one.
To eliminate the timing difference, we need to perform the same amount of work in both cases.
Right now, we follow this recipe:
• Fetch stored credentials for given username;
• If they do not exist, return 401;
• If they exist, hash the password candidate and compare with the stored hash.
We need to remove that early exit - we should have a fallback expected password (with salt and load paramet-
ers) that can be compared to the hash of the password candidate.
//! src/routes/newsletters.rs
// [...]
user_id = Some(stored_user_id);
expected_password_hash = stored_password_hash;
}
spawn_blocking_with_tracing(move || {
verify_password_hash(expected_password_hash, credentials.password)
})
.await
.context("Failed to spawn blocking task.")
.map_err(PublishError::UnexpectedError)??;
//! tests/api/helpers.rs
use argon2::{Algorithm, Argon2, Params, PasswordHasher, Version};
// [...]
impl TestUser {
async fn store(&self, pool: &PgPool) {
let salt = SaltString::generate(&mut rand::thread_rng());
// Match parameters of the default password
let password_hash = Argon2::new(
Algorithm::Argon2id,
Version::V0x13,
Params::new(15000, 2, 1, None).unwrap(),
)
.hash_password(self.password.as_bytes(), &salt)
.unwrap()
.to_string();
// [...]
}
// [...]
}
10.3 Is it safe?
We went to great lengths to follow all most common best practices while building our password-based au-
thentication flow.
Time to ask ourselves: is it safe?
Right now, there is no way for a user to reset their passwords. This is definitely a gap we’d need to fill.
The type of interaction we need to support is a key decision factor when it comes to authentication.
To significantly raise our security profile we’d have to throw in something they have (e.g. request signing) or
something they are (e.g. IP range restrictions).
A popular option, when all service are owned by the same organization, is mutual TLS (mTLS).
Both signing and mTLS rely on of public key cryptography - keys must be provisioned, rotated, managed.
The overhead is only justified once your system reaches a certain size.
12
Which is why you should never enter your password into a website that is not using HTTPS - i.e. HTTP + TLS.
428 CHAPTER 10. SECURING OUR API
Let’s start from the basics: how do we return an HTML page from our API?
We can begin by adding a dummy home page endpoint.
//! src/routes/mod.rs
// [...]
// New module!
mod home;
pub use home::*;
//! src/routes/home/mod.rs
use actix_web::HttpResponse;
//! src/startup.rs
use crate::routes::home;
// [...]
Not much to be seen here - we are just returning a 200 OK without a body.
Let’s add a very simple HTML landing page14 to the mix:
<!-- src/routes/home/home.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<title>Home</title>
</head>
<body>
14
An in-depth introduction to HTML and CSS is beyond the scope of this book. We will avoid CSS entirely while explaining
the required basics of HTML as we introduce new elements to build the pages we need for our newsletter application. Check out
Interneting is hard (but it doesn't have to be) for an excellent introduction to these topics.
10.5. LOGIN FORMS 431
We want to read this file and return it as the body of our GET / endpoint.
We can use include_str!, a macro from Rust’s standard library: it reads the file at the provided path and
returns its content as a &'static str.
This is possible because include_str! operates at compile-time - the file content is stored as part of
the application binary, therefore ensuring that a pointer to its content (&str) remains valid indefinitely
('static)15 .
//! src/routes/home/mod.rs
// [...]
If you launch your application with cargo run and visit http://localhost:8000 in the browser you should
see the Welcome to our newsletter! message.
The browser is not entirely happy though - if you open the browser’s console16 , you should see a warning.
On Firefox 93.0:
In other words - the browser has inferred that we are returning HTML content, but it would very much
prefer to be told explicitly.
We have two options:
When returning an HTML page, the content type should be set to text/html; charset=utf-8.
Let’s add it in:
15
There is often confusion around 'static due to its different meanings depending on the context. Check out this excellent
piece on common Rust lifetime misconceptions if you want to learn more about the topic.
16
Throughout this chapter we will rely on the introspection tools made available by browsers. For Firefox, follow this guide. For
Google Chrome, follow this guide.
432 CHAPTER 10. SECURING OUR API
//! src/routes/home/mod.rs
// [...]
use actix_web::http::header::ContentType;
10.6 Login
Let’s start working on our login form.
We need to wire up an endpoint placeholder, just like we did for GET /. We will serve the login form at GET
/login.
//! src/routes/mod.rs
// [...]
// New module!
mod login;
pub use login::*;
//! src/routes/login/mod.rs
mod get;
pub use get::login_form;
//! src/routes/login/get.rs
use actix_web::HttpResponse;
10.6. LOGIN 433
//! src/startup.rs
use crate::routes::{/* */, login_form};
// [...]
<label>Password
<input
type="password"
placeholder="Enter Password"
434 CHAPTER 10. SECURING OUR API
name="password"
>
</label>
<button type="submit">Login</button>
</form>
</body>
</html>
//! src/routes/login/get.rs
use actix_web::HttpResponse;
use actix_web::http::header::ContentType;
form is the HTML element doing the heavy-lifting here. Its job is to collect a set of data fields and send them
over for processing to a backend server.
The fields are defined using the input element - we have two here: username and password.
Inputs are given a type attribute - it tells the browser how to display them.
text and password will both be rendered as a single-line free-text field, with one key difference: the characters
entered into a password field are obfuscated.
Each input is wrapped in a label element:
• clicking on the label name toggles the input field;
• it improves accessibility for screen-readers users (it is read out loud when the user is focused on the
element).
On each input we have set two other attributes:
• placeholder, whose value is shown as a suggestion within the text field before the user starts filling
the form;
• name, the key that we must use in the backend to identify the field value within the submitted form
data.
At the end of the form, there is a button - it will trigger the submission of the provided input to the backend.
What happens if you enter a random username and password and try to submit it?
The page refreshes, the input fields are reset - the URL has changed though!
It should now be localhost:8000/login?username=myusername&password=mysecretpassword.
This is form’s default behaviour17 - form submits the data to the very same page it is being served from
17
It begs the question of why GET was chosen as default method, considering it is strictly less secure. We also do not see any
10.6. LOGIN 435
(i.e. /login) using the GET HTTP verb. This is far from ideal - as you have just witnessed, a form submitted
via GET encodes all input data in clear text as query parameters. Being part of the URL, they end up stored as
part of the browser’s navigation history. Query parameters are also captured in logs (e.g. http.route prop-
erty in our own backend).
We really do not want passwords or any type of sensitive data there.
We can change this behaviour by setting a value for action and method on form:
<!-- src/routes/login/login.html -->
<!-- [...] -->
<form action="/login" method="post">
<!-- [...] -->
We could technically omit action, but the default behaviour is not particularly well-documented therefore
it is clearer to define it explicitly.
Thanks to method="post" the input data will be passed to the backend using the request body, a much safer
option.
If you try to submit the form again, you should see a 404 in the API logs for POST /login. Let’s define the
endpoint!
//! src/routes/login/mod.rs
// [...]
mod post;
pub use post::login;
//! src/routes/login/post.rs
use actix_web::HttpResponse;
//! src/startup.rs
use crate::routes::login;
// [...]
warnings in the browser’s console, even though we are obviously transmitting sensitive data in clear text via query parameters (a
field with type password, form using GET as method).
436 CHAPTER 10. SECURING OUR API
})
// [...]
}
You should now see Welcome to our newsletter! after form submission.
//! src/routes/login/post.rs
// [...]
10.6. LOGIN 437
use actix_web::web;
use secrecy::Secret;
#[derive(serde::Deserialize)]
pub struct FormData {
username: String,
password: Secret<String>,
}
We built the foundation of password-based authentication in the earlier part of this chapter - let’s look again
at the auth code in the handler for POST /newsletters:
//! src/routes/newsletters.rs
// [...]
#[tracing::instrument(
name = "Publish a newsletter issue",
skip(body, pool, email_client, request),
fields(username=tracing::field::Empty, user_id=tracing::field::Empty)
)]
pub async fn publish_newsletter(
body: web::Json<BodyData>,
pool: web::Data<PgPool>,
email_client: web::Data<EmailClient>,
request: HttpRequest,
) -> Result<HttpResponse, PublishError> {
let credentials = basic_authentication(request.headers())
.map_err(PublishError::AuthError)?;
tracing::Span::current()
.record("username", &tracing::field::display(&credentials.username));
let user_id = validate_credentials(credentials, &pool).await?;
tracing::Span::current()
.record("user_id", &tracing::field::display(&user_id));
// [...]
basic_authentication deals with the extraction of credentials from the Authorization header when using
the ‘Basic’ authentication scheme - not something we are interested in reusing in login.
validation_credentials, instead, is what we are looking for: it takes username and password as input,
returning either the corresponding user_id (if authentication is successful) or an error (if credentials are
invalid).
438 CHAPTER 10. SECURING OUR API
//! src/routes/newsletters.rs
// [...]
async fn validate_credentials(
credentials: Credentials,
pool: &PgPool,
// We are returning a `PublishError`,
// which is a specific error type detailing
// the relevant failure modes of `POST /newsletters`
// (not just auth!)
) -> Result<uuid::Uuid, PublishError> {
let mut user_id = None;
let mut expected_password_hash = Secret::new(
"$argon2id$v=19$m=15000,t=2,p=1$\
gZiV/M1gPc22ElAH/Jh1Hw$\
CWOrkoo7oJBQ/iyh7uJ0LO2aLEfrHwTWllSAxT0zRno"
.to_string()
);
spawn_blocking_with_tracing(move || {
verify_password_hash(expected_password_hash, credentials.password)
})
.await
.context("Failed to spawn blocking task.")
.map_err(PublishError::UnexpectedError)??;
user_id.ok_or_else(|| {
PublishError::AuthError(anyhow::anyhow!("Unknown username."))
})
}
10.6. LOGIN 439
//! src/authentication.rs
#[derive(thiserror::Error, Debug)]
pub enum AuthError {
#[error("Invalid credentials.")]
InvalidCredentials(#[source] anyhow::Error),
#[error(transparent)]
UnexpectedError(#[from] anyhow::Error),
}
We are using an enumeration because, just like we did in POST /newsletters, we want to empower the caller
to react differently depending on the error type - i.e. return a 500 for UnexpectedError, while AuthErrors
should result into a 401.
Let’s change the signature of validate_credentials to return Result<uuid::Uuid, AuthError> now:
//! src/routes/newsletters.rs
use crate::authentication::AuthError;
// [...]
async fn validate_credentials(
// [...]
) -> Result<uuid::Uuid, AuthError> {
// [...]
spawn_blocking_with_tracing(/* */)
.await
.context("Failed to spawn blocking task.")??;
user_id
.ok_or_else(|| anyhow::anyhow!("Unknown username."))
.map_err(AuthError::InvalidCredentials)
}
440 CHAPTER 10. SECURING OUR API
The first error comes from validate_credentials itself - we are calling verify_password_hash, which is
still returning a PublishError.
//! src/routes/newsletters.rs
// [...]
#[tracing::instrument(/* */)]
fn verify_password_hash(
expected_password_hash: Secret<String>,
password_candidate: Secret<String>,
) -> Result<(), PublishError> {
let expected_password_hash = PasswordHash::new(
expected_password_hash.expose_secret()
)
.context("Failed to parse hash in PHC string format.")
.map_err(PublishError::UnexpectedError)?;
Argon2::default()
.verify_password(
password_candidate.expose_secret().as_bytes(),
&expected_password_hash
)
.context("Invalid password.")
.map_err(PublishError::AuthError)
}
//! src/routes/newsletters.rs
// [...]
#[tracing::instrument(/* */)]
fn verify_password_hash(/* */) -> Result<(), AuthError> {
let expected_password_hash = PasswordHash::new(/* */)
.context("Failed to parse hash in PHC string format.")?;
Argon2::default()
.verify_password(/* */)
.context("Invalid password.")
.map_err(AuthError::InvalidCredentials)
}
This comes from the call to validate_credentials inside publish_newsletters, the request handler.
AuthError does not implement a conversion into PublishError, therefore the ? operator cannot be used.
We will call map_err to perform the mapping inline:
//! src/routes/newsletters.rs
// [...]
#[tracing::instrument(/* */)]
pub async fn validate_credentials(/* */) -> Result</* */> {
// [...]
}
#[tracing::instrument(/* */)]
fn verify_password_hash(/* */) -> Result</* */> {
// [...]
}
#[tracing::instrument(/* */)]
async fn get_stored_credentials(/* */) -> Result</* */> {
// [...]
}
//! src/routes/newsletters.rs
// [...]
use crate::authentication::{validate_credentials, AuthError, Credentials};
// There will be warnings about unused imports, follow the compiler to fix them!
// [...]
//! src/routes/login/post.rs
use crate::authentication::{validate_credentials, Credentials};
use actix_web::http::header::LOCATION;
use actix_web::web;
use actix_web::HttpResponse;
use secrecy::Secret;
use sqlx::PgPool;
#[derive(serde::Deserialize)]
pub struct FormData {
username: String,
password: Secret<String>,
}
#[tracing::instrument(
skip(form, pool),
fields(username=tracing::field::Empty, user_id=tracing::field::Empty)
)]
// We are now injecting `PgPool` to retrieve stored credentials from the database
pub async fn login(
form: web::Form<FormData>,
pool: web::Data<PgPool>
) -> HttpResponse {
let credentials = Credentials {
username: form.0.username,
password: form.0.password,
};
tracing::Span::current()
.record("username", &tracing::field::display(&credentials.username));
match validate_credentials(credentials, &pool).await {
Ok(user_id) => {
tracing::Span::current()
.record("user_id", &tracing::field::display(&user_id));
HttpResponse::SeeOther()
.insert_header((LOCATION, "/"))
.finish()
}
Err(_) => {
todo!()
}
}
}
A login attempt using random credentials should now fail: the request handler panics due to
444 CHAPTER 10. SECURING OUR API
validation_credentials returning an error, which in turn leads to actix-web dropping the connection.
It is not a graceful failure - the browser is likely to show something along the lines of The connection was
reset.
We should try as much as possible to avoid panics in request handlers - all errors should be handled grace-
fully.
Let’s introduce a LoginError:
//! src/routes/login/post.rs
// [...]
use crate::authentication::AuthError;
use crate::routes::error_chain_fmt;
use actix_web::http::StatusCode;
use actix_web::{web, ResponseError};
#[tracing::instrument(/* */)]
pub async fn login(/* */) -> Result<HttpResponse, LoginError> {
// [...]
let user_id = validate_credentials(credentials, &pool)
.await
.map_err(|e| match e {
AuthError::InvalidCredentials(_) => LoginError::AuthError(e.into()),
AuthError::UnexpectedError(_) => LoginError::UnexpectedError(e.into()),
})?;
tracing::Span::current().record("user_id", &tracing::field::display(&user_id));
Ok(HttpResponse::SeeOther()
.insert_header((LOCATION, "/"))
.finish())
}
#[derive(thiserror::Error)]
pub enum LoginError {
#[error("Authentication failed")]
AuthError(#[source] anyhow::Error),
#[error("Something went wrong")]
UnexpectedError(#[from] anyhow::Error),
}
match self {
LoginError::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR,
LoginError::AuthError(_) => StatusCode::UNAUTHORIZED,
}
}
}
The code is very similar to what we wrote a few sections ago while refactoring POST /newsletters.
What is the effect on the browser?
Submission of the form triggers a page load, resulting in Authentication failed being shown on screen18 .
Much better than before, we are making progress!
18
The default implementation of error_response provided by actix_web’s ResponseError trait populates the body using the
Display representation of the error returned by the request handler.
446 CHAPTER 10. SECURING OUR API
<p><i>{}</i></p>
<form action="/login" method="post">
<label>Username
<input
type="text"
placeholder="Enter Username"
name="username"
>
</label>
<label>Password
<input
type="password"
placeholder="Enter Password"
name="password"
>
</label>
<button type="submit">Login</button>
</form>
</body>
</html>"#,
self
))
}
To solve the second issue, we need the user to land on a GET endpoint.
To solve the first issue, we need to find a way to reuse the HTML we wrote in GET /login, instead of duplic-
ating it.
We can achieve both goals with another redirect: if authentication fails, we send the user back to GET /login.
10.6. LOGIN 447
//! src/routes/login/post.rs
// [...]
Unfortunately a vanilla redirect is not enough - the browser would show the login form to the user again,
with no feedback explaining that their login attempt was unsuccessful.
We need to find a way to instruct GET /login to show an error message.
Let’s explore a few options.
#! Cargo.toml
# [...]
[dependencies]
urlencoding = "2"
# [...]
//! src/routes/login/post.rs
// [...]
// [...]
}
The error query parameter can then be extracted in the request handler for GET /login.
//! src/routes/login/get.rs
use actix_web::{web, HttpResponse, http::header::ContentType};
#[derive(serde::Deserialize)]
pub struct QueryParams {
error: Option<String>,
}
Finally, we can customise the returned HTML page based on its value:
//! src/routes/login/get.rs
// [...]
placeholder="Enter Username"
name="username"
>
</label>
<label>Password
<input
type="password"
placeholder="Enter Password"
name="password"
>
</label>
<button type="submit">Login</button>
</form>
</body>
</html>"#,
))
}
It works19 !
Your account has been locked, please submit your details here to resolve the issue.
content built from untrusted sources - e.g. user inputs, query parameters, etc.
From a user perspective, XSS attacks are particularly insidious - the URL matches the one you wanted to
visit, therefore you are likely to trust the displayed content.
OWASP provides an extensive cheat sheet on how to prevent XSS attacks - I strongly recommend familiar-
ising with it if you are working on a web application.
Let’s look at the guidance for our issue here: we want to display untrusted data (the value of a query para-
meter) inside an HTML element (<p><i>UNTRUSTED DATA HERE</i></p>).
According to OWASP’s guidelines, we must HTML entity-encode the untrusted input - i.e.:
• convert & to &
• convert < to <
• convert > to >
• convert " to "
• convert ' to '
• convert / to /.
HTML entity encoding prevents the insertion of further HTML elements by escaping the characters re-
quired to define them.
Let’s amend our login_form handler:
#! Cargo.toml
# [...]
[dependencies]
htmlescape = "0.3"
# [...]
//! src/routes/login/get.rs
// [...]
Load the compromised URL again - you will see a different message:
Your account has been locked, please submit your details <a
href=”https://zero2prod.com”>here</a> to resolve the issue.
10.6. LOGIN 451
The HTML a element is no longer rendered by the browser - the user has now reasons to suspect that some-
thing fishy is going on.
Is it enough?
At the very least, users are less likely to copy-paste and navigate to the link compared to just clicking on here.
Nonetheless, attackers are not naive - they will amend the injected message as soon as they notice that our
website is performing HTML entity encoding. It could be as simple as
Your account has been locked, please call +CC3332288777 to resolve the issue.
This might be good enough to lure in a couple of victims. We need something stronger than character escap-
ing.
We are deliberately omitting a few nuances around key padding - you can find all the details in RFC 2104.
sha2 = "0.10"
# [...]
Let’s add another query parameter to our Location header, tag, to store the HMAC of our error message.
//! src/routes/login/post.rs
use hmac::{Hmac, Mac};
// [...]
The code snippet is almost perfect - we just need a way to get our secret!
Unfortunately it will not be possible from within ResponseError - we only have access to the error type
(LoginError) that we are trying to convert into an HTTP response. ResponseError is just a specialised
Into trait.
In particular, we do not have access to the application state (i.e. we cannot use the web::Data extractor),
which is where we would be storing the secret.
// [...]
#[tracing::instrument(
skip(form, pool, secret),
fields(username=tracing::field::Empty, user_id=tracing::field::Empty)
)]
pub async fn login(
form: web::Form<FormData>,
pool: web::Data<PgPool>,
// Injecting the secret as a secret string for the time being.
secret: web::Data<Secret<String>>,
// No longer returning a `Result<HttpResponse, LoginError>`!
) -> HttpResponse {
// [...]
match validate_credentials(credentials, &pool).await {
Ok(user_id) => {
tracing::Span::current()
.record("user_id", &tracing::field::display(&user_id));
HttpResponse::SeeOther()
.insert_header((LOCATION, "/"))
.finish()
}
Err(e) => {
let e = match e {
AuthError::InvalidCredentials(_) => LoginError::AuthError(e.into()),
AuthError::UnexpectedError(_) => {
LoginError::UnexpectedError(e.into())
},
};
let query_string = format!(
"error={}",
urlencoding::Encoded::new(e.to_string())
);
let hmac_tag = {
let mut mac = Hmac::<sha2::Sha256>::new_from_slice(
secret.expose_secret().as_bytes()
).unwrap();
mac.update(query_string.as_bytes());
mac.finalize().into_bytes()
};
HttpResponse::SeeOther()
.insert_header((
LOCATION,
format!("/login?{}&tag={:x}", query_string, hmac_tag),
454 CHAPTER 10. SECURING OUR API
))
.finish()
}
}
}
#[tracing::instrument(/* */)]
// Returning a `Result` again!
pub async fn login(/* */) -> Result<HttpResponse, InternalError<LoginError>> {
// [...]
match validate_credentials(credentials, &pool).await {
Ok(user_id) => {
// [...]
// We need to Ok-wrap again
Ok(/* */)
}
Err(e) => {
// [...]
let response = HttpResponse::SeeOther()
.insert_header((
LOCATION,
format!("/login?{}&tag={:x}", query_string, hmac_tag),
))
.finish();
Err(InternalError::from_response(e, response))
}
}
}
10.6. LOGIN 455
//! src/startup.rs
use secrecy::Secret;
// [...]
impl Application {
pub async fn build(configuration: Settings) -> Result<Self, std::io::Error> {
// [...]
let server = run(
// [...]
configuration.application.hmac_secret,
)?;
// [...]
}
}
fn run(
// [...]
hmac_secret: Secret<String>,
) -> Result<Server, std::io::Error> {
let server = HttpServer::new(move || {
// [...]
.app_data(Data::new(hmac_secret.clone()))
})
// [...]
}
#! configuration/base.yml
application:
# [...]
# You need to set the `APP_APPLICATION__HMAC_SECRET` environment variable
# on Digital Ocean as well for production!
hmac_secret: "long-and-very-secret-random-key-needed-to-verify-message-integrity"
456 CHAPTER 10. SECURING OUR API
# [...]
Using Secret<String> as the type injected into the application state is far from ideal. String is a prim-
itive type and there is a significant risk of conflict - i.e. another middleware or service registering another
Secret<String> against the application state, overriding our HMAC secret (or vice versa).
Let’s create a wrapper type to sidestep the issue:
//! src/startup.rs
// [...]
fn run(
// [...]
hmac_secret: Secret<String>,
) -> Result<Server, std::io::Error> {
let server = HttpServer::new(move || {
// [...]
.app_data(Data::new(HmacSecret(hmac_secret.clone())))
})
// [...]
}
#[derive(Clone)]
pub struct HmacSecret(pub Secret<String>);
//! src/routes/login/post.rs
use crate::startup::HmacSecret;
// [...]
#[tracing::instrument(/* */)]
pub async fn login(
// [...]
// Inject the wrapper type!
secret: web::Data<HmacSecret>,
) -> Result<HttpResponse, InternalError<LoginError>> {
// [...]
match validate_credentials(/* */).await {
Ok(/* */) => {/* */}
Err(/* */) => {
// [...]
let hmac_tag = {
let mut mac = Hmac::<sha2::Sha256>::new_from_slice(
secret.0.expose_secret().as_bytes()
).unwrap();
// [...]
10.6. LOGIN 457
};
// [...]
}
}
}
to
#[derive(serde::Deserialize)]
pub struct QueryParams {
error: Option<String>,
tag: Option<String>,
}
would not capture the new requirements accurately - it would allow callers to pass a tag parameter while
omitting the error one, or vice versa. We would need to do extra validation in the request handler to make
sure this is not the case.
We can avoid this issue entirely by making all fields in QueryParams required while QueryParams itself be-
comes optional:
//! src/routes/login/get.rs
// [...]
#[derive(serde::Deserialize)]
pub struct QueryParams {
error: String,
tag: String,
458 CHAPTER 10. SECURING OUR API
A neat little reminder to make illegal state impossible to represent using types!
To verify the tag we will need access to the HMAC shared secret - let’s inject it:
//! src/routes/login/get.rs
use crate::startup::HmacSecret;
// [...]
tag was a byte slice encoded as a hex string. We will need the hex crate to decode it back to bytes in GET
/login. Let’s add it as a dependency:
#! Cargo.toml
# [...]
[dependencies]
# [...]
hex = "0.4"
We can now define a verify method on QueryParams itself: it will return the error string if the message
authentication code matches our expectations, an error otherwise.
//! src/routes/login/get.rs
use hmac::{Hmac, Mac};
use secrecy::ExposeSecret;
// [...]
impl QueryParams {
fn verify(self, secret: &HmacSecret) -> Result<String, anyhow::Error> {
10.6. LOGIN 459
Ok(self.error)
}
}
We now need to amend the request handler to call it, which raises a question: what do we want to do if the
verification fails?
One approach is to fail the entire request by returning a 400. Alternatively, we can log the verification failure
as a warning and skip the error message when rendering the HTML.
Let’s go for the latter - a user being redirected with some dodgy query parameters will see our login page, an
acceptable scenario.
//! src/routes/login/get.rs
// [...]
[…] a small piece of data that a server sends to a user’s web browser. The browser may store the cookie
and send it back to the same server with later requests.
We can use cookies to implement the same strategy we tried with query parameters:
• The user enters invalid credentials and submits the form;
• POST /login sets a cookie containing the error message and redirects the user back to GET /login;
• The browser calls GET /login, including the values of the cookies currently set for the user;
10.6. LOGIN 461
• GET /login’s request handler checks the cookies to see if there is an error message to be rendered;
• GET /login returns the HTML form to the caller and deletes the error message from the cookie.
The URL is never touched - all error-related information is exchanged via a side-channel (cookies), invisible
to the browser history. The last step in the algorithm ensures that the error message is indeed ephemeral -
the cookie is “consumed” when the error message is rendered. If the page is reloaded, the error message will
not be shown again.
One-time notifications, the technique we just described, are known as flash messages.
//! tests/api/login.rs
// Empty for now
We will need to send a POST /login request - let’s add a little helper to our TestApp, the HTTP client used
to interact with our application in our tests:
//! tests/api/helpers.rs
// [...]
impl TestApp {
pub async fn post_login<Body>(&self, body: &Body) -> reqwest::Response
where
Body: serde::Serialize,
{
reqwest::Client::new()
.post(&format!("{}/login", &self.address))
// This `reqwest` method makes sure that the body is URL-encoded
// and the `Content-Type` header is set accordingly.
.form(body)
.send()
.await
462 CHAPTER 10. SECURING OUR API
// [...]
}
We can now start to sketch our test case. Before touching cookies, we will begin with a simple assertion - it
returns a redirect, status code 303.
//! tests/api/login.rs
use crate::helpers::spawn_app;
#[tokio::test]
async fn an_error_flash_message_is_set_on_failure() {
// Arrange
let app = spawn_app().await;
// Act
let login_body = serde_json::json!({
"username": "random-username",
"password": "random-password"
});
let response = app.post_login(&login_body).await;
// Assert
assert_eq!(response.status().as_u16(), 303);
}
Our endpoint already returns a 303 - both in case of failure and success! What is going on?
The answer can be found in reqwest’s documentation:
By default, a Client will automatically handle HTTP redirects, having a maximum redirect chain of
10 hops. To customize this behavior, a redirect::Policy can be used with a ClientBuilder.
reqwest::Client sees the 303 status code and automatically proceeds to call GET /login, the path specified
in the Location header, which return a 200 - the status code we see in the assertion panic message.
For the purpose of our testing, we do not want reqwest::Client to follow redirects - let’s customise the
10.6. LOGIN 463
impl TestApp {
pub async fn post_login<Body>(&self, body: &Body) -> reqwest::Response
where
Body: serde::Serialize,
{
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap()
// [...]
}
// [...]
}
// Little helper function - we will be doing this check several times throughout
// this chapter and the next one.
pub fn assert_is_redirect_to(response: &reqwest::Response, location: &str) {
assert_eq!(response.status().as_u16(), 303);
assert_eq!(response.headers().get("Location").unwrap(), location);
}
//! tests/api/login.rs
use crate::helpers::assert_is_redirect_to;
// [...]
#[tokio::test]
async fn an_error_flash_message_is_set_on_failure() {
// [...]
// Assert
assert_is_redirect_to(&response, "/login");
}
The endpoint is still using query parameters to pass along the error message. Let’s remove that functionality
from the request handler:
//! src/routes/login/post.rs
// A few imports are now unused and can be removed.
// [...]
#[tracing::instrument(/* */)]
pub async fn login(
form: web::Form<FormData>,
pool: web::Data<PgPool>,
// We no longer need `HmacSecret`!
) -> Result<HttpResponse, InternalError<LoginError>> {
// [...]
match validate_credentials(/* */).await {
Ok(/* */) => {/* */}
Err(e) => {
let e = match e {
AuthError::InvalidCredentials(_) => LoginError::AuthError(e.into()),
AuthError::UnexpectedError(_) => {
LoginError::UnexpectedError(e.into())
},
};
let response = HttpResponse::SeeOther()
.insert_header((LOCATION, "/login"))
.finish();
Err(InternalError::from_response(e, response))
}
}
}
I know, it feels like we are going backwards - you need to have a bit of patience!
The test should pass. We can now start looking at cookies, which begs the question - what does “set a cookie”
actually mean?
Cookies are set by attaching a special HTTP header to the response - Set-Cookie.
In its simplest form it looks like this:
10.6. LOGIN 465
Set-Cookie: {cookie-name}={cookie-value}
Set-Cookie can be specified multiple times - one for each cookie you want to set.
reqwest provides the get_all method to deal with multi-value headers:
//! tests/api/login.rs
// [...]
use reqwest::header::HeaderValue;
use std::collections::HashSet;
#[tokio::test]
async fn an_error_flash_message_is_set_on_failure() {
// [...]
let cookies: HashSet<_> = response
.headers()
.get_all("Set-Cookie")
.into_iter()
.collect();
assert!(cookies
.contains(&HeaderValue::from_str("_flash=Authentication failed").unwrap())
);
}
Truth be told, cookies are so ubiquitous to deserve a dedicated API, sparing us the pain of working with the
raw headers. reqwest locks this functionality behind the cookies feature-flag - let’s enable it:
#! Cargo.toml
# [...]
# Using multi-line format for brevity
[dependencies.reqwest]
version = "0.11"
default-features = false
features = ["json", "rustls-tls", "cookies"]
//! tests/api/login.rs
// [...]
use reqwest::header::HeaderValue;
use std::collections::HashSet;
#[tokio::test]
async fn an_error_flash_message_is_set_on_failure() {
// [...]
let flash_cookie = response.cookies().find(|c| c.name() == "_flash").unwrap();
assert_eq!(flash_cookie.value(), "Authentication failed");
}
466 CHAPTER 10. SECURING OUR API
As you can see, the cookie API is significantly more ergonomic. Nonetheless there is value in touching
directly what it abstracts away, at least once.
The test should fail, as expected.
#[tracing::instrument(/* */)]
pub async fn login(/* */) -> Result<HttpResponse, InternalError<LoginError>> {
match validate_credentials(/* */).await {
Ok(/* */) => {/* */}
Err(e) => {
// [...]
let response = HttpResponse::SeeOther()
.insert_header((LOCATION, "/login"))
.insert_header(("Set-Cookie", format!("_flash={e}")))
.finish();
Err(InternalError::from_response(e, response))
}
}
}
#[tracing::instrument(/* */)]
pub async fn login(/* */) -> Result<HttpResponse, InternalError<LoginError>> {
match validate_credentials(/* */).await {
Ok(/* */) => {/* */}
Err(e) => {
// [...]
let response = HttpResponse::SeeOther()
.insert_header((LOCATION, "/login"))
.cookie(Cookie::new("_flash", e.to_string()))
10.6. LOGIN 467
.finish();
Err(InternalError::from_response(e, response))
}
}
}
impl TestApp {
// Our tests will only look at the HTML page, therefore
// we do not expose the underlying reqwest::Response
pub async fn get_login_html(&self) -> String {
reqwest::Client::new()
.get(&format!("{}/login", &self.address))
.send()
.await
.expect("Failed to execute request.")
.text()
.await
.unwrap()
}
// [...]
}
We can then extend our existing test to call get_login_html after having submitted invalid credentials to
POST /login:
//! tests/api/login.rs
// [...]
#[tokio::test]
async fn an_error_flash_message_is_set_on_failure() {
// [...]
// Act
let login_body = serde_json::json!({
"username": "random-username",
468 CHAPTER 10. SECURING OUR API
"password": "random-password"
});
let response = app.post_login(&login_body).await;
// Assert
// [...]
// Act - Part 2
let html_page = app.get_login_html().await;
assert!(html_page.contains(r#"<p><i>Authentication failed</i></p>"#));
}
// [...]
}
impl TestApp {
pub async fn post_subscriptions(/* */) -> reqwest::Response {
self.api_client
.post(/* */)
// [...]
}
//! src/routes/login/get.rs
use crate::startup::HmacSecret;
use actix_web::http::header::ContentType;
use actix_web::{web, HttpResponse};
use hmac::{Hmac, Mac, NewMac};
470 CHAPTER 10. SECURING OUR API
#[derive(serde::Deserialize)]
pub struct QueryParams {
error: String,
tag: String,
}
impl QueryParams {
fn verify(self, secret: &HmacSecret) -> Result<String, anyhow::Error> {
/* */
}
}
Let’s begin by ripping out all the code related to query parameters and their (cryptographic) validation:
//! src/routes/login/get.rs
use actix_web::http::header::ContentType;
use actix_web::HttpResponse;
Back to the basics. Let’s seize this opportunity to remove the dependencies we added during our HMAC
adventure - sha2, hmac and hex.
To access cookies on an incoming request we need to get our hands on HttpRequest itself. Let’s add it as an
input to login_form:
//! src/routes/login/get.rs
// [...]
use actix_web::HttpRequest;
This is not what we had in mind when we said that error messages should be ephemeral. How do we fix it?
There is no Unset-cookie header - how do we delete the _flash cookie from the user’s browser?
Let’s zoom in on the lifecycle of a cookie.
When it comes to durability, there are two types of cookies: session cookies and persistent cookies. Session
cookies are stored in memory - they are deleted when the session ends (i.e. the browser is closed). Persistent
cookies, instead, are saved to disk and will still be there when you re-open the browser.
472 CHAPTER 10. SECURING OUR API
A vanilla Set-Cookie header creates a session cookie. To set a persistent cookie you must specify an expira-
tion policy using a cookie attribute - either Max-Age or Expires.
Max-Age is interpreted as the number of seconds remaining until the cookie expires - e.g. Set-Cookie:
_flash=omg; Max-Age=5 creates a persistent _flash cookie that will be valid for the next 5 seconds.
Expires, instead, expects a date - e.g. Set-Cookie: _flash=omg; Expires=Thu, 31 Dec 2022 23:59:59
GMT; creates a persistent cookie that will be valid until the end of 2022.
Setting Max-Age to 0 instructs the browser to immediately expire the cookie - i.e. to unset it, which is exactly
what we want! A bit hacky? Yes, but it is what it is.
Let’s kick-off the implementation work. We can start by modifying our integration test to account for this
scenario - the error message should not be shown if we reload the login page after the first redirect:
//! tests/api/login.rs
// [...]
#[tokio::test]
async fn an_error_flash_message_is_set_on_failure() {
// Arrange
// [...]
// Act - Part 1 - Try to login
// [...]
// Act - Part 2 - Follow the redirect
// [...]
// Act - Part 3 - Reload the login page
let html_page = app.get_login_html().await;
assert!(!html_page.contains(r#"Authentication failed"#));
}
cargo test should report a failure. We now need to change our request handler - we must set the _flash
cookie on the response with Max-Age=0 to remove the flash messages stored in the user’s browser.:
//! src/routes/login/get.rs
use actix_web::cookie::{Cookie, time::Duration};
//! [...]
Under the hood, it performs the exact same operation but it does not require the reader to piece together
the meaning of setting Max-Age to zero.
10.6.4.15 actix-web-flash-messages
We could use the cookie API provided by actix-web to harden our cookie-based implementation of flash
messages - some things are straight-forward (Secure, Http-Only), others requires a bit more work (HMAC),
but they are all quite achievable if we put in some effort.
We have already covered HMAC tags in depth when discussing query parameters, so there would be little
educational benefit in implementing signed cookies from scratch. We will instead plug in one of the crates
from actix-web’s community ecosystem: actix-web-flash-messages21 .
actix-web-flash-messages provides a framework to work with flash messages in actix-web, closely
modeled after Django’s message framework.
Let’s add it as a dependency:
#! Cargo.toml
# [...]
[dependencies]
actix-web-flash-messages = { version = "0.4", features = ["cookies"] }
# [...]
To start playing around with flash messages we need to register FlashMessagesFramework as a middleware
on our actix_web’s App:
//! src/startup.rs
// [...]
use actix_web_flash_messages::FlashMessagesFramework;
20
An attack known as “cookie jar overflow” can be used to delete pre-existing Http-Only cookies. The cookies can then be over-
written with a value set by the malicious script.
21
Full disclosure: I am the author of actix-web-flash-messages.
10.6. LOGIN 475
// [...]
let message_framework = FlashMessagesFramework::builder(todo!()).build();
let server = HttpServer::new(move || {
App::new()
.wrap(message_framework.clone())
.wrap(TracingLogger::default())
// [...]
})
// [...]
}
//! src/startup.rs
// [...]
use actix_web_flash_messages::storage::CookieMessageStore;
CookieMessageStore enforces that the cookie used as storage is signed, therefore we must provide a Key to its
builder. We can reuse the hmac_secret we introduced when working on HMAC tags for query parameters:
//! src/startup.rs
// [...]
use secrecy::ExposeSecret;
use actix_web::cookie::Key;
• Only show flash messages at info level or above in a production environment, while retaining debug
level messages for local development;
• Use different colours, in the UI, to display messages (e.g. red for errors, orange for warnings, etc.).
We can rework POST /login to send a FlashMessage:
//! src/routes/login/post.rs
// [...]
use actix_web_flash_messages::FlashMessage;
#[tracing::instrument(/* */)]
pub async fn login(/* */) -> Result</* */> {
// [...]
match validate_credentials(/* */).await {
Ok(/* */) => { /* */ }
Err(e) => {
let e = /* */;
FlashMessage::error(e.to_string()).send();
let response = HttpResponse::SeeOther()
// No cookies here now!
.insert_header((LOCATION, "/login"))
.finish();
// [...]
}
}
}
The FlashMessagesFramework middleware takes care of all the heavy-lifting behind the scenes - creating the
cookie, signing it, setting the right properties, etc.
We can also attach multiple flash messages to a single response - the framework takes care of how they should
be combined and represented in the storage layer.
How does the receiving side work? How do we read incoming flash messages in GET /login?
HttpResponse::Ok()
// No more removal cookie!
.content_type(ContentType::html())
.body(format!(/* */))
}
The code needs to change a bit to accommodate the chance of having received multiple flash mes-
sages, but overall it is almost equivalent. In particular, we no longer have to deal with the cookie API,
neither to retrieve incoming flash messages nor to make sure that they get erased after having been read
- actix-web-flash-messages takes care of it. The validity of the cookie signature is verified in the back-
ground as well, before the request handler is invoked.
Our assertions are a bit too close to the implementation details - we should only verify that the rendered
HTML contains (or does not contain) the expected error message. Let’s amend the test code:
//! tests/api/login.rs
// [...]
#[tokio::test]
async fn an_error_flash_message_is_set_on_failure() {
// Arrange
// [...]
// Act - Part 1 - Try to login
// [...]
// Assert
// No longer asserting facts related to cookies
assert_is_redirect_to(&response, "/login");
10.7 Sessions
We focused for a while on what should happen on a failed login attempt. Time to swap: what do we expect
to see after a successful login?
Authentication is meant to restrict access to functionality that requires higher privileges - in our case, the cap-
ability to send out a new issue of the newsletter to the entire mailing list. We want to build an administration
panel - we will have a /admin/dashboard page, restricted to logged-in users, to access all admin functionality.
We will get there in stages. As the very first milestone, we want to:
• redirect to /admin/dashboard after a successful login attempt to show a Welcome <username>! greet-
ing message;
• if a user tries to navigate directly to /admin/dashboard and they are not logged in, they will be redir-
ected to the login form.
This plan requires sessions.
(CSPRNG).
Randomness on its own is not enough - we also need uniqueness. If we were to associate two users with the
same session token we would be in trouble:
• we could be granting higher privileges to one of the two compared to what they deserve;
• we risk exposing personal or confidential information, such as names, emails, past activity, etc.
We need a session store - the server must remember the tokens it has generated in order to authorize future
requests for logged-in users. We also want to associate information to each active session - this is known as
session state.
10.7.3.1 Postgres
Would Postgres be a viable session store?
We could create a new sessions table with the token as primary index - an easy way to ensure token unique-
ness. We have a few options for the session state:
• “classical” relational modelling, using a normalised schema (i.e. the way we approached storage of our
application state);
• a single state column holding a collection of key-value pairs, using the jsonb data type.
Unfortunately, there is no built-in mechanism for row expiration in Postgres. We would have to add a
expires_at column and trigger a cleanup job on a regular schedule to purge stale sessions - somewhat cum-
bersome.
10.7.3.2 Redis
Redis is another popular option when it comes to session storage.
Redis is an in-memory database - it uses RAM instead of disk for storage, trading off durability for speed.
It is great fit, in particular, for data that can be modelled as a collection of key-value pairs. It also provides
native support for expiration - we can attach a time-to-live to all values and Redis will take care of disposal.
How would it work for sessions?
Our application never manipulates sessions in bulk - we always work on a single session at a time, identified
using its token. Therefore, we can use the session token as key while the value is the JSON representation of
the session state - the application takes care of serialization/deserialization.
Sessions are meant to be short-lived - no reason to be concerned by the usage of RAM instead of disk for
persistence, the speed boost is a nice side effect!
As you might have guessed at this point, we will be using Redis as our session storage backend!
10.7.4 actix-session
actix-session provides session management for actix-web applications. Let’s add it to our dependencies:
#! Cargo.toml
# [...]
[dependencies]
# [...]
actix-session = "0.7"
The key type in actix-session is SessionMiddleware - it takes care of loading the session data, tracking
changes to the state and persisting them at the end of the request/response lifecycle.
To build an instance of SessionMiddleware we need to provide a storage backend and a secret key to sign
(or encrypt) the session cookie. The approach is quite similar to the one used by FlashMessagesFramework
in actix-web-flash-messages.
//! src/startup.rs
// [...]
use actix_session::SessionMiddleware;
fn run(
// [...]
) -> Result<Server, std::io::Error> {
// [...]
let secret_key = Key::from(hmac_secret.expose_secret().as_bytes());
let message_store = CookieMessageStore::builder(secret_key.clone()).build();
// [...]
let server = HttpServer::new(move || {
App::new()
.wrap(message_framework.clone())
.wrap(SessionMiddleware::new(todo!(), secret_key.clone()))
.wrap(TracingLogger::default())
// [...]
})
// [...]
}
actix-session is quite flexible when it comes to storage - you can provide your own by implementing the
SessionStore trait. It also offers some implementations out of the box, hidden behind a set of feature flags
10.7. SESSIONS 481
We can now access RedisSessionStore. To build one we will have to pass a Redis connection string as input
- let’s add redis_uri to our configuration struct:
//! src/configuration.rs
// [...]
#[derive(serde::Deserialize, Clone)]
pub struct Settings {
// [...]
// We have not created a stand-alone settings struct for Redis,
// let's see if we need more than the uri first!
// The URI is marked as secret because it may embed a password.
pub redis_uri: Secret<String>,
}
# configuration/base.yaml
# 6379 is Redis' default port
redis_uri: "redis://127.0.0.1:6379"
# [...]
impl Application {
// Async now! We also return anyhow::Error instead of std::io::Error
pub async fn build(configuration: Settings) -> Result<Self, anyhow::Error> {
// [...]
let server = run(
// [...]
configuration.redis_uri
).await?;
// [...]
}
}
482 CHAPTER 10. SECURING OUR API
//! src/main.rs
// [...]
#[tokio::main]
// anyhow::Result now instead of std::io::Error
async fn main() -> anyhow::Result<()> {
// [...]
}
//! src/routes/admin/dashboard.rs
use actix_web::HttpResponse;
HttpResponse::Ok().finish()
}
//! src/routes/mod.rs
// [...]
mod admin;
pub use admin::*;
//! src/startup.rs
use crate::routes::admin_dashboard;
// [...]
async fn run(/* */) -> Result<Server, anyhow::Error> {
// [...]
let server = HttpServer::new(move || {
App::new()
// [...]
.route("/admin/dashboard", web::get().to(admin_dashboard))
// [...]
})
// [...]
}
#[tokio::test]
async fn redirect_to_admin_dashboard_after_login_success() {
// Arrange
let app = spawn_app().await;
//! tests/api/helpers.rs
// [...]
impl TestApp {
// [...]
pub async fn get_admin_dashboard_html(&self) -> String {
self.api_client
.get(&format!("{}/admin/dashboard", &self.address))
.send()
.await
.expect("Failed to execute request.")
.text()
.await
.unwrap()
}
}
Getting past the first assertion is easy enough - we just need to change the Location header in the response
returned by POST /login:
//! src/routes/login/post.rs
// [...]
#[tracing::instrument(/* */)]
pub async fn login(/* */) -> Result</* */> {
// [...]
match validate_credentials(/* */).await {
Ok(/* */) => {
// [...]
486 CHAPTER 10. SECURING OUR API
Ok(HttpResponse::SeeOther()
.insert_header((LOCATION, "/admin/dashboard"))
.finish())
}
// [...]
}
}
10.7.5.2 Session
We need to identify the user once it lands on GET /admin/dashboard after following the redirect returned
by POST /login - this is a perfect usecase for sessions.
We will store the user identifier into the session state in login and then retrieve it from the session state in
admin_dashboard.
We need to become familiar with Session, the second key type from actix_session.
SessionMiddleware does all the heavy lifting of checking for a session cookie in incoming requests - if it
finds one, it loads the corresponding session state from the chosen storage backend. Otherwise, it creates a
new empty session state.
We can then use Session as an extractor to interact with that state in our request handlers.
Let’s see it in action in POST /login:
//! src/routes/login/post.rs
use actix_session::Session;
// [...]
#[tracing::instrument(
skip(form, pool, session),
// [...]
)]
pub async fn login(
// [...]
session: Session,
) -> Result</* */> {
// [...]
match validate_credentials(/* */).await {
Ok(user_id) => {
10.7. SESSIONS 487
// [...]
session.insert("user_id", user_id);
Ok(HttpResponse::SeeOther()
.insert_header((LOCATION, "/admin/dashboard"))
.finish())
}
// [...]
}
}
#! Cargo.toml
# [...]
[dependencies]
# We need to add the `serde` feature
uuid = { version = "1", features = ["v4", "serde"] }
You can think of Session as a handle on a HashMap - you can insert and retrieve values against String keys.
The values you pass in must be serializable - actix-session converts them into JSON behind the scenes.
That’s why we had to add the serde feature to our uuid dependency.
Serialisation implies the possibility of failure - if you run cargo check you will see that the compiler warns
us that we are not handling the Result returned by session.insert. Let’s take care of that:
//! src/routes/login/post.rs
// [...]
#[tracing::instrument(/* */)]
pub async fn login(/* */) -> Result<HttpResponse, InternalError<LoginError>> {
// [...]
match validate_credentials(/* */).await {
Ok(user_id) => {
// [...]
session
.insert("user_id", user_id)
.map_err(|e| login_redirect(LoginError::UnexpectedError(e.into())))?;
// [...]
}
Err(e) => {
let e = match e {
AuthError::InvalidCredentials(_) => LoginError::AuthError(e.into()),
AuthError::UnexpectedError(_) => {
LoginError::UnexpectedError(e.into())
},
};
Err(login_redirect(e))
}
488 CHAPTER 10. SECURING OUR API
}
}
If something goes wrong, the user will be redirected back to the /login page with an appropriate error
message.
Does it work though? Let’s try to get the user_id on the other side!
//! src/routes/admin/dashboard.rs
use actix_session::Session;
use actix_web::{web, HttpResponse};
use uuid::Uuid;
// Return an opaque 500 while preserving the error's root cause for logging.
fn e500<T>(e: T) -> actix_web::Error
where
T: std::fmt::Debug + std::fmt::Display + 'static
{
actix_web::error::ErrorInternalServerError(e)
}
todo!()
};
Ok(HttpResponse::Ok().finish())
}
When using Session::get we must specify what type we want to deserialize the session state entry into - a
Uuid in our case. Deserialization may fail, so we must handle the error case.
Now that we have the user_id, we can use it to fetch the username and return the “Welcome {username}!”
message we talked about before.
//! src/routes/admin/dashboard.rs
// [...]
use actix_web::http::header::ContentType;
use actix_web::web;
use anyhow::Context;
use sqlx::PgPool;
Stay there though, we are not finished yet - as it stands, our login flow is potentially vulnerable to session
fixation attacks.
Sessions can be used for more than authentication - e.g. to keep track of what items have been added to the
basket when shopping in “guest” mode. This implies that a user might be associated to an anonymous session
and, after they authenticate, to a privileged session. This can be leveraged by attackers.
Websites go to great lengths to prevent malicious actors from sniffing session tokens, leading to another at-
tack strategy - seed the user’s browser with a known session token before they log in, wait for authentication
to happen and, boom, you are in!
There is a simple countermeasure we can take to disrupt this attack - rotating the session token when the
user logs in.
This is such a common practice that you will find it supported in the session management API of all major
web frameworks - including actix-session, via Session::renew. Let’s add it in:
//! src/routes/login/post.rs
// [...]
#[tracing::instrument(/* */)]
pub async fn login(/* */) -> Result<HttpResponse, InternalError<LoginError>> {
// [...]
match validate_credentials(/* */).await {
Ok(user_id) => {
// [...]
session.renew();
10.7. SESSIONS 491
session
.insert("user_id", user_id)
.map_err(|e| login_redirect(LoginError::UnexpectedError(e.into())))?;
// [...]
}
// [...]
}
}
//! src/lib.rs
// [...]
pub mod session_state;
//! src/session_state.rs
use actix_session::{Session, SessionGetError, SessionInsertError};
use uuid::Uuid;
impl TypedSession {
const USER_ID_KEY: &'static str = "user_id";
pub fn renew(&self) {
self.0.renew();
}
#! Cargo.toml
# [...]
[dependencies]
serde_json = "1"
# [...]
It is just three lines long, but it does probably expose you to a few new Rust concepts/constructs. Take the
time you need to go line by line and properly understand what is happening - or, if you prefer, understand
the gist and come back later to deep dive!
//! src/routes/login/post.rs
// You can now remove the `Session` import
use crate::session_state::TypedSession;
// [...]
#[tracing::instrument(/* */)]
pub async fn login(
// [...]
// Changed from `Session` to `TypedSession`!
session: TypedSession,
) -> Result</* */> {
// [...]
match validate_credentials(/* */).await {
Ok(user_id) => {
// [...]
session.renew();
session
.insert_user_id(user_id)
.map_err(|e| login_redirect(LoginError::UnexpectedError(e.into())))?;
// [...]
}
// [...]
}
}
//! src/routes/admin/dashboard.rs
// You can now remove the `Session` import
use crate::session_state::TypedSession;
// [...]
If a user tries to navigate directly to /admin/dashboard and they are not logged in, they will be redir-
ected to the login form.
#[tokio::test]
async fn you_must_be_logged_in_to_access_the_admin_dashboard() {
// Arrange
let app = spawn_app().await;
// Act
let response = app.get_admin_dashboard().await;
// Assert
assert_is_redirect_to(&response, "/login");
}
//! tests/api/helpers.rs
//!
impl TestApp {
// [...]
pub async fn get_admin_dashboard(&self) -> reqwest::Response {
self.api_client
.get(&format!("{}/admin/dashboard", &self.address))
.send()
.await
.expect("Failed to execute request.")
}
// [...]
• a username;
• a PHC string.
Pick your favourite UUID generator to get a valid user id. We will use admin as username.
Getting a PHC string is a bit more cumbersome - we will use everythinghastostartsomewhere as a pass-
word, but how do we generate the corresponding PHC string?
We can cheat by leveraging the code we wrote in our test suite:
//! tests/api/helpers.rs
// [...]
impl TestUser {
pub fn generate() -> Self {
Self {
// [...]
// password: Uuid::new_v4().to_string(),
password: "everythinghastostartsomewhere".into(),
}
}
This is just a temporary edit - it is then enough to run cargo test -- --nocapture to get a well-formed
PHC string for our migration script. Revert the changes once you have it.
Run the migration and then launch your application with cargo run - you should finally be able to log in
successfully!
If everything works as expected, a “Welcome admin!” message should greet you at /admin/dashboard. Con-
grats!
//! src/routes/admin/password/mod.rs
mod get;
pub use get::change_password_form;
mod post;
pub use post::change_password;
//! src/routes/admin/password/get.rs
use actix_web::http::header::ContentType;
use actix_web::HttpResponse;
<input
type="password"
placeholder="Enter current password"
name="current_password"
>
</label>
<br>
<label>New password
<input
type="password"
placeholder="Enter new password"
name="new_password"
>
</label>
<br>
<label>Confirm new password
<input
type="password"
placeholder="Type the new password again"
name="new_password_check"
>
</label>
<br>
<button type="submit">Change password</button>
</form>
<p><a href="/admin/dashboard"><- Back</a></p>
</body>
</html>"#,
))
}
//! src/routes/admin/password/post.rs
use actix_web::{HttpResponse, web};
use secrecy::Secret;
#[derive(serde::Deserialize)]
pub struct FormData {
current_password: Secret<String>,
new_password: Secret<String>,
new_password_check: Secret<String>,
}
//! src/startup.rs
use crate::routes::{change_password, change_password_form};
// [...]
Just like the admin dashboard itself, we do not want to show the change password form to users who are not
logged in. Let’s add two integration tests:
//! tests/api/main.rs
mod change_password;
// [...]
//! tests/api/helpers.rs
// [...]
impl TestApp {
// [...]
pub async fn get_change_password(&self) -> reqwest::Response {
self.api_client
.get(&format!("{}/admin/password", &self.address))
.send()
.await
.expect("Failed to execute request.")
}
self.api_client
.post(&format!("{}/admin/password", &self.address))
.form(body)
.send()
.await
.expect("Failed to execute request.")
}
}
//! tests/api/change_password.rs
use crate::helpers::{spawn_app, assert_is_redirect_to};
use uuid::Uuid;
#[tokio::test]
async fn you_must_be_logged_in_to_see_the_change_password_form() {
// Arrange
let app = spawn_app().await;
// Act
let response = app.get_change_password().await;
// Assert
assert_is_redirect_to(&response, "/login");
}
#[tokio::test]
async fn you_must_be_logged_in_to_change_your_password() {
// Arrange
let app = spawn_app().await;
let new_password = Uuid::new_v4().to_string();
// Act
let response = app
.post_change_password(&serde_json::json!({
"current_password": Uuid::new_v4().to_string(),
"new_password": &new_password,
"new_password_check": &new_password,
}))
.await;
// Assert
assert_is_redirect_to(&response, "/login");
}
10.8. SEED USERS 501
We can then satisfy the requirements by adding a check in the request handlers26 :
//! src/routes/admin/password/get.rs
use crate::session_state::TypedSession;
use crate::utils::{e500, see_other};
// [...]
//! src/routes/admin/password/post.rs
use crate::session_state::TypedSession;
use crate::utils::{e500, see_other};
// [...]
//! src/utils.rs
use actix_web::HttpResponse;
use actix_web::http::header::LOCATION;
// Return an opaque 500 while preserving the error root's cause for logging.
pub fn e500<T>(e: T) -> actix_web::Error
where
T: std::fmt::Debug + std::fmt::Display + 'static,
{
actix_web::error::ErrorInternalServerError(e)
26
An alternative approach, to spare us the repetition, is to create a middleware that wraps all the endpoints nested under the
/admin/ prefix. The middleware checks the session state and redirects the visitor to /login if they are not logged in. If you like
a challenge, give it a try! Beware though: actix-web’s middlewares can be tricky to implement due to the lack of async syntax in
traits.
502 CHAPTER 10. SECURING OUR API
//! src/lib.rs
// [...]
pub mod utils;
//! src/routes/admin/dashboard.rs
// The definition of e500 has been moved to src/utils.rs
use crate::utils::e500;
// [...]
We do not want the change password form to be an orphan page either - let’s add a list of available actions to
our admin dashboard, with a link to our new page:
//! src/routes/admin/dashboard.rs
// [...]
We have taken care of all the preliminary steps, it is time to start working on the core functionality.
Let’s start with an unhappy case - we asked the user to write the new password twice and the two entries do
not match. We expect to be redirected back to the form with an appropriate error message.
//! tests/api/change_password.rs
// [...]
#[tokio::test]
async fn new_password_fields_must_match() {
// Arrange
let app = spawn_app().await;
let new_password = Uuid::new_v4().to_string();
let another_new_password = Uuid::new_v4().to_string();
//! tests/api/helpers.rs
// [...]
impl TestApp {
504 CHAPTER 10. SECURING OUR API
// [...]
The test fails because the request handler panics. Let’s fix it:
//! src/routes/admin/password/post.rs
use secrecy::ExposeSecret;
// [...]
That takes care of the redirect, the first part of the test, but it does not handle the error message:
---- change_password::new_password_fields_must_match stdout ----
thread 'change_password::new_password_fields_must_match' panicked at
'assertion failed: html_page.contains(...)',
We have gone through this journey before for the login form - we can use a flash message again!
//! src/routes/admin/password/post.rs
// [...]
use actix_web_flash_messages::FlashMessage;
//! src/routes/admin/password/get.rs
// [...]
use actix_web_flash_messages::IncomingFlashMessages;
use std::fmt::Write;
Ok(HttpResponse::Ok()
.content_type(ContentType::html())
.body(format!(
r#"<!-- [...] -->
<body>
{msg_html}
<!-- [...] -->
</body>
</html>"#,
)))
}
#[tokio::test]
async fn current_password_must_be_valid() {
506 CHAPTER 10. SECURING OUR API
// Arrange
let app = spawn_app().await;
let new_password = Uuid::new_v4().to_string();
let wrong_password = Uuid::new_v4().to_string();
// Assert
assert_is_redirect_to(&response, "/admin/password");
To validate the value passed as current_password we need to retrieve the username and then invoke the
validate_credentials routine, the one powering our login form.
Let’s start with the username:
//! src/routes/admin/password/post.rs
use crate::routes::admin::dashboard::get_username;
use sqlx::PgPool;
// [...]
if form.new_password.expose_secret() != form.new_password_check.expose_secret() {
// [...]
}
let username = get_username(user_id, &pool).await.map_err(e500)?;
// [...]
todo!()
}
//! src/routes/admin/dashboard.rs
// [...]
#[tracing::instrument(/* */)]
// Marked as `pub`!
pub async fn get_username(/* */) -> Result</* */> {
// [...]
}
We can now pass the username and password combination to validate_credentials - if the validation fails,
we need to take different actions depending on the returned error:
//! src/routes/admin/password/post.rs
// [...]
use crate::authentication::{validate_credentials, AuthError, Credentials};
}
todo!()
}
10.8.2.5 Logout
It is finally time to look at the happy path - a user successfully changing their password.
We will use the following scenario to check that everything behaves as expected:
• Log in;
• Change password by submitting the change password form;
• Log out;
• Log in again using the new password.
There is just one roadblock left - we do not have a log-out endpoint yet!
Let’s work to bridge this functionality gap before moving forward.
Let’s start by encoding our requirements in a test:
//! tests/api/admin_dashboard.rs
// [...]
#[tokio::test]
async fn logout_clears_session_state() {
// Arrange
let app = spawn_app().await;
//! tests/api/helpers.rs
// [...]
impl TestApp {
// [...]
A log-out is a state-alerting operation: we need to use the POST method via a HTML button:
//! src/routes/admin/dashboard.rs
// [...]
<li>
<form name="logoutForm" action="/admin/logout" method="post">
<input type="submit" value="Logout">
</form>
</li>
</ol>
<!-- [...] -->"#,
)))
}
//! src/session_state.rs
// [...]
impl TypedSession {
// [...]
pub fn log_out(self) {
self.0.purge()
}
}
//! src/routes/admin/logout.rs
use crate::session_state::TypedSession;
use crate::utils::{e500, see_other};
use actix_web::HttpResponse;
use actix_web_flash_messages::FlashMessage;
//! src/routes/login/get.rs
// [...]
pub async fn login_form(/* */) -> HttpResponse {
// [...]
// Display all messages levels, not just errors!
for m in flash_messages.iter() {
// [...]
}
// [...]
}
//! src/routes/admin/mod.rs
// [...]
mod logout;
pub use logout::log_out;
//! src/startup.rs
use crate::routes::log_out;
// [...]
#[tokio::test]
async fn changing_password_works() {
// Arrange
let app = spawn_app().await;
let new_password = Uuid::new_v4().to_string();
This is the most complex user scenario we have written so far - a grand total of six steps. This is far from
being a record - enterprise applications often require tens of steps to execute real world business processes.
It takes a lot of work to keep the test suite readable and maintainable in those scenarios.
The test currently fails at the third step - POST /admin/password panics because we left a todo!() invoca-
tion after the preliminary input validation steps. To implement the required functionality we will need to
compute the hash of the new password and then store it in the database - we can add a new dedicated routine
to our authentication module:
//! src/authentication.rs
use argon2::password_hash::SaltString;
use argon2::{
Algorithm, Argon2, Params, PasswordHash,
PasswordHasher, PasswordVerifier, Version
};
// [...]
fn compute_password_hash(
password: Secret<String>
) -> Result<Secret<String>, anyhow::Error> {
let salt = SaltString::generate(&mut rand::thread_rng());
let password_hash = Argon2::new(
Algorithm::Argon2id,
Version::V0x13,
Params::new(15000, 2, 1, None).unwrap(),
)
.hash_password(password.expose_secret().as_bytes(), &salt)?
.to_string();
Ok(Secret::new(password_hash))
}
For Argon2 we used the parameters recommended by OWASP, the same ones we were already using in our
test suite.
We can now plug this function into the request handler:
//! src/routes/admin/password/post.rs
// [...]
pub async fn change_password(/* */) -> Result</* */> {
// [...]
crate::authentication::change_password(user_id, form.0.new_password, &pool)
.await
.map_err(e500)?;
FlashMessage::error("Your password has been changed.").send();
Ok(see_other("/admin/password"))
}
10.9 Refactoring
We have added many new endpoints that are restricted to authenticated users. For the sake of speed, we have
copy-pasted the same authentication logic across multiple request handlers - it is a good idea to take a step
back and try to figure out if we can come up with a better solution.
Let’s look at POST /admin/passwords as an example. We currently have:
//! src/routes/admin/password/post.rs
// [...]
return Ok(see_other("/login"));
};
let user_id = user_id.unwrap();
// [...]
}
async fn reject_anonymous_users(
session: TypedSession
) -> Result<Uuid, actix_web::Error> {
match session.get_user_id().map_err(e500)? {
Some(user_id) => Ok(user_id),
None => {
let response = see_other("/login");
let e = anyhow::anyhow!("The user has not logged in");
Err(InternalError::from_response(e, response).into())
}
}
}
Notice how we moved the redirect response on the error path in order to use the ? operator in our request
handler.
We could now go and refactor all other /admin/* routes to leverage reject_anonymous_users. Or, if you
are feeling adventurous, we could try writing a middleware to handle this for us - let’s do it!
Our needs are quite simple, we can get away with less: actix_web_lab::from_fn.
actix_web_lab is a crate used to experiment with future additions to the actix_web framework, with a faster
release policy. Let’s add it to our dependencies:
516 CHAPTER 10. SECURING OUR API
#! Cargo.toml
# [...]
[dependencies]
actix-web-lab = "0.18"
# [...]
from_fn takes an asynchronous function as argument and returns an actix-web middleware as output. The
asynchronous function must have the following signature and structure:
use actix_web_lab::middleware::Next;
use actix_web::body::MessageBody;
use actix_web::dev::{ServiceRequest, ServiceResponse};
async fn my_middleware(
req: ServiceRequest,
next: Next<impl MessageBody>,
) -> Result<ServiceResponse<impl MessageBody>, Error> {
// before the handler is invoked
// Invoke handler
let response = next.call(req).await;
Let’s adapt reject_anonymous_users to follow those requirements - it will live in our authentication mod-
ule.
//! src/authentication/mod.rs
mod middleware;
mod password;
pub use password::{
change_password, validate_credentials,
AuthError, Credentials
};
pub use middleware::reject_anonymous_users;
//! src/authentication/password.rs
// Copy over **everything** from the old src/authentication.rs
To start out, we need to get our hands on a TypedSession instance. ServiceRequest is nothing more than
a wrapper around HttpRequest and Payload, therefore we can leverage our existing implementation of
FromRequest:
//! src/authentication/middleware.rs
use actix_web_lab::middleware::Next;
use actix_web::body::MessageBody;
use actix_web::dev::{ServiceRequest, ServiceResponse};
use actix_web::FromRequest;
use crate::session_state::TypedSession;
Now that we have the session handler, we can check if the session state contains a user id:
//! src/authentication/middleware.rs
use actix_web::error::InternalError;
use crate::utils::{e500, see_other};
// [...]
}?;
match session.get_user_id().map_err(e500)? {
Some(_) => next.call(req).await,
None => {
let response = see_other("/login");
let e = anyhow::anyhow!("The user has not logged in");
Err(InternalError::from_response(e, response).into())
}
}
}
This, as it stands, is already useful - it can be leveraged to protect endpoints that require authentication.
At the same time, it isn’t equivalent to what we had before - how are we going to access the retrieved user id
in our endpoints?
This is a common issue when working with middlewares that extract information out of incoming requests
- it is solved via request extensions.
The middleware inserts the information it wants to pass to downstream request handlers into the type map
attached to the incoming request (request.extensions_mut()).
Request handlers can then access it using the ReqData extractor.
//! src/authentication/middleware.rs
use uuid::Uuid;
use std::ops::Deref;
use actix_web::HttpMessage;
// [...]
If you run the test suite, you’ll be greeted by several failures. If you inspect the logs for one of them, you’ll
find the following error:
It makes sense - we never registered our middleware against our App instance, therefore the insertion of
UserId into the request extensions never takes place.
Let’s fix it.
Our routing table currently looks like this:
//! src/startup.rs
// [...]
We want to apply our middleware logic exclusively to /admin/* endpoints, but calling wrap on App would
apply the middleware to all our routes.
Considering that our target endpoints all share the same common base path, we can achieve our objective
by introducing a scope:
//! src/startup.rs
// [...]
10.9. REFACTORING 521
We can now add a middleware restricted to /admin/* by calling wrap on web::scope("admin") instead of
the top-level App:
//! src/startup.rs
use crate::authentication::reject_anonymous_users;
use actix_web_lab::middleware::from_fn;
// [...]
If you run the test suite, it should pass (apart from our idempotency test).
You can now go through the other /admin/* endpoints and remove the duplicated check-if-logged-in-or-
redirect code.
10.10 Summary
Take a deep breath - we covered a lot of ground in this chapter.
We built, from scratch, a large chunk of the machinery that powers authentication in most of the software
you interact with on a daily basis.
API security is an amazingly broad topic - we explored together a selection of key techniques, but this intro-
duction is in no way exhaustive. There are entire areas that we just mentioned but did not have a chance
to cover in depth (e.g. OAuth2/OpenID Connect). Look at the bright side - you learned enough to go and
tackle those topics on your own should your applications require them.
It is easy to forget the bigger picture when you spend a lot of time working close to the details - why did we
even start to talk about API security?
That’s right! We had just built a new endpoint to send out newsletter issues and we did not want to give
everyone on the Internet a chance to broadcast content to our audience. We added ‘Basic’ authentication to
POST /newsletters early in the chapter but we have not yet ported it over to session-based authentication.
On GitHub you can find a project snapshot before and after fulfilling the exercise requirements. The
next chapter assumes that the exercise has been completed - make sure to double-check your solution
before moving forward!
POST /admin/newsletters will be under the spotlight during the next chapter - we will be reviewing our
initial implementation under a microscope to understand how it behaves when things break down. It will
give us a chance to talk more broadly about fault tolerance, scalability and asynchronous processing.
524 CHAPTER 10. SECURING OUR API
Chapter 11
Fault-tolerant Workflows
We kept the first iteration of our newsletter endpoint very simple: emails are immediately sent out to all
subscribers via Postmark, one API call at a time.
This is good enough if the audience is small - it breaks down, in a variety of ways, when dealing with hundreds
of subscribers.
We want our application to be fault-tolerant.
Newsletter delivery should not be disrupted by transient failures like application crashes, Postmark API
errors or network timeouts. To deliver a reliable service in the face of failure we will have to explore new
concepts: idempotency, locking, queues and background jobs.
#[derive(serde::Deserialize)]
pub struct FormData {
title: String,
text_content: String,
html_content: String,
}
1
At the end of chapter 10 you were asked to convert POST /newsletters (JSON + ‘Basic’ auth) into POST /admin/newsletters
(HTML Form data + session-based auth) as a take-home exercise. Your implementation might differ slightly from mine, therefore
the code blocks here might not match exactly what you see in your IDE. Check the book’s GitHub repository to compare solutions.
525
526 CHAPTER 11. FAULT-TOLERANT WORKFLOWS
#[tracing::instrument(/* */)]
pub async fn publish_newsletter(
form: web::Form<FormData>,
pool: web::Data<PgPool>,
email_client: web::Data<EmailClient>,
) -> Result<HttpResponse, actix_web::Error> {
// [...]
}
#[tracing::instrument(/* */)]
pub async fn publish_newsletter(/* */) -> Result<HttpResponse, actix_web::Error> {
// [...]
let subscribers = get_confirmed_subscribers(&pool).await.map_err(e500)?;
// [...]
}
struct ConfirmedSubscriber {
email: SubscriberEmail,
}
#[tracing::instrument(/* */)]
async fn get_confirmed_subscribers(
pool: &PgPool,
) -> Result<Vec<Result<ConfirmedSubscriber, anyhow::Error>>, anyhow::Error> {
/* */
}
#[tracing::instrument(/* */)]
pub async fn publish_newsletter(/* */) -> Result<HttpResponse, actix_web::Error> {
// [...]
let subscribers = get_confirmed_subscribers(&pool).await.map_err(e500)?;
for subscriber in subscribers {
match subscriber {
11.2. OUR GOAL 527
Ok(subscriber) => {
email_client
.send_email(/* */)
.await
.with_context(/* */)
.map_err(e500)?;
}
Err(error) => {
tracing::warn!(/* */);
}
}
}
FlashMessage::info("The newsletter issue has been published!").send();
Ok(see_other("/admin/newsletters"))
}
Once all subscribers have been taken care of, we redirect the author back to the newsletter form - they will
be shown a flash message confirming that the issue was published successfully.
11.3.2.1 Postgres
The database might misbehave when we try to retrieve the current list of subscribers. We do not have a lot
of options apart from retrying. We can:
• retry in process, by adding some logic around the get_confirmed_subscribers call;
• give up by returning an error to the user. The user can then decide if they want to retry or not.
The first option makes our application more resilient to spurious failures. Nonetheless, you can only per-
form a finite number of retries; you will have to give up eventually.
Our implementation opts for the second strategy from the get-go. It might result in a few more 500s, but it
is not incompatible with our over-arching objective.
An API endpoint is retry-safe (or idempotent) if the caller has no way to observe if a request has
been sent to the server once or multiple times.
We will probe and explore this definition for a few sections: it is important to fully understand its ramifica-
tions.
If you have been in the industry long enough, you have probably heard another term used to describe the
concept of retry-safety: idempotency. They are mostly used as synonyms - we will use idempotency go-
ing forward, mostly to align with other industry terminology that will be relevant to our implementation
(i.e. idempotency keys).
4
Client-side JavaScript can be used to disable buttons after they have been clicked, reducing the likelihood of this scenario.
530 CHAPTER 11. FAULT-TOLERANT WORKFLOWS
POST /payments, in particular, takes as input the beneficiary details and the payment amount. An API call
triggers a money transfer from your account to the specified beneficiary; your balance is reduced accordingly
(i.e. new_balance = old_balance - payment_amount).
Let’s consider this scenario: your balance is 400 USD and you send a request to transfer 20 USD. The request
succeeds: the API returned a 200 OK5 , your balance was updated to 380 USD and the beneficiary received
20 USD.
You then retry the same request - e.g. you click twice on the Pay now button.
What should happen if POST /payments is idempotent?
Our idempotency definition is built around the concept of observability - properties of the system state that
the caller can inspect by interacting with the system itself.
For example: you could easily determine that the second call is a retry by going through the logs emitted by
the API. But the caller is not an operator - they have no way to inspect those logs. They are invisible to the
users of the API - in so far as idempotency is concerned, they don’t exist. They are not part of the domain
model exposed and manipulated by the API.
The domain model in our example includes:
• the caller’s account, with its balance (via GET /balance) and payment history (via GET /payments);
• other accounts6 reachable over the payment network (i.e. beneficiaries we can pay).
Given the above, we can say that POST /payments is idempotent if, when the request is retried,
Considering our use case (processing forms), we will go for the second strategy in order to minimize the
number of user-visible errors - browsers do not automatically retry 409s.
#[tokio::test]
async fn newsletter_creation_is_idempotent() {
// Arrange
let app = spawn_app().await;
create_confirmed_subscriber(&app).await;
app.test_user.login(&app).await;
Mock::given(path("/email"))
.and(method("POST"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&app.email_server)
.await;
// Mock verifies on Drop that we have sent the newsletter email **once**
}
The retry succeeded, but it resulted in the newsletter being delivered twice to our subscriber - the problematic
behaviour we identified during the failure analysis at the very beginning of this chapter.
to send an email we make sure to pass along the subscriber-specific idempotency key.
When a retry comes in, we execute the same processing logic - this leads to the same sequence of HTTP
calls to Postmark, using exactly the same idempotency keys. Assuming their idempotency implementation
is sound, no new email is going to be dispatched.
for the subscriber-specific idempotency key - it ensures a unique outcome for each subscriber-newsletter issue pair. Alternatively,
we must implement a likeness check to ensure that the same idempotency key cannot be used for two different requests to POST
/admin/newsletters - i.e. the idempotency key is enough to ensure that the newsletter content is not the same.
11
This is equivalent to a non-repeatable read in a relational database.
11.7. IDEMPOTENCY STORE 535
We do not want to store idempotency keys forever - it would be impractical and wasteful.
We also do not want actions performed by a user A to influence the outcome of actions performed by user
B - there is a concrete security risk (cross-user data leakage) if proper isolation is not enforced.
Storing idempotency keys and responses into the session state of the user would guarantee both isolation
and expiry out of the box. At the same time, it doesn’t feel right to tie the lifespan of idempotency keys to
the lifespan of the corresponding user sessions.
Based on our current requirements, Redis looks like the best solution to store our (user_id,
idempotency_key, http_response) triplets. They would have their own time-to-live policy, with no ties
to session states, and Redis would take care of cleaning old entries for us.
Unfortunately, new requirements will soon emerge and turn Redis into a limiting choice. There is not much
to learn by taking the wrong turn here, so I’ll cheat and force our hand towards Postgres.
Spoiler: we will leverage the possibility of modifying the idempotency triplets and our application state
within a single SQL transaction.
11.7.2 Schema
We need to define a new table to store the following information:
• user id;
• idempotency key;
• HTTP response.
The user id and the idempotency key can be used as a composite primary key. We should also record when
each row was created in order to evict old idempotency keys.
There is a major unknown though: what type should be used to store HTTP responses?
We could treat the whole HTTP response as a blob of bytes, using bytea as column type.
Unfortunately, it’d be tricky to re-hydrate the bytes into an HttpResponse object - actix-web does not
provide any serialization/deserialization implementation for HttpResponse.
We are going to write our own (de)serialisation code - we will work with the core components of an HTTP
response:
• status code;
• headers;
• body.
We are not going to store the HTTP version - the assumption is that we are working exclusively with
HTTP/1.1.
We can use smallint for the status code - it’s maximum value is 32767, which is more than enough. bytea
will do for the body.
What about headers? What is their type?
We can have multiple header values associated to the same header name, therefore it makes sense to represent
them as an array of (name, value) pairs.
We can use TEXT for the name (see http’s implementation) while value will require BYTEA because it allows
opaque octets (see http’s test cases).
536 CHAPTER 11. FAULT-TOLERANT WORKFLOWS
Postgres does not support arrays of tuples, but there is a workaround: we can define a Postgres composite
type - i.e. a named collection of fields, the equivalent of a struct in our Rust code.
CREATE TYPE header_pair AS (
name TEXT,
value BYTEA
);
-- migrations/20220211080603_create_idempotency_table.sql
CREATE TYPE header_pair AS (
name TEXT,
value BYTEA
);
We could have defined an overall http_response composite type, but we would have run into a bug in sqlx
which is in turn caused by a bug in the Rust compiler. Best to avoid nested composite types for the time
being.
#[derive(serde::Deserialize)]
11.8. SAVE AND REPLAY 537
We do not care about the exact format of the idempotency key, as long as it’s not empty and it’s reasonably
long.
Let’s define a new type to enforce minimal validation:
//! src/lib.rs
// [...]
// New module!
pub mod idempotency;
//! src/idempotency/mod.rs
mod key;
pub use key::IdempotencyKey;
//! src/idempotency/key.rs
#[derive(Debug)]
pub struct IdempotencyKey(String);
k.0
}
}
//! src/routes/admin/newsletter/post.rs
use crate::idempotency::IdempotencyKey;
use crate::utils::e400;
// [...]
&text_content
)
// [...]
}
// [...]
}
}
// [...]
}
thread 'newsletter::newsletters_are_not_delivered_to_unconfirmed_subscribers'
panicked at 'assertion failed: `(left == right)`
left: `400`,
right: `303`'
thread 'newsletter::newsletters_are_delivered_to_confirmed_subscribers'
panicked at 'assertion failed: `(left == right)`
left: `400`,
right: `303`'
Our test requests are being rejected because they do not include an idempotency key.
Let’s update them:
//! tests/api/newsletter.rs
// [...]
#[tokio::test]
async fn newsletters_are_not_delivered_to_unconfirmed_subscribers() {
// [...]
let newsletter_request_body = serde_json::json!({
// [...]
"idempotency_key": uuid::Uuid::new_v4().to_string()
});
}
#[tokio::test]
async fn newsletters_are_delivered_to_confirmed_subscribers() {
540 CHAPTER 11. FAULT-TOLERANT WORKFLOWS
// [...]
let newsletter_request_body = serde_json::json!({
// [...]
"idempotency_key": uuid::Uuid::new_v4().to_string()
});
}
#[tokio::test]
async fn you_must_be_logged_in_to_publish_a_newsletter() {
// [...]
let newsletter_request_body = serde_json::json!({
// [...]
"idempotency_key": uuid::Uuid::new_v4().to_string()
});
// [...]
}
We also need to update GET /admin/newsletters to embed a randomly-generated idempotency key in the
HTML form:
//! src/routes/admin/newsletter/get.rs
// [...]
//! src/idempotency/persistence.rs
use super::IdempotencyKey;
use actix_web::HttpResponse;
use sqlx::PgPool;
use uuid::Uuid;
There is a caveat - sqlx does not know how to handle our custom header_pair type:
error: unsupported type _header_pair of column #2 ("response_headers")
|
| let saved_response = sqlx::query!(
542 CHAPTER 11. FAULT-TOLERANT WORKFLOWS
| __________________________^
| | r#"
| | SELECT
.. |
| | idempotency_key.as_ref()
| | )
| |_____^
It might not be supported out of the box, but there is a mechanism for us to specify how it should be handled
- the Type, Decode and Encode traits.
Luckily enough, we do not have to implement them manually - we can derive them with a macro!
We just need to specify the type fields and the name of the composite type as it appears in Postgres; the macro
should take care of the rest:
//! src/idempotency/persistence.rs
// [...]
#[derive(Debug, sqlx::Type)]
#[sqlx(type_name = "header_pair")]
struct HeaderPairRecord {
name: String,
value: Vec<u8>,
}
It turns out that sqlx::query! does not handle custom type automatically - we need to explain how we
want the custom column to be handled by using an explicit type annotation.
The query becomes:
//! src/idempotency/persistence.rs
// [...]
11.8. SAVE AND REPLAY 543
At last, it compiles!
Let’s map the retrieved data back into a proper HttpResponse:
//! src/idempotency/persistence.rs
use actix_web::http::StatusCode;
// [...]
//! src/routes/admin/newsletter/post.rs
// [...]
use crate::idempotency::get_saved_response;
//! src/idempotency/persistence.rs
// [...]
11.8. SAVE AND REPLAY 545
We need to break HttpResponse into its separate components before we write the INSERT query.
We can use .status() for the status code, .headers() for the headers… what about the body?
There is a .body() method - this is its signature:
/// Returns a reference to this response's body.
pub fn body(&self) -> &B {
self.res.body()
}
What is B? We must include the impl block definition into the picture to grasp it:
impl<B> HttpResponse<B> {
/// Returns a reference to this response's body.
pub fn body(&self) -> &B {
self.res.body()
}
}
Well, well, it turns out that HttpResponse is generic over the body type!
But, you may ask, “we have been using HttpResponse for 400 pages without specifying any generic para-
meter, what’s going on?”
We have always worked with responses that were fully formed on the server before being sent back to the caller.
HTTP/1.1 supports another mechanism to transfer data - Transfer-Encoding: chunked, also known as
HTTP streaming.
The server breaks down the payload into multiple chunks and sends them over to the caller one at a time in-
546 CHAPTER 11. FAULT-TOLERANT WORKFLOWS
stead of accumulating the entire body in memory first. It allows the server to significantly reduce its memory
usage. It is quite useful when working on large payloads such as files or results from a large query (streaming
all the way through!).
With HTTP streaming in mind, it becomes easier to understand the design of MessageBody, the trait that
must be implemented to use a type as body in actix-web:
pub trait MessageBody {
type Error: Into<Box<dyn Error + 'static, Global>>;
fn size(&self) -> BodySize;
fn poll_next(
self: Pin<&mut Self>,
cx: &mut Context<'_>
) -> Poll<Option<Result<Bytes, Self::Error>>>;
// [...]
}
You pull data, one chunk at a time, until you have fetched it all.
When the response is not being streamed, the data is available all at once - poll_next returns it all in one go.
Let’s try to understand BoxBody, the default body type used by HttpResponse. The body type we have been
using for several chapters, unknowingly!
BoxBody abstracts away the specific payload delivery mechanism. Under the hood, it is nothing more than
an enum with a variant for each strategy, with a special case catering for body-less responses:
#[derive(Debug)]
pub struct BoxBody(BoxBodyInner);
enum BoxBodyInner {
None(body::None),
Bytes(Bytes),
Stream(Pin<Box<dyn MessageBody<Error = Box<dyn StdError>>>>),
}
It worked for so long because we did not really care about the way the response was being sent back to the
caller.
Implementing save_response forces us to look closer - we need to collect the response in memory12 in order
to save it in the idempotency table of our database.
actix-web has a dedicated function for situation like ours: to_bytes.
It calls poll_next until there is no more data to fetch, than it returns the entire response back to us inside a
Bytes container13 .
I’d normally advise for caution when talking about to_bytes - if you are dealing with huge payloads, there
is a risk of putting the server under significant memory pressure.
12
We technically have another option: stream the response body directly to the database and then stream it back from the database
directly to the caller.
13
You can think of Bytes as a Vec<u8> with extra perks - check out the documentation of the bytes crate for more details.
11.8. SAVE AND REPLAY 547
This is not our case - all our response bodies are small and don’t actually take advantage of HTTP streaming,
so to_bytes will not actually do any work.
BoxBody implements MessageBody, but &BoxBody doesn’t - and .body() returns a reference, it does not give
us ownership over the body.
Why do we need ownership? It’s because of HTTP streaming, once again!
548 CHAPTER 11. FAULT-TOLERANT WORKFLOWS
Pulling a chunk of data from the payload stream requires a mutable reference to the stream itself - once the
chunk has been read, there is no way to “replay” the stream and read it again.
.into_parts() requires ownership of HttpResponse - we’ll have to change the signature of save_response
to accommodate it. Instead of asking for a reference, we’ll take ownership of the response and then return
another owned HttpResponse in case of success.
| | user_id,
.. |
| | body.as_ref()
| | )
| |_____^
It does make sense - we are using a custom type and sqlx::query! is not powerful enough to learn about
it at compile-time in order to check our query. We will have to disable compile-time verification - use
query_unchecked! instead of query!:
//! src/idempotency/persistence.rs
// [...]
sqlx knows, via our #[sqlx(type_name = "header_pair")] attribute, the name of the composite type
itself. It does not know the name of the type for arrays containing header_pair elements.
Postgres creates an array type implicitly when we run a CREATE TYPE statement - it is simply the composite
type name prefixed by an underscore14 .
We can provide this information to sqlx by implementing the PgHasArrayType trait, just like the compiler
suggested:
14
If the type name ends up being too long, some truncation takes place as well.
11.9. CONCURRENT REQUESTS 551
//! src/idempotency/persistence.rs
use sqlx::postgres::PgHasArrayType;
// [...]
11.8.3.3 Plug It In
It’s a milestone, but it is a bit early to cheer - we don’t know if it works yet. Our integration test is still red.
Let’s plug save_response into our request handler:
//! src/routes/admin/newsletter/post.rs
use crate::idempotency::save_response;
// [...]
#[tokio::test]
async fn concurrent_form_submission_is_handled_gracefully() {
// Arrange
let app = spawn_app().await;
create_confirmed_subscriber(&app).await;
app.test_user.login(&app).await;
Mock::given(path("/email"))
.and(method("POST"))
// Setting a long delay to ensure that the second request
// arrives before the first one completes
.respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(2)))
.expect(1)
.mount(&app.email_server)
.await;
assert_eq!(response1.status(), response2.status());
assert_eq!(response1.text().await.unwrap(), response2.text().await.unwrap());
// Mock verifies on Drop that we have sent the newsletter email **once**
}
The test fails - our server returned a 500 Internal Server Error to one of the two requests:
thread 'newsletter::concurrent_form_submission_is_handled_gracefully'
panicked at 'assertion failed: `(left == right)`
left: `303`,
11.9. CONCURRENT REQUESTS 553
right: `500`'
Caused by:
duplicate key value violates unique constraint "idempotency_pkey"
The slowest request fails to insert into the idempotency table due to our uniqueness constraint.
The error response is not the only issue: both requests executed the email dispatch code (otherwise we
wouldn’t have seen the constraint violation!), resulting into duplicate delivery.
11.9.2 Synchronization
The second request is not aware of the first until it tries to insert into the database.
If we want to prevent duplicate delivery, we need to introduce cross-request synchronization before we
start processing subscribers.
In-memory locks (e.g. tokio::sync::Mutex) would work if all incoming requests were being served by a
single API instance. This is not our case: our API is replicated, therefore the two requests might end up
being processed by two different instances.
Our synchronization mechanism will have to live out-of-process - our database being the natural candidate.
Let’s think about it: we have an idempotency table, it contains one row for each unique combination of user
id and idempotency key. Can we do something with it?
Our current implementation inserts a row into the idempotency table after processing the request, just be-
fore returning the response to the caller. We are going to change that: we will insert a new row as soon as the
handler is invoked.
We don’t know the final response at that point - we haven’t started processing yet! We must relax the NOT
NULL constraints on some of the columns:
We can now insert a row as soon as the handler gets invoked using the information we have up to that point
- the user id and the idempotency key, our composite primary key.
The first request will succeed in inserting a row into idempotency. The second request, instead, will fail due
554 CHAPTER 11. FAULT-TOLERANT WORKFLOWS
We will use ON CONFLICT DO NOTHING - if no new row was inserted, we will try to fetch the saved response.
Before we start implementing, there is an issue we need to solve: our code no longer compiles. Our code
has not been updated to deal with the fact that a few columns in idempotency are now nullable. We must
update the query to ask sqlx to forcefully assume that the columns will not be null - if we are wrong, it will
cause an error at runtime.
The syntax is similar to the type casting syntax we used previously to deal with header pairs - we must append
a ! to the column alias name:
//! src/idempotency/persistence.rs
// [...]
Let’s now define the skeleton of a new function, the one we will invoke at the beginning of our request
handler - try_processing.
It will try to perform the insertion we just discussed - if it fails because a row already exists, we will assume
that a response has been saved and try to return it.
//! src/idempotency/mod.rs
// [...]
11.9. CONCURRENT REQUESTS 555
//! src/idempotency/persistence.rs
// [...]
//! src/routes/admin/newsletter/post.rs
use crate::idempotency::{try_processing, NextAction};
// [...]
// [...]
#[allow(clippy::large_enum_variant)]
pub enum NextAction {
// Return transaction for later usage
StartProcessing(Transaction<'static, Postgres>),
// [...]
}
//! src/routes/admin/newsletter/post.rs
// [...]
.map_err(e500)?
{
NextAction::StartProcessing(t) => t,
// [...]
};
// [...]
let response = save_response(transaction, /* */)
.await
.map_err(e500)?;
// [...]
}
[…] a SELECT query (without a FOR UPDATE/SHARE clause) sees only data committed before
the query began; it never sees either uncommitted data or changes committed during query execution
by concurrent transactions. In effect, a SELECT query sees a snapshot of the database as of the
instant the query begins to run.
Data-altering statements, instead, will be influenced by uncommitted transactions that are trying to alter the
same set of rows:
UPDATE, DELETE, SELECT FOR UPDATE […] will only find target rows that were committed as of the
command start time. However, such a target row might have already been updated (or deleted or
locked) by another concurrent transaction by the time it is found. In this case, the would-be updater
will wait for the first updating transaction to commit or roll back (if it is still in progress).
The second concurrent request will fail due to a database error: could not serialize access due to
concurrent update.
repeatable read is designed to prevent non-repeatable reads (who would have guessed?): the same SELECT
query, if run twice in a row within the same transaction, should return the same data.
This has consequences for statements such as UPDATE: if they are executed within a repeatable read trans-
action, they cannot modify or lock rows changed by other transactions after the repeatable read transaction
began.
This is why the transaction initiated by the second request fails to commit in our little experiment above. The
same would have happened if we had chosen serializable, the strictest isolation level available in Postgres.
//! tests/api/newsletter.rs
use fake::faker::internet::en::SafeEmail;
use fake::faker::name::en::Name;
use fake::Fake;
use wiremock::MockBuilder;
// [...]
#[tokio::test]
async fn transient_errors_do_not_cause_duplicate_deliveries_on_retries() {
// Arrange
let app = spawn_app().await;
let newsletter_request_body = serde_json::json!({
"title": "Newsletter title",
"text_content": "Newsletter body as plain text",
"html_content": "<p>Newsletter body as HTML</p>",
"idempotency_key": uuid::Uuid::new_v4().to_string()
});
// Two subscribers instead of one!
create_confirmed_subscriber(&app).await;
create_confirmed_subscriber(&app).await;
app.test_user.login(&app).await;
assert_eq!(response.status().as_u16(), 303);
The test does not pass - we are seeing yet another instance of duplicated delivery:
thread 'newsletter::transient_errors_do_not_cause_duplicate_deliveries_on_retries'
panicked at 'Verifications failed:
- Delivery retry.
Expected range of matching incoming requests: == 1
Number of matched incoming requests: 2
It makes sense, if you think again about our idempotency implementation: the SQL transaction inserting
into the idempotency table commits exclusively when processing succeeds.
Errors lead to an early return - this triggers a rollback when the Transaction<'static, Postgres> value is
dropped.
Can we do better?
A newsletter author expects one of the following scenarios after clicking on Submit:
• the issue was delivered to all subscribers;
• the issue could not be published, therefore nobody received it.
Our implementation allows for a third scenario at the moment: the issue could not be published (500
Internal Server Error), but some subscribers received it anyway.
That won’t do - partial execution is not acceptable, the system must end up in a sensible state.
There are two common approaches to solve this problem: backward recovery and forward recovery.
If they choose to give up retrying, while in the middle of delivery, the system is once again left in an incon-
sistent state.
We will therefore opt for active recovery in our implementation.
11.10.4.1 newsletter_issues
By dispatching eagerly, we never needed to store the details of the issues we were sending out. To pursue our
new strategy, this has to change: we will start persisting newsletter issues in a dedicated newsletter_issues
table.
The schema should not come as a surprise:
sqlx migrate add create_newsletter_issues_table
-- migrations/20220211080603_create_newsletter_issues_table.sql
CREATE TABLE newsletter_issues (
newsletter_issue_id uuid NOT NULL,
title TEXT NOT NULL,
17
The author would still benefit from having visibility into the delivery process - e.g. a page to track how many emails are still
outstanding for a certain newsletter issue. Workflow observability is out of scope for the book, but it might be an interesting exercise
to pursue on your own.
11.10. DEALING WITH ERRORS 565
#[tracing::instrument(skip_all)]
async fn insert_newsletter_issue(
transaction: &mut Transaction<'_, Postgres>,
title: &str,
text_content: &str,
html_content: &str,
) -> Result<Uuid, sqlx::Error> {
let newsletter_issue_id = Uuid::new_v4();
let query = sqlx::query!(
r#"
INSERT INTO newsletter_issues (
newsletter_issue_id,
title,
text_content,
html_content,
published_at
)
VALUES ($1, $2, $3, $4, now())
"#,
newsletter_issue_id,
title,
text_content,
html_content
);
transaction.execute(query).await?;
Ok(newsletter_issue_id)
}
566 CHAPTER 11. FAULT-TOLERANT WORKFLOWS
11.10.4.2 issue_delivery_queue
-- migrations/20220211080603_create_issue_delivery_queue_table.sql
CREATE TABLE issue_delivery_queue (
newsletter_issue_id uuid NOT NULL
REFERENCES newsletter_issues (newsletter_issue_id),
subscriber_email TEXT NOT NULL,
PRIMARY KEY(newsletter_issue_id, subscriber_email)
);
#[tracing::instrument(skip_all)]
async fn enqueue_delivery_tasks(
transaction: &mut Transaction<'_, Postgres>,
newsletter_issue_id: Uuid,
) -> Result<(), sqlx::Error> {
let query = sqlx::query!(
r#"
INSERT INTO issue_delivery_queue (
newsletter_issue_id,
subscriber_email
)
SELECT $1, email
FROM subscriptions
WHERE status = 'confirmed'
"#,
newsletter_issue_id,
);
transaction.execute(query).await?;
Ok(())
}
We are ready to overhaul our request handler by putting together the pieces we just built:
11.10. DEALING WITH ERRORS 567
//! src/routes/admin/newsletter/post.rs
// [...]
#[tracing::instrument(
name = "Publish a newsletter issue",
skip_all,
fields(user_id=%&*user_id)
)]
pub async fn publish_newsletter(
form: web::Form<FormData>,
pool: web::Data<PgPool>,
user_id: web::ReqData<UserId>,
) -> Result<HttpResponse, actix_web::Error> {
let user_id = user_id.into_inner();
let FormData {
title,
text_content,
html_content,
idempotency_key,
} = form.0;
let idempotency_key: IdempotencyKey = idempotency_key.try_into().map_err(e400)?;
let mut transaction = match try_processing(&pool, &idempotency_key, *user_id)
.await
.map_err(e500)?
{
NextAction::StartProcessing(t) => t,
NextAction::ReturnSavedResponse(saved_response) => {
success_message().send();
return Ok(saved_response);
}
};
let issue_id = insert_newsletter_issue(
&mut transaction,
&title,
&text_content,
&html_content
)
.await
.context("Failed to store newsletter issue details")
.map_err(e500)?;
enqueue_delivery_tasks(&mut transaction, issue_id)
.await
.context("Failed to enqueue delivery tasks")
.map_err(e500)?;
568 CHAPTER 11. FAULT-TOLERANT WORKFLOWS
The logic in the request handler is now quite linear. The author is also going to have a quicker feedback loop
- the endpoint no longer has to iterate over hundreds of subscribers before redirecting them to a success page.
Multiple workers would pick the same task and we would end up with a lot of duplicated emails.
We need synchronization. Once again, we are going to leverage the database - we will use row-level locks.
Postgres 9.5 introduced the SKIP LOCKED clause - it allows SELECT statements to ignore all rows that are
currently locked by another concurrent operation.
FOR UPDATE, instead, can be used to lock the rows returned by a SELECT.
We are going to combine them:
SELECT (newsletter_issue_id, subscriber_email)
FROM issue_delivery_queue
FOR UPDATE
SKIP LOCKED
LIMIT 1
11.10. DEALING WITH ERRORS 569
//! src/issue_delivery_worker;
use crate::email_client::EmailClient;
use sqlx::{Executor, PgPool, Postgres, Transaction};
use tracing::{field::display, Span};
use uuid::Uuid;
#[tracing::instrument(
skip_all,
fields(
newsletter_issue_id=tracing::field::Empty,
subscriber_email=tracing::field::Empty
),
err
)]
async fn try_execute_task(
pool: &PgPool,
email_client: &EmailClient
) -> Result<(), anyhow::Error> {
if let Some((transaction, issue_id, email)) = dequeue_task(pool).await? {
Span::current()
.record("newsletter_issue_id", &display(issue_id))
.record("subscriber_email", &display(&email));
// TODO: send email
delete_task(transaction, issue_id, &email).await?;
}
Ok(())
}
#[tracing::instrument(skip_all)]
async fn dequeue_task(
pool: &PgPool,
570 CHAPTER 11. FAULT-TOLERANT WORKFLOWS
#[tracing::instrument(skip_all)]
async fn delete_task(
mut transaction: PgTransaction,
issue_id: Uuid,
email: &str,
) -> Result<(), anyhow::Error> {
let query = sqlx::query!(
r#"
DELETE FROM issue_delivery_queue
WHERE
newsletter_issue_id = $1 AND
subscriber_email = $2
"#,
issue_id,
email
);
transaction.execute(query).await?;
transaction.commit().await?;
Ok(())
}
11.10. DEALING WITH ERRORS 571
To actually send the email, we need to fetch the newsletter content first:
//! src/issue_delivery_worker;
// [...]
struct NewsletterIssue {
title: String,
text_content: String,
html_content: String,
}
#[tracing::instrument(skip_all)]
async fn get_issue(
pool: &PgPool,
issue_id: Uuid
) -> Result<NewsletterIssue, anyhow::Error> {
let issue = sqlx::query_as!(
NewsletterIssue,
r#"
SELECT title, text_content, html_content
FROM newsletter_issues
WHERE
newsletter_issue_id = $1
"#,
issue_id
)
.fetch_one(pool)
.await?;
Ok(issue)
}
We can then recover the dispatch logic that used to live in POST /admin/newsletters:
//! src/issue_delivery_worker;
use crate::domain::SubscriberEmail;
// [...]
#[tracing::instrument(/* */)]
async fn try_execute_task(
pool: &PgPool,
email_client: &EmailClient
) -> Result<(), anyhow::Error> {
if let Some((transaction, issue_id, email)) = dequeue_task(pool).await? {
// [...]
match SubscriberEmail::parse(email.clone()) {
572 CHAPTER 11. FAULT-TOLERANT WORKFLOWS
Ok(email) => {
let issue = get_issue(pool, issue_id).await?;
if let Err(e) = email_client
.send_email(
&email,
&issue.title,
&issue.html_content,
&issue.text_content,
)
.await
{
tracing::error!(
error.cause_chain = ?e,
error.message = %e,
"Failed to deliver issue to a confirmed subscriber. \
Skipping.",
);
}
}
Err(e) => {
tracing::error!(
error.cause_chain = ?e,
error.message = %e,
"Skipping a confirmed subscriber. \
Their stored contact details are invalid",
);
}
}
delete_task(transaction, issue_id, &email).await?;
}
Ok(())
}
As you can see, we do not retry when the delivery attempt fails due to a Postmark error.
This could be changed by enhancing issue_delivery_queue - e.g. adding a n_retries and execute_after
columns to keep track of how many attempts have already taken place and how long we should wait before
trying again. Try implementing it as an exercise!
try_execute_task tries to deliver a single email - we need a background task that keeps pulling from
issue_delivery_queue and fulfills tasks as they become available.
//! src/issue_delivery_worker;
use std::time::Duration;
// [...]
async fn worker_loop(
pool: PgPool,
email_client: EmailClient
) -> Result<(), anyhow::Error> {
loop {
if try_execute_task(&pool, &email_client).await.is_err() {
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
}
If we experience a transient failure18 , we need to sleep for a while to improve our future chances of success.
This could be further refined by introducing an exponential backoff with jitter.
There is another scenario we need to keep in mind, apart from failure: issue_delivery_queue might be
empty.
When that is the case, try_execute_task is going to be invoked continuously. That translates into an ava-
lanche of unnecessary queries to the database.
We can mitigate this risk by changing the signature of try_execute_task - we need to know if it actually
managed to dequeue something.
//! src/issue_delivery_worker.rs
// [...]
enum ExecutionOutcome {
TaskCompleted,
EmptyQueue,
}
#[tracing::instrument(/* */)]
async fn try_execute_task(/* */) -> Result<ExecutionOutcome, anyhow::Error> {
let task = dequeue_task(pool).await?;
if task.is_none() {
return Ok(ExecutionOutcome::EmptyQueue);
}
let (transaction, issue_id, email) = task.unwrap();
// [...]
Ok(ExecutionOutcome::TaskCompleted)
18
Almost all errors returned by try_execute_task are transient in nature, except for invalid subscriber emails - sleeping is not
going to fix those. Try refining the implementation to distinguish between transient and fatal failures, empowering worker_loop
to react appropriately.
574 CHAPTER 11. FAULT-TOLERANT WORKFLOWS
//! src/issue_delivery_worker.rs
// [...]
19
We are not re-using the dependencies we built for our actix_web application. This separation enables us, for example, to
precisely control how many database connections are allocated to background tasks vs our API workloads. At the same time, this is
clearly unnecessary at this stage: we could have built a single pool and HTTP client, passing Arc pointers to both sub-systems (API
and worker). The right choice depends on the circumstances and the overall set of constraints.
11.10. DEALING WITH ERRORS 575
To run our background worker and the API side-to-side we need to restructure our main function.
We are going to build the Future for each of the two long-running tasks - Futures are lazy in Rust, so nothing
happens until they are actually awaited.
We will use tokio::select! to get both tasks to make progress concurrently. tokio::select! returns as
soon as one of the two tasks completes or errors out:
//! src/main.rs
use zero2prod::issue_delivery_worker::run_worker_until_stopped;
// [...]
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let subscriber = get_subscriber(
"zero2prod".into(), "info".into(), std::io::stdout
);
init_subscriber(subscriber);
tokio::select! {
_ = application => {},
_ = worker => {},
};
Ok(())
}
There is a pitfall to be mindful of when using tokio::select! - all selected Futures are polled as a single
task. This has consequences, as tokio’s documentation highlights:
576 CHAPTER 11. FAULT-TOLERANT WORKFLOWS
By running all async expressions on the current task, the expressions are able to run concurrently but
not in parallel. This means all expressions are run on the same thread and if one branch blocks the
thread, all other expressions will be unable to continue. If parallelism is required, spawn each async
expression using tokio::spawn and pass the join handle to select!.
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// [...]
let application = Application::build(configuration.clone()).await?;
let application_task = tokio::spawn(application.run_until_stopped());
let worker_task = tokio::spawn(run_worker_until_stopped(configuration));
tokio::select! {
_ = application_task => {},
_ = worker_task => {},
};
Ok(())
}
As it stands, we have no visibility into which task completed first or if they completed successfully at all. Let’s
add some logging:
//! src/main.rs
use std::fmt::{Debug, Display};
use tokio::task::JoinError;
// [...]
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// [...]
tokio::select! {
o = application_task => report_exit("API", o),
o = worker_task => report_exit("Background worker", o),
};
Ok(())
}
11.10. DEALING WITH ERRORS 577
fn report_exit(
task_name: &str,
outcome: Result<Result<(), impl Debug + Display>, JoinError>
) {
match outcome {
Ok(Ok(())) => {
tracing::info!("{} has exited", task_name)
}
Ok(Err(e)) => {
tracing::error!(
error.cause_chain = ?e,
error.message = %e,
"{} failed",
task_name
)
}
Err(e) => {
tracing::error!(
error.cause_chain = ?e,
error.message = %e,
"{}' task failed to complete",
task_name
)
}
}
}
impl EmailClientSettings {
pub fn client(self) -> EmailClient {
let sender_email = self.sender().expect("Invalid sender email address.");
let timeout = self.timeout();
EmailClient::new(
self.base_url,
sender_email,
self.authorization_token,
timeout,
)
}
// [...]
}
//! tests/api/helpers.rs
use zero2prod::email_client::EmailClient;
// [...]
//! src/issue_delivery_worker.rs
// [...]
//! src/startup.rs
// [...]
impl Application {
pub async fn build(configuration: Settings) -> Result<Self, anyhow::Error> {
let connection_pool = get_connection_pool(&configuration.database);
// Use helper function!
let email_client = configuration.email_client.client();
// [...]
}
// [...]
}
impl TestApp {
pub async fn dispatch_all_pending_emails(&self) {
loop {
if let ExecutionOutcome::EmptyQueue =
try_execute_task(&self.db_pool, &self.email_client)
.await
.unwrap()
{
break;
}
}
}
// [...]
}
//! src/issue_delivery_worker.rs
// [...]
// Mark as pub
pub enum ExecutionOutcome {/* */}
#[tracing::instrument(/* */)]
// Mark as pub
pub async fn try_execute_task(/* */) -> Result</* */> {/* */}
580 CHAPTER 11. FAULT-TOLERANT WORKFLOWS
#[tokio::test]
async fn newsletters_are_not_delivered_to_unconfirmed_subscribers() {
// [...]
assert!(html_page.contains(
"<p><i>The newsletter issue has been accepted - \
emails will go out shortly.</i></p>"
));
app.dispatch_all_pending_emails().await;
// Mock verifies on Drop that we haven't sent the newsletter email
}
#[tokio::test]
async fn newsletters_are_delivered_to_confirmed_subscribers() {
// [...]
assert!(html_page.contains(
"<p><i>The newsletter issue has been accepted - \
emails will go out shortly.</i></p>"
));
app.dispatch_all_pending_emails().await;
// Mock verifies on Drop that we have sent the newsletter email
}
#[tokio::test]
async fn newsletter_creation_is_idempotent() {
// [...]
// Act - Part 2 - Follow the redirect
let html_page = app.get_publish_newsletter_html().await;
assert!(html_page.contains(
"<p><i>The newsletter issue has been accepted - \
emails will go out shortly.</i></p>"
));
// [...]
// Act - Part 4 - Follow the redirect
let html_page = app.get_publish_newsletter_html().await;
assert!(html_page.contains(
"<p><i>The newsletter issue has been accepted - \
emails will go out shortly.</i></p>"
));
app.dispatch_all_pending_emails().await;
// Mock verifies on Drop that we have sent the newsletter email **once**
11.11. EPILOGUE 581
#[tokio::test]
async fn concurrent_form_submission_is_handled_gracefully() {
// [...]
app.dispatch_all_pending_emails().await;
// Mock verifies on Drop that we have sent the newsletter email **once**
}
// We deleted `transient_errors_do_not_cause_duplicate_deliveries_on_retries`
// It is no longer relevant given the redesign
11.11 Epilogue
This is where our journey together comes to an end.
We started from an empty skeleton. Look at our project now: fully functional, well tested, reasonably secure
- a proper minimum viable product. The project was never the goal though - it was an excuse, an opportunity
to see what it feels like to write a production-ready API using Rust.
Zero To Production In Rust started with a question, a question I hear every other day:
I have taken you on a tour. I showed you a little corner of the Rust ecosystem, an opinionated yet powerful
toolkit. I tried to explain, to the best of my abilities, the key language features.
The choice is now yours: you have learned enough to keep walking on your own, if you wish to do so.
Rust’s adoption in the industry is taking off: we are living through an inflection point. It was my ambition
to write a book that could serve as a ticket for this rising tide - an onboarding guide for those who want to
be a part of this story.
This is just the beginning - the future of this community is yet to be written, but it is looking bright.
582 CHAPTER 11. FAULT-TOLERANT WORKFLOWS