REST API in Rust in 2023

Rust’s popularity has been steadily growing and it’s voted the most loved programming language for seven consecutive years.

I started using it in 2020 doing mostly CLIs and very simple programs, just to learn the language and get comfortable with its uncommon features. I never used it for web development; Until a couple weeks ago. In that period I decided to build a POC project to see if Rust is actually fit for web development as I know it.

I also stumbled upon this page some time ago and wanted to try for myself.

The project

The project I built is a personal finance app that lets you record income and expenses across multiple accounts. It also provides you with an overview of your assets. It is very simple and never meant for real use. To me it was just a way to use technologies that I know or wanted to learn all together.

finance app

In this post I won’t focus on the frontend, only on the backend.

Functionality

The funcionality provided by the backend is:

  • Signup and login (passwordless)
  • Send OTPs via mail
  • Authentication
  • Get an users’s profile (all the accounts)
  • Get a single account
  • Get all movements of an account
  • Create an account
  • Add a movement to an account

Architecture

I decided to build a monolith following the clean architecture model (within the limits of common sense). In a POC like this having microservices was just not worth it and very time consuming.

(If I were to use them, my final opinion on the role of Rust in web development would not have changed.)

So this is an overview of the architecture:

clean architecture overview

Let’s go over the services.

User and Account service

These are just repositories on top of the db. I used Postgres with a library called SQLX. SQLX is a very cool library that extends Rust’s safety features to the database. Are you trying to select a field that does not exists? Well your code won’t compile.

OTP service

Passwordless login is achieved with OTPs. A random 6 digit number is generated and stored in redis alongside the user’s email in a key-value pair.

It will be validated against the OTP provided by the user and will expire after a certain amount of time passes.

I used the library bb8-redis, that allows connection pooling on top of redis-rs.

Mail service

This is just a layer to send OTPs to users via email. I used lettre

Token service

I wanted to use PASETO v4 public tokens instead of JWTs, just to try out the new kid on the block. After a long struggle, I managed to make it work with pasetors (Don’t ask me how I still have PTSD from this).

REST API

As for the APIs I decided to go with Axum. I played around with Actix Web some time ago (and I will say that it’s more mature), but this time I wanted to use a package that had no unsafe code, just for the sake of it. Axum is also maintained by the same group that maintains Tokio.

Rocket would have been another choice if its development wasn’t that slow and intermittent. It is also more of a complete framework, like Nest.js, Spring Boot or ASP.NET. As such it had a kind of complexity which I didn’t want to have in a small side project (still looking back a this with a little bit of regret, looking at the amount of code I wrote).

Bits and pieces

Other libraries I used are the following:

Compare this list to a package.json/go.mod of an equivalent service written in Node.js/Go and you will see that Rust’s standard library misses important functionality.

Rust by design has a minimal std lib, so nothing new here! That being said some additional features (and almost a requirement for web dev) would be nice to have.

The good

Let’s start with the good part.

I will very briefly say, that the performance of a REST API written in Rust will be the best among any other language. Period. Thanks to its satefy guarantees, it is almost impossible to make a Rust service crash. Again, period.

But these are things we already know, thery are very well documented and discussed everywhere on the web.

Library support

With this project I wanted to see the amount of libraries available in Rust for web development.

To build this project I needed all of these features…

  • validation
  • JSON serialization/deserialization
  • connection pooling for PG and Redis
  • migrations
  • SMTP
  • structured logging
  • enviroment variables parsing
  • .env parsing
  • HTTP
  • Paseto

…and for all of them I found at least two crates that did the job pretty well, which is great!

Type safety

Having a library like Sqlx extend type safety to the database is a big win. If you have no logic in your db or in your queries it means that you will have no more sql related bugs.

Validator does a pretty good job in the api layer and prevents “bad” data to be processed. Nothing new here, any other language can do it one way or another.

Error conversion

Error conversion (thanks to anyhow and thiserror) works really well along with Rust’s type safety. With these libraries you can categorize any error into something you can use, which in my case would be different HTTP status codes.

This is also something you can do in other languages, but in most of them it is feels way more manual. Five points to Griffindor!

The Bad

Here come the downsides, and some hefty ones.

Lots of glue

Types in Rust are strict.

// these have different types

struct S1 {
  value: u8;
}

struct S2 {
  value: u8;
}

To make a function work with different types you need generics. And to provide your types in the function they need to implement the same trait.

This bothers us when we want our json body to be validated before getting to the route handler. We need to implement Axum’s FromRequest for a type that implements Validate. And so we start writing…

#[derive(Debug, Clone, Copy, Default)]
pub struct ValidatedJson<T>(pub T);

#[async_trait]
impl<T, S, B> FromRequest<S, B> for ValidatedJson<T>
where
    T: DeserializeOwned + Validate,
    S: Send + Sync,
    Json<T>: FromRequest<S, B, Rejection = JsonRejection>,
    B: Send + 'static,
{
    type Rejection = Error;

    async fn from_request(req: Request<B>, state: &S) -> Result<Self, Self::Rejection> {
        let Json(value) = Json::<T>::from_request(req, state).await?;
        value.validate()?;
        Ok(ValidatedJson(value))
    }
}

Great, now rinse and repeat for everything that Axum doesn’t already do for you, such as implementing IntoResponse for your errors.

Don’t forget that we use other libraries other than Axum; Want to mark an Enum as a Sqlx type? You can either:

  • Pollute your domain with an external library
  • Duplicate the enum in the service layer and write pointless mappings

Want structured logging? You better start presssing keys!

More glue, more code, more tests. And in the end, less readability.

Mocking (and testing really)

I used Mockall to write mocks, what looks to be the most used library to do it. I quickly found out how limited (by Rust’s strict type system) it was.

Want to mock an external library trait? Well you can but most libraries expose structs anyway.

Want to mock a struct? No (sane) way.

Want to mock an external library struct? No way.

With that in mind I wrote integration tests for all services that used external dependencies. Because I was able to mock my own service traits, unit tests were written for use cases.

I also found out that unlike pytest or jest, you can’t split between unit and integration tests unless you put the latter in a separate folder and change the build target to both a binary and a library. But by doing this integration tests would only have visibility of public exports. So in Rust’s terminology an integration tests will only test your library as the end user would use it. In the end I decided to leave them there toghether with unit tests.

Time

Time is important. In Rust you give your time in exchange of performance and correctness. I could have written this API in Node.js, Go or Python in less than half the time.

Could the service crash or have bugs? Maybe. Fixing them would still take me less time than writing it in Rust.

Do bugs and crashes matter for the average API? Not really. When it crashes your platform will usually restart it, while a QA team or even your end users will catch the bugs. In both cases you are notified when something bad occurs and you can act quickly.

Your will to live exiting your body

Errors. On errors. On errors.

Starting from scratch and bootstrapping the service was a real pain. From slowly realizing, step by step, all of the points I wrote above to battling with the borrow checker on things I didn’t wan’t to bother.

Let’s not mention adding Send + Sync almost everywhere, wrapping service traits in Boxes, and use cases in Arcs!

In the end it took me almost two weeks to write a backend that can be considered production ready, but with minimal exposed functionality.

Was it worth it?

In short no. The developer experience is just not there yet. Like most of the libraries: they work really well until you try to put them together.

I really like Rust and I mean it. I just think that with the current state of technologies there are better tools to build APIs with.

As of 2023, this is my take on Rust, use it on things that are:

  • mission critical
  • in dire need of performance
  • outside your reach (a cli, a linter), meaning on somebody else’s machine
  • not monitored/monitorable
  • not following an iterative software development process (a network protocol, a driver)

Don’t use it for your average REST API, yet.