How to Write a REST API Using Rust and Axum Framework with Postgres connection pooling

This article will demonstrate writing a REST API from scratch using Rust and Axum framework. I’ll show you how to set up a production-ready Rust Workspace, plan the API and add request validations, and finally some samples for logging, database handling, and error responses.

In the past, I spent quite some time searching for a nice web framework in Rust. Played quite a bit with Actix Web, Rocket, and Axum. But in the end, I chose Axum because it has nice semantics and Straightforward programming paradigms. I couldn’t find a lot of documentation around Axum, but finally figured out how to structure and write a basic REST API using this framework.

Before creating our project and start coding, i want to give a brief overview of the requirements and Business Context.

This tutorial is a part of an entire REST API framework i’m planning to build over time. Please don’t forget to subscribe and follow me for regular updates if it helps you in any way!

Business Context

We’re going to develop the CREATE API for an experimentation platform. Online Experimentation or most known as AB testing, allows organizations to test features and experiences on their consumers.

For instance, Amazon may change the design of their home page and feel that it may drive more sales. To test this, they’ll set up an experiment that routes 50% of the users to their older page, and the remaining 50% to their newer experience.

They can then collect data and decide which page had a significant impact on revenue. The process of creating an experiment will be managed by our APIs. Experiment is just a metadata holding information about the experiment and some traffic split metadata.

I’ll demonstrate how to store this data in a Postgres Database in a quite simple way without using any kind of ORM. After all our platform must be extremely performant and scale to billions of users when we rollout.

Project Setup

To set up the project, we create a new folder called openab (Feel free to choose any name you wish to use. ) I'll be using this throughout.

mkdir openab  
vi Cargo.toml

Within the Cargo.toml file, add the following to define our Cargo workspace. A Cargo Workspace allows us to organize our project with multiple modules all co-located in a common directory.

It becomes quite easy to reuse and share components of our programs using workspaces.

Paste the following in your Cargo Workspace file.

[workspace]

members = [  
    "management-server",  
    "common"  
]

The members section within a Rust workspace is a list of rust projects that will be part of this workspace. We’ll create two projects.

  1. A common library that will house our models, common API logic and everything that can act as a framework or a sharable model throughout.
  2. A management-server project which houses a REST API service for managing experiments in our platform. In simple terms it’s mostly CRUD APIs for creating and managing experiment lifecycle.

To create these projects, add the following commands.

cargo new --lib --vcs=none common
cargo new --vcs=none management-server

This will set up our library and our binary application, respectively. It’s time to add dependencies to our application. For writing REST APIs, we’ll add the following dependencies.

  1. Axum
  2. Tokio
  3. Logging and Tracing
  4. Error Macros
  5. Database Connectivity ( Postgres. )
  6. Serde ( Serialization and Deserialization)

Add the following dependencies within the commons and management-server projects.

common/

[dependencies]  
serde = {version="1.0.145", features=["derive"]}  
serde_json = "1.0.85"  
pgdb-lib-rs = {version="0.1.2"}  
sqlx = { version = "0.6.2", features = [ "runtime-async-std-native-tls", "postgres", "json" ] }  
tokio = { version = "1", features = ["full"] }  
axum = { version="0.5.16", feaures=[]}  
tracing = "0.1.36"  
tracing-subscriber = { version="0.3.15", features = ["env-filter"] }  
thiserror = "1.0.37"

management-server/Cargo.toml

[dependencies]  
axum = { version="0.5.16", feaures=[]}  
serde = {version="1.0.145", features=["derive"]}  
serde_json = "1.0.85"  
common = {path = "../common"}  
tokio = { version = "1.21.2", features = ["full"] }  
pgdb-lib-rs = {version="0.1.2"}tracing = "0.1.36"  
tracing-subscriber = { version="0.3.15", features = ["env-filter"] }  
thiserror = "1.0.37"

We’re now set to start writing our REST API definitions.

API Definition

We’ll be writing an API that allows users to create an Experiment. It will be a POST method, that takes in details like experiment name, objective, and the number of variants that users are planning to test. We’ll write handlers that can take a JSON Post request, perform some validations, and return the experiment if everything looks good.

Define Models (common library)

Before we implement our handlers, let’s define our request models that we’ll be working with. we define two types of Models.

