Building Rust Web API with Warp and Diesel
Introduction
In this article, I would like to share with you my experience of writing a very simple Web API in Rust using Warp and Diesel.
As I am still Rust newbie, please let me know of any mistakes you have spotted, and of course, any feedback is appreciated.
Prerequisites
- Basic knowledge of Rust
- Basic knowledge of how web APIs work
Project overview
To not create another Todo List, we are going to create a simple book catalog (I know I know it is almost as original).
API
We will start by defining our API, it will consist of the following methods:
- POST
/api/v1/books
- to add book to the catalog. - GET
/api/v1/books
- to list all our books. - PUT
/api/v1/books/:id
- to update the status of our book for example:ToRead
,Reading
,Finished
,Rereading
. - DELETE
/api/v1/books/:id
- to delete book from our collection
As mentioned before, we will use Warp as our web framework. It is based on composable Filters
and I have found it quite easy to work with.
Database
To manage our database and connect it with our application, we will use Diesel, which is probably the most popular Rust ORM.
Diesel not only allows us to read and write to the database from our code but also provides a CLI tool to manage migrations.
We will use Postgres as a database but Diesel also supports other drivers like MySQL or SQLite.
Let’s implement it!
Setup the project
First, we will create a new project with cargo
:
|
|
Now let’s declare dependencies for our application in Cargo.toml
:
|
|
To explain things quickly:
- Warp is using
tokio
as an async runtime therefore we need it as a dependency. - We will also need
serde
to work with JSON. - For
diesel
we needpostgres
andr2d2
features for working with the Postgres database and creating a connection pool. - For some basic logging, we will use
log
andpretty_env_logger
.
Setup database with Diesel
After we set up our project we can go ahead and start preparing our database. For that, we will need to install Diesel CLI. You can get a detailed guide on how to do it in Diesel getting start guide.
To setup up Diesel with our project we need to provide it with DATABASE_URL
environment variable or the .env
file. Let’s create it now:
|
|
To continue setup we will need a running database. You can use local Postgres or spin up an instance in Docker container:
|
|
And now we can run the setup:
|
|
This will create a book_catalog
database in our Postgres and add some files to our project:
migrations
directory is the place where our migrations live.diesel.toml
is a configuration file fordiesel-cli
for our project.
Now, let’s add our first migration:
|
|
Every migration is a subdirectory in the migrations
and its name is a timestamp joined with the name we passed to the command. The migration consist of two SQL files:
up.sql
for performing the migration.down.sql
for reverting it.
Database schema
Our database will be stupid simple with just one table representing our books. It obviously is far from perfect but it is enough for demonstration purposes.
In the up.sql
we will simply create the table:
|
|
and in the down.sql
we will drop it:
|
|
You may ask why not use an enum for book status? Unfortunately, Diesel does not support enums out of the box so to keep it simple we will just use
varchar
and map it to the Rust enum in our code. If you really need enums you can check out this create which makes it possible to use enums directly with Diesel.
The last step will be to run our migration on the database and generate schema.rs
file:
|
|
The file contains the table!
macro which creates code based on our database schema to represent tables and columns.
If you would like to adjust the file name or its location, you can do so by modifying diesel.toml
. For our case, the default is perfectly fine.
Define model
Before we start operating on the database we need to have an internal representation of our data. We will create our structs in the model.rs
file.
|
|
This part if pretty straight forward. We declare two structs one of which - CreateBookDTO
- will be used to create books, as it does not have an id
field, which will be assigned by Postgres. The other one - BookDTO
- will represent the whole book object. We will use it for queries.
Besides that, we specify the table_name
and derive from some of the Diesel traits like Queryable
for performing database queries and Insertable
for performing inserts.
You may have noticed that in the case of BookDTO
struct, we do not actually need to specify the table_name
. That is because structs implementing Queryable
are not related to a specific table. They just represent the result of a query with a specific type signature and therefore can be used with multiple tables.
We are still missing one thing which is the BookStatus
enum. As I have mentioned before, enums are not supported in Diesel out of the box, and for Postgres to treat it as a text field (varchar(256)
in our case), we need to implement two traits:
ToSql
- to convert Rust enum value to text stored in the database.FromSql
- to match text from the database to Rust enum value.
Let’s add it to our model.rs
:
|
|
Custom Errors
The last step for our model will be the custom error type. We will add it to a new errors.rs
file. Let’s define new enum - ErrorType
- and new struct - AppError
.
|
|
ErrorType
will help us to differentiate between different kinds of errors and map them properly to HTTP status codes in the to_http_status()
method. For our application, we will only use three error types but you can add more if you need it.
We will also need to convert errors from Diesel to our AppError
and for that, we have from_diesel_err(...)
. Note that we are mapping Diesel errors to a specific ErrorType
so if we get diesel::result::Error::NotFound
from the database, our API will properly respond with 404
status code.
Furthermore, the AppError
implements standard traits like Display
and Error
but also one specific to Warp - Reject
. This trait will allow us to pass AppError
to the warp::reject::custom(...)
function so that we can later use it while handling the rejections.
Implement data access
Now we have our database and model representing the entities. We can go ahead and write some code that will allow us to access the DB. The heavy lifting here is done by Diesel so we will just need a couple of simple methods. We will wrap them up with a DBAccessManager
struct.
Let’s create a new file for that and call it data_access.rs
.
First, we will add the required imports and define the struct. It will contain database connection object which we will get from the connection pool - more on that later.
|
|
Now let’s implement the first method.
|
|
For inserting data to the database we are using insert_into
function, passing it the books::table
generated by macro from schema.rs
. Then we set values from our CreateBookDTO
struct and finally we execute the query.
As a result, we are expecting to get either BookDTO
or diesel::result::Error
, therefore if an error occurs we use the previously prepared function AppError::from_diesel_err
to map it to the AppError
.
Let’s add remaining methods for listing, updating, and deleting books.
|
|
The code is pretty similar. We use filter([COLUMN_NAME].eq([VALUE]))
as an equivalent of SQL WHERE
statement and set([COLUMN_NAME].eq([NEW_VALUE])
for column updates. We use load()
for querying multiple rows and execute()
to run queries like update or delete.
In the case of update_book_status()
and delete_book()
methods we additionally check if any rows were affected and if that is not the case we return new error with type NotFound
.
We can now import macros from diesel
crate in our main.rs
as well as declare our modules:
|
|
Create API
Before we create our awesome Books API, let’s start with something simple to get the taste of Warp.
We will start with simple HelloWorld
handler so let’s replace our main
function with the following:
|
|
We initialize or Filter using warp::path!
macro and specify the path to hello
. Then we extend it with the map
function which simply returns Hello World!
string. By default, the response will have a 200
status code.
Then we are just starting our server on port 3030
.
We can now run it with cargo
:
|
|
And verify if it is working correctly using curl
:
|
|
We should get 200
response with:
|
|
Add database connection pool
To access the database we need the database connection and we will need it for handling every request. Initializing connection every time someone calls our API would be expensive so as mentioned in previous sections, we will use the connection pool.
To create a connection pool we will use the r2d2
feature from Diesel
. First, we need a function to create our connection pool. Let’s add it to the main.rs
:
|
|
Instead of passing the connection object itself, we will wrap it with the DBAccessManager
that we have created earlier.
|
|
This function will get a connection from the pool, use it to create DBAccessManager
, and append it to the parameters tuple of the Filter
, we will see this in action when we will be setting up our filters.
Creating handlers
Before we stitch everything together let’s create structs and handlers for our endpoints. We will do it in a dedicated file api.rs
.
First, we need structs that will represent the JSON objects that our API will be receiving and responding with:
|
|
We can also add a method to the AddBook
struct, to convert it to the CreateBookDTO
that we use later:
|
|
Before adding handler methods, let’s add one more function, that will take a Result
and based on that respond either with an object serialized to JSON or an error. Here we will leverage the Reject
trait implemented for our AppError
:
|
|
To serialize the struct to JSON it needs to implement the Serialize
trait, therefore T: Serialize
.
Now we can use it in every handler method we create. We can add them now to api.rs
:
|
|
We have four simple methods:
add_book
to add a new book to our collection.update_status
to update the status of the specified book.delete_book
to delete the book.list_books
to list all of our books.
As you can see all of our handlers are async
functions and their logic is quite simple:
- Log that the method is called.
- Call a method from
DBAccessManager
. - Map the result to the desired struct.
- Respond with a JSON object or an error.
We could get away by not defining new methods for our handlers as their logic is quite trivial, but I find it useful to decouple it from all the Filters
setup, that we will do in our main file. This would be much more apparent in the case of more complex applications.
Before we move on we need to declare a new module in our main.rs
file.
|
|
Handling rejections
We will add one more function that will help us handle rejections. Because we implemented the Reject
trait for the AppError
, we can now extract it from the warp::Rejection
struct. We will try to do it in the handle_rejection
function. Let’s add it to errors.rs
:
|
|
Here we try to extract different errors from the warp::Rejection
struct and map it to proper HTTP status code.
For serializing the error response to JSON we use simple struct - ErrorMessage
- and use warp::reply::with_status(...)
to respond with a proper HTTP status code.
Connecting the pieces
Before we make use of our handlers we need to add one more filter, to decode the request body from JSON
and append it to the parameters tuple.
|
|
We will create every route as a separate function:
|
|
The rest of the methods follow a similar structure:
|
|
Now we will add the final method to combine all the previously created filters into a single one, that will be passed to warp::serve
.
|
|
Finally, let’s update our main
function to finalize our API.
We will read the database connection string from the DATABASE_URL
environment variable using env::var("DATABASE_URL")
. Thanks to that we can reuse the .env
file created for Diesel.
We will also use previously prepared functions to create our database connection pool and combined filter with the API endpoints.
Last but not least we will use recover
function on the filter and pass it handle_rejection
so that it will be called when the request will not match any filters or the error will be returned.
|
|
Now we should be able to successfully compile the application.
Run the application
First, let’s make sure we still have our database up and running. If that is the case we need to set the DATABASE_URL
environment variable, we can do it manually or leverage existing .env
file that we created for Diesel:
|
|
Now we can run the application again using cargo
:
|
|
We should see some logs indicating that the application has started:
|
|
Use the API
Now that everything is up and running let’s make some calls!
Add book, that we are currently reading:
|
|
|
|
We should get back the id that we now can use to update the book status:
|
|
|
|
Let’s list books to see that the status was updated:
|
|
|
|
And finally, we can also delete it:
|
|
Summing up
Obviously our application is very simplistic and far from perfect, there are tons of things that we would have to do to make it even close to the production quality, but it is enough to get started with something and learn some fundamentals of Warp and Diesel.
If you have any suggestions or feedback please let me know!
Want to get your hands dirty?
A good way to learn new things in software space (at least for me) is to take an existing piece of code and add something to it as you exercise both your code reading and understanding skills as well as writing skills.
If you are up for the challenge and want to get your hands a little dirty, try to implement another endpoint (GET /api/v1/books/:id
) that will return a book with a specified ID.
The following curl
:
|
|
should return the book with ID 1, for example:
|
|
You can clone the code from the repository.
If you will struggle with doing it by yourself, don’t worry! The answer is available on the get-book-endpoint
branch.