CreateExperimentRequest which takes in the input from the user. And Experiment struct which is the created experiment. This will be returned back to the user.

If you don’t understand the database part, please ignore it for now. this is used for saving our experiment to our database. I’ll be writing a separate post on that.

The focus on this article is to setup the entire project, and really focus on error handling, organization, and validations as the first step.

Error Models ( common library )

Since we’re working with production ready APIs, we’ll have to ensure that we implement some sort of Error Responses that can be conveyed to the users in case something goes wrong, or some validations fail. For this reason, we define an Error struct within our common project, and implement some traits for Error Handling. Below is the definition.

In my opinion, this is the most critical part of writing REST Apis. Our error model holds information around the HTTP status code and some messages that can be displayed to the users upon any errors.

Our goal is to render this model to our users when we face any errors within our APIs and not to terminate our program. As I mentioned before, anything that must be sent to the users as a response must implement the IntoResponse trait. Therefore, we implement the trait IntoResponse for our Error Struct. This just takes in the current status code, and serializes our error struct to JSON. So now, whenever we write our error handler, we can just return this object whenever an error occurs. And Axum will makes sure that the correct errors are displayed to the user. This is the beauty of using simple trait implementation. We don’t have to worry about error propagation from now on.

So, we have everything to implement the actual logic. It’s time to write our handlers.

Handler Definition

Navigate to management-server/src folder and create a new file called handlers.rs This will act as our REST API controller. Similar to @RestControllers in Sprig framework, handlers are the methods that respond to a request, and then return an HTTP Response to the client.

Axum handlers are methods that implement two simple traits — FromRequest and IntoResponse . From Request allows things like deserializing the payload, parsing request headers, and get the request ready for processing by our handler. On the other hand, IntoResponse is responsible for translating our Objects into HTTP response. For the purposes of this demo, all our requests and responses will be using JSON format. I’ll show you how to parse, and respond using JSON along with standard error handling using the same API.

Within handler.rs write the following code. I’ll explain each part and walk through it.

A handler in Axum is composed of an Extractor and any Extensions are required for processing.

In simple terms an extractor is something that parses request body, and deserializes it in a form you can use. There are multiple extractors defined by the framework. For this tutorial, we’re using a JSON extractor, that deserializes input as a struct from JSON body.

Our REST API will also be storing any experiments that are created in a PostgresQl database. So, we expect our handler to get a database instance that can be used to perform CRUD operations within our database.

Extensions are a way to get any shared state or dependency within Axum. These are extremely useful if you have client dependencies or shared application data.

In the example above, we just rely on our Database extension to get a handle for our Postgres Database Connection. Don’t worry about database handling. I'll cover it, and working with sqlx in a separate post.

Notice on line 29 for our handler, I’ve written a simple validation method that checks for experiment name length. We must not have an experiment name less than 3 characters.

If the validation fails, we just return our ApiError type we created earlier. That’s all it takes for error handling. No throwing-catch blocks for exceptions.

The errors are propagated since our handler response type is also of a Result type. It can consist of an experiment or an error. Both are serializable to JSON response.

Main Function

The last step in implementing our API is the main function. We define our API routes within the main, and associate this handler function with our route. This is called every time a POST call happens to /experiments endpoint.

That's all that needs to be done to implement a simple CRUD API using Rust and AXUM.

Our router defines a route to /experiments , which called our create_experiment handler for all POST methods.

The handler then validates the request and saves the experiment in our database returning the experiment object to the user.

Testing APIs

Run the management server application and try making a few requests to our APIs. Given below are some request and response samples along with validation examples.

cd management-server && cargo run

Successful Creation

successful experiment creation REST API Call

Failed Validation

Validation Error renders error messages as expected.

Conclusion

Having worked with Golang and frameworks like Gin or Echo, I never thought it would be so simple to write a REST API in Rust as well.

The fact that we could write this API in a few hours is fantastic. I am amazed by the simplicity of this framework. Even with the error handling. It’s so elegant! It’s been a breeze for me to work with it.

Although, the documentation is not as simple and easy to find as Actix Web or Rocket. I hope to continue using this framework and write more about every aspect of this. And I'll also make this code available on GitHub.

I’ll end with one statement. It’s easy to write an API and have a quick start. But it’s extremely hard to make it production-ready — especially error handling and logging. Please always make sure you have proper error logging and handling right from the start