9 Building an HTTP REST API service

This chapter covers

In this chapter, we will put much of what we’ve learned in the previous chapters into practice by building a web service with async Rust. For completeness, we’ll write an API client in chapter 10.

I’ll focus mainly on the final code and spend less time discussing syntax, boilerplate, and alternative implementations. I’m confident you will get the most value from a complete working example. Much of the “how to” content on the internet (and elsewhere) tends to omit many of the full-picture implementation details and gloss over many complexities, so I will do my best to point out what’s missing from this example and where to go from here. I will not discuss the subjects of deployment, load balancing, state management, cluster management, or high availability in depth because they are outside the scope of this book and not directly related to the Rust language.

At the end of this chapter, we’ll have built a web API service that uses a database for state management to provide the critical features of nearly every web service in existence: creating, reading, updating, deleting, and listing items in a database. We’ll model a “todo” CRUD app because this is a commonly used example for teaching purposes. Afterward, you can use this as a template or starter project for future development. Let’s dive in!

9.1 Choosing a web framework

While writing this book, we’ve seen the async Rust landscape change quite a bit, especially with regard to the tools and libraries available for working with async Rust. The changes have largely been positive, and, in particular, I’m quite impressed with the progress of Tokio and its related projects.

For writing a web service, my recommendation is to use the axum framework, which is part of the larger Tokio project. The axum framework is somewhat minimal—as far as web frameworks go—but it packs a big punch, thanks to its flexible API and mostly macro-free implementation. It’s relatively easy to integrate with other libraries and tools, and the simple API makes it quick and easy to get started. axum is based on Tower (https://github.com/tower-rs/tower), a library that provides abstractions for building networked services, and hyper (https://hyper.rs/), which provides an HTTP client and server implementation for both HTTP/1 and HTTP/2 (the book HTTP/2 in Action (Barry Pollard; https://www.manning.com/books/http2-in-action) provides a deep dive into the specifics of HTTP/2).

The best thing about axum is that it doesn’t impose much on you in terms of patterns or practices. It does require that you learn the Tower library, if you wish to get into the nitty-gritty details, but for simple tasks, this is not necessary. A basic web service can be stood up quickly without needing to spend a great deal of time learning the web framework before writing a web service. For production services, axum includes support for tracing and metrics, which only require a small amount of configuration to enable.

Honorable mentions

The two other frameworks worth mentioning are Rocket (https://rocket.rs/), a web framework that aims to be a Ruby on Rails for Rust, and Actix (https://actix.rs/), one of the earliest Rust web frameworks.

Both Rocket and Actix share the same flaw: they make significant use of macros to hide implementation details. axum, on the other hand, does not use macros for its core API, which (in my humble opinion) makes it much nicer to work with and easier to reason about.

To their credit, both Rocket and Actix existed before Rust’s stabilization of the Future trait and the async/await syntax—before which the use of macros was required. Additionally, both frameworks have made strides in reducing their reliance on macros in more recent versions.

9.2 Creating an architecture

For our web service, we’ll follow a typical web tier architecture, which consists of at least three components: a load balancer, the web service itself, and a stateful service (i.e., a database). We’re not going to implement a load balancer (we’ll assume one already exists or is provided), and for the database, we’ll use SQLite, but in practice, you’d likely want to use a SQL database, such as PostgreSQL. The architecture is shown in figure 9.1.

CH09_F01_Matthews

Figure 9.1 Web service architecture

As shown in the diagram, our API service can scale horizontally by simply adding more instances of the service. Each instance of our API service receives requests from the load balancer and talks independently to the database for storing and retrieving state.

Our application should accept its configuration from the environment, so we’ll pass configuration parameters using environment variables. We could use command-line parsing or a config file instead, but environment variables are very convenient, especially when deploying in contexts such as cluster orchestration systems. In our case, we’re only going to use a couple of configuration parameters: one to specify the database and another to configure logging. We’ll discuss these parameters later.

The configuration for each instance of our API service will be identical in most cases, though there might be special circumstances in which you want to specify parameters that are unique to each service instance, such as locality information or an IP address to bind to. In practice, we typically bind to the 0.0.0.0 address, which binds to all interfaces and effectively delegates the job of handling details to the OS networking stack (and can be configured as needed).

9.3 API design

For our service, we’ll model a basic todo app. You may have encountered the todo app before, and for this, we’re only going to implement create, read, update, and delete (CRUD) endpoints for the todos and a listing endpoint. We’ll also add liveness and readiness health check endpoints. We’ll place our API routes under the /v1 path, as shown in table 9.1.

Table 9.1 API service routes

Path

HTTP method

Action

Request body

Response

/v1/todos

GET

List

N/A

List of all todos

/v1/todos

POST

Create

New todo object

The newly created todo object

/v1/todos/:id

GET

Read

N/A

The newly created todo object

/v1/todos/:id

PUT

Update

Updated todo object

The newly created todo object

/v1/todos/:id

DELETE

Delete

New todo object

The newly created todo object

For the read, update, and delete paths, we use a path parameter for the ID of each todo, which is denoted in the preceding paths with the :id token. We’ll add liveness and readiness health check endpoints, as shown in table 9.2.

Table 9.2 API service health check endpoints

Path

HTTP method

Response

/alive

GET

Returns 200 with ok on success

/ready

GET

Returns 200 with ok on success

Now that we’ve described the API, let’s look at the tools and libraries we’ll use to build it in the next section.

9.4 Libraries and tools

We’ll rely on existing crates to do much of the heavy lifting for our service. We don’t need to write much code at all—most of what we’ll do involves gluing existing components together to build our service. However, we have to pay close attention to how we combine the different components, but lucky for us, Rust’s type system makes that easy by telling us when it’s wrong, with compiler errors.

We can initialize the project with cargo new api-server, after which we can start adding the crates we need with cargo add .... The crates we need and their features are listed in table 9.3.

Table 9.3 API service dependencies

Name

Features

Description

axum

Default

Web framework

chrono

serde

Date/time library, with serde feature

serde

derive

Serialization/deserialization library, with #[derive(...)] feature

serde_json

Default

JSON serialization/deserialization for the serde crate

sqlx

runtime-tokio-rustls, sqlite,chrono,macros

Async SQL toolkit for SQLite, MySQL, and PostgreSQL

tokio

macros,rt-multi-thread

Async runtime, used with axum and sqlx

tower-http

trace,cors

Provides HTTP middleware for axum, specifically, tracing and CORS

tracing

default

Async tracing library

tracing-subscriber

env-filter

Allows us to subscribe to tracing data within crates that use tracing

Note Dependency versions are not listed in table 9.3. These can be found in Cargo.toml from the source code listings for this book.

For dependencies with features, you can use the --feature flag with cargo add. For example, to add axum with the default features, we run cargo add axum, and for SQLx, we run cargo add sqlx --features runtime-tokio-rustls,sqlite,chrono,macros. You can also simply copy the Cargo.toml from the book’s source code for this project.

You may also want to try the sqlx-cli (https://crates.io/crates/sqlx-cli) tool, which can be installed with cargo install sqlx-cli. This tool allows you to create databases, run migrations, and drop databases. Once installed, run sqlx --help for more information. This tool is not required to run the code, but it’s useful if you want to do more with SQLx.

For your convenience, you can install everything from table 9.3 in a one-shot, “copy-pastable” command as follows:

cargo add axum
cargo add chrono --features serde
cargo add serde --features derive
cargo add serde_json
cargo add sqlx --features runtime-tokio-rustls,sqlite,chrono,macros
cargo add tokio --features macros,rt-multi-thread
cargo add tower-http --features trace,cors
cargo add tracing
cargo add tracing-subscriber --features env-filter
cargo install sqlx-cli

After running these commands, your Cargo.toml will look like the following listing.

Listing 9.1 API service Cargo.toml

[package]
name = "api-service"
version = "0.1.0"
edition = "2021"
 
# See more keys and their definitions at https://doc.rust-lang.org/cargo/ 
 reference/manifest.html
 
[dependencies]
axum = "0.6.18"
chrono = { version = "0.4.26", features = ["serde"] }
serde = { version = "1.0.164", features = ["derive"] }
serde_json = "1.0.99"
sqlx = { version = "0.6.3", features = ["runtime-tokio-rustls", "sqlite",
 "chrono", "macros"] }
tokio = { version = "1.28.2", features = ["macros", "rt-multi-thread"] }
tower-http = { version = "0.4.1", features = ["trace", "cors"] }
tracing = "0.1.37"
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }

With our dependencies set up, we can dive into writing the code.

Note In practice, you’d likely add and change the dependencies as you go, so don’t take this as a suggestion that you need to set up all the dependencies ahead of time. As I like to say, software is soft, so never avoid modifying it to your taste (including the examples I provide).

9.5 Application scaffolding

Our application entry point in main.rs contains a small amount of boilerplate and the necessary setup for our application. Within it, we’ll do the following:

9.5.1 main()

Let’s start by taking a look at our main() function.

Listing 9.2 API service main() function from src/main.rs

#[tokio::main]
async fn main() {
    init_tracing();                                            
 
    let dbpool = init_dbpool().await
        .expect("couldn’t initialize DB pool");                
 
    let router = create_router(dbpool).await;                  
 
    let bind_addr = std::env::var("BIND_ADDR")
        .unwrap_or_else(|_| "127.0.0.1:3000".to_string());     
 
    axum::Server::bind(&bind_addr.parse().unwrap())            
        .serve(router.into_make_service())                     
        .await
        .expect("unable to start server")
}

Initializes the tracing and logging for our service and its dependencies

Initializes the DB pool

Creates the core application service and its routes

Fetches the binding address from the environment variable BIND_ADDR or uses the default value of 127.0.0.1:3000

Parses the binding address into a socket address

Creates the service and starts the HTTP server

Our main() doesn’t contain much, and we have to dig deeper to understand what’s going on. Before we do that, it should be noted that we’re using Tokio’s tokio::main macro to initialize the Tokio runtime, which hides a bit of complexity for us, such as setting the number of worker threads.

Tip Tokio will read the TOKIO_WORKER_THREADS environment variable, and if provided, it will set the number of worker threads to the value defined.

For more complex scenarios, you may want to manually instantiate the Tokio runtime and configure it accordingly using tokio::runtime::Builder.

9.5.2 init_tracing()

Moving on, let’s take a look at the tracing initialization in init_tracing().

Listing 9.3 API service init_tracing() function in src/main.rs

fn init_tracing() {
    use tracing_subscriber::{
        filter::LevelFilter, fmt, prelude::*, EnvFilter
    };
 
    let rust_log = std::env::var(EnvFilter::DEFAULT_ENV)                   
        .unwrap_or_else(|_| "sqlx=info,tower_http=debug,info".to_string());
 
    tracing_subscriber::registry()                                         
        .with(fmt::layer())                                                
        .with(
            EnvFilter::builder()                                           
                .with_default_directive(LevelFilter::INFO.into())
                .parse_lossy(rust_log),
        )
        .init();
}

Fetches the RUST_LOG environment variable, providing a default value if it’s not defined

Returns the default global registry

Adds a formatting layer, which provides human-readable trace formatting

Constructs an environment filter, with the default log level set to info or using the value provided by RUST_LOG otherwise

Initializing the tracing is important if we want to see useful log messages. We probably don’t want to turn on all tracing messages, just the traces that are useful, so we explicitly enable the debug level messages for tower_http::*, and info-level messages for sqlx::*. We could also add our own traces, but the ones included in the crates we’re using are more than sufficient for our needs.

Determining which traces to enable can be a little tricky, but we can turn on all the traces by setting RUST_LOG=trace. This can generate a lot of logging output, so don’t try this in production environments if you don’t need to. EnvFilter is compatible with env_logger, which is used by many other Rust crates, so we can maintain compatibility and familiarity within the Rust ecosystem.

9.5.3 init_dbpool()

For our state management, we’ll use a connection pool to obtain a connection to the database. The connection pool allows us to acquire and reuse connections to the database without needing to create a new connection for each request, which provides us a nice little optimization. The connection pool settings are database specific and can be configured as needed, but for this example, we’ll stick with the default parameters. Additionally, the pooling is nice but not entirely necessary because we’re using SQLite (as opposed to a network-connected database, like MySQL or PostgreSQL), which operates within the same process on background threads managed by the SQLite library. Let’s look at init_dbpool() in the following listing.

Listing 9.4 API service init_dbpool() function in src/main.rs

async fn init_dbpool() -> Result<sqlx::Pool<sqlx::Sqlite>, sqlx::Error> {
    use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
    use std::str::FromStr;
 
    let db_connection_str =
        std::env::var("DATABASE_URL")
        .unwrap_or_else(|_| "sqlite:db.sqlite".to_string());      
 
    let dbpool = SqlitePoolOptions::new()
        .connect_with(SqliteConnectOptions::from_str(&db_connection_str)?
        .create_if_missing(true))                                 
        .await
        .expect("can’t connect to database");
 
    sqlx::migrate!()                                              
        .run(&dbpool)                                             
        .await
        .expect("database migration failed");
 
    Ok(dbpool)
}

We’ll try to read the DATABASE_URL environment variable or default to sqlite:db.sqlite if not defined (which opens a file called db.sqlite in the current working directory).

When we connect to the database, we ask the driver to create the database if it doesn’t already exist.

After we’ve connected to the DB, we run any necessary migrations.

We can pass our newly created DB pool directly to SQLx, which will obtain a connection from the pool.

Databases are a complex topic and well outside the scope of this book, but I’ll summarize what’s happening in the preceding code listing:

9.6 Data modeling

We’re keeping this service simple by only modeling one kind of data: a todo item. Our todos only need two fields: a body (i.e., the todo item), which is just a text string, and a Boolean field to mark whether an item is completed. We could simply delete a todo once it’s completed, but it might be nice to keep completed todos around if we want to look back at the old (completed) todos. We’ll include a timestamp for the creation date and the time the todo was last updated. You might also want a third timestamp, the time at which an item is completed, but we’ll keep this example simple.

9.6.1 SQL schema

Let’s write the SQL schema for our todos table.

Listing 9.5 API service SQL schema from migrations/20230701202642_todos.sql

CREATE TABLE IF NOT EXISTS todos (
    id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
    body TEXT NOT NULL,
    completed BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

I won’t go into too much detail, as the SQL itself is fairly self-explanatory. I want to note a couple of details, however:

Our CREATE TABLE ... statement will be added as a migration, so when the database is first initialized and migrations are executed, the table will be created. We’ll use the SQLx CLI to create the migration:

$ sqlx migrate add todos
# ...

This command creates a file called migrations/20230701202642_todos.sql, which we’ve populated with the SQL code from listing 9.5.

9.6.2 Interfacing with our data

We’re now ready to write the Rust code to model our todos in Rust and interact with the database. We’ll support five operations: create, read, update, delete, and list. These are the default table stakes CRUD operations that you generally get out of the box and that you will encounter many times over if you spend much time working with web frameworks.

Let’s look at our Todo struct.

Listing 9.6 API service Todo struct from src/todo.rs

#[derive(Serialize, Clone, sqlx::FromRow)]    
pub struct Todo {
    id: i64,
    body: String,
    completed: bool,
    created_at: NaiveDateTime,                
    updated_at: NaiveDateTime,
}

We’re deriving the Serialize trait from the serde crate and sqlx::FromRow, which allows us to get a Todo from a SQLx query.

We use the chrono::NaiveDateTime type to map SQL timestamps into Rust objects.

There isn’t a lot to see for the Todo struct itself, so we’ll jump into the impl blocks, which get more interesting. We’ll first look at the listing and reading code.

Listing 9.7 API service Todo struct impl read block from src/todo.rs

impl Todo {
    pub async fn list(dbpool: SqlitePool) -> Result<Vec<Todo>, Error> {
        query_as("select * from todos")                                    
            .fetch_all(&dbpool)
            .await
            .map_err(Into::into)
    }
    pub async fn read(dbpool: SqlitePool, id: i64) -> Result<Todo, Error> {
        query_as("select * from todos where id = ?")                       
            .bind(id)
            .fetch_one(&dbpool)
            .await
            .map_err(Into::into)
    }
}

Selects all todos from the todos table

Selects one todo from the todos table with a matching id field

In this code, we have two methods: list() and read(). Each method applies the action you’d expect by executing a query against the database. The only real difference between list() and read() is the number of records returned and the fact that we need to select by ID when reading a single record. Now, let’s look at the following listing, which shows the write impl block.

Listing 9.8 API service Todo struct impl write block from src/todo.rs

impl Todo {
    pub async fn create(
        dbpool: SqlitePool,
        new_todo: CreateTodo,                                             
    ) -> Result<Todo, Error> {
        query_as("insert into todos (body) values (?) returning *")       
            .bind(new_todo.body())
            .fetch_one(&dbpool)                                           
            .await
            .map_err(Into::into)
    }
    pub async fn update(
        dbpool: SqlitePool,
        id: i64,
        updated_todo: UpdateTodo,                                         
    ) -> Result<Todo, Error> {
        query_as(
            "update todos set body = ?, completed = ?, \
            updated_at = datetime(‘now’) where id = ? returning *",       
        )
        .bind(updated_todo.body())                                        
        .bind(updated_todo.completed())
        .bind(id)
        .fetch_one(&dbpool)                                               
        .await
            .map_err(Into::into)
    }
    pub async fn delete(dbpool: SqlitePool, id: i64) -> Result<(), Error> {
        query("delete from todos where id = ?")                           
            .bind(id)
            .execute(&dbpool)                                             
            .await?;
        Ok(())                                                            
    }
}

We’ve added a new type here, CreateTodo, which we haven’t defined yet. It contains the todo body, which we need to create a todo.

We use the returning * SQL clause to retrieve the record immediately after it’s inserted.

We execute the query with fetch_one() because we expect this to return one row.

We’ve added another new type here, UpdateTodo, which contains the two fields we allow to be updated.

Once again, we’re using the returning * SQL clause to retrieve the updated record immediately. Notice how we set the updated_at field to the current date and time.

Each value is bound in the order they’re declared within the SQL statement, using the ? token to bind values. This syntax varies, depending on the SQL implementation.

We expect to fetch one row when this query is executed.

The delete is destructive; nothing is left to return if it succeeds.

Here, we use execute() to execute the query, which is used for queries that don’t return records.

We return unit upon success (i.e., no previous errors).

The code for each action is quite similar, so let’s discuss some of the shared behaviors:

Let’s take a quick look at the CreateTodo and UpdateTodo structs, which we introduced in listing 9.8. First, let’s examine CreateTodo.

Listing 9.9 API service CreateTodo struct from src/todo.rs

#[derive(Deserialize)]
pub struct CreateTodo {
    body: String,
}
 
impl CreateTodo {
    pub fn body(&self) -> &str {
        self.body.as_ref()
    }
}

Notice how the only method we provide is an accessor for the body field. This is because we’re relying on Deserialize to create the struct, which we derived at the top. We don’t need to construct a CreateTodo; we just need to deserialize it when we receive one in an API call.

Next, let’s look at UpdateTodo.

Listing 9.10 API service UpdateTodo struct from src/todo.rs

#[derive(Deserialize)]
pub struct UpdateTodo {
    body: String,
    completed: bool,
}
 
impl UpdateTodo {
    pub fn body(&self) -> &str {
        self.body.as_ref()
    }
 
    pub fn completed(&self) -> bool {
        self.completed
    }
}

UptadeTodo is nearly the same as CreateTodo, except we have two fields: body and completed. Once again, we rely on the serde library to construct the object for us.

That’s it for the data model. Now, we’ll move on to defining the API routes in the next section.

9.7 Declaring the API routes

We’ve already designed our API, so all we need to do is declare the routes using axum’s Router. If you’ve used any other web frameworks, this code will look quite familiar, as it consists of the same components: a request path (with optional parameters), a request method, the request handler, the state we require for our handlers, and any additional layers for our service.

Let’s go ahead and look at the code in the following listing from router.rs, which defines the service and its router.

Listing 9.11 API service router from src/router.rs

pub async fn create_router(
    dbpool: sqlx::Pool<sqlx::Sqlite>,                                 
) -> axum::Router {
    use crate::api::{
        ping, todo_create, todo_delete, todo_list, todo_read, todo_update,
    };
    use axum::{routing::get, Router};
    use tower_http::cors::{Any, CorsLayer};
    use tower_http::trace::TraceLayer;
 
    Router::new()
        .route("/alive", get(|| async { "ok" }))                      
        .route("/ready", get(ping))                                   
        .nest(
            "/v1",                                                    
            Router::new()
                .route("/todos", get(todo_list).post(todo_create))    
                .route(
                    "/todos/:id",
                    get(todo_read).put(todo_update)
                     .delete(todo_delete),
                ),                                                    
        )
        .with_state(dbpool)                                           
        .layer(CorsLayer::new().allow_methods(Any)
         .allow_origin(Any))                                        
        .layer(TraceLayer::new_for_http())                            
}

The database pool is passed into the router, which takes ownership.

Our liveness health check merely returns a 200 status with the body ok.

Our readiness health check makes a GET request with the ping() handler.

The API routes are nested under the /v1 path.

Here, we permit two methods for the /v1/todos path—either GET or POST—which call the todo_list() and todo_create() handlers, respectively. We can change the methods together using a handy fluent interface.

The path parameter :id maps to the todo’s ID. GET, PUT, or DELETE methods for /v1/todos/:id map to todo_read(), todo_update(), and todo_delete, respectively.

We hand the database connection pool off to the router to be passed into handlers as state.

A CORS layer is added to demonstrate how to apply CORS headers.

We need to add the HTTP tracing layer from tower_http to get request traces.

axum::Router is the core abstraction of the axum web framework, which allows us to declare the routes and their handlers as well as mix in layers from other services, such as tower_http. Although this example is quite basic, you can get very far building upon what I’ve demonstrated here, as it will cover a significant portion of use cases. For practical purposes, you would need to consult the axum documentation at https://docs.rs/axum/ to go more in depth in the framework and its features. Let’s move on to implementing the API route handlers.

9.8 Implementing the API routes

The final puzzle piece is the API route handlers, which we’ll discuss now. Let’s start by looking at the ping() handler for the readiness check because it’s the most basic handler.

Listing 9.12 API service ping handler from src/api.rs

pub async fn ping(
    State(dbpool): State<SqlitePool>,          
) -> Result<String, Error> {
    use sqlx::Connection;
 
    let mut conn = dbpool.acquire().await?;    
    conn.ping()                                
        .await
        .map(|_| "ok".to_string())             
        .map_err(Into::into)                   
}

The State extractor gives us the database connection pool from the axum state.

We need to acquire a connection from the database pool first.

The ping() method will check if the database connection is OK. In the case of SQLite, this checks that the SQLite background threads are alive.

Upon success, ping() returns unit, so we just map it to the string ok, which is returned as our response.

We use the From trait to map sqlx::Error to our own error types.

In ping(), I’ve introduced a new concept from the axum framework called extractors. In short, an extractor is anything that implements the axum::extract::FromRequest or axum::extract::FromRequestParts traits, but we can also use one of the extractors that axum provides for use, which include the following:

The axum framework provides several other extractors, and you can also create your own by implementing the extractor traits.

Moving on, let’s get into the most import bits: the todo API route handlers.

Listing 9.13 API service todo handlers from src/api.rs

pub async fn todo_list(
    State(dbpool): State<SqlitePool>,
) -> Result<Json<Vec<Todo>>, Error> {                   
    Todo::list(dbpool).await.map(Json::from)            
}
 
pub async fn todo_read(
    State(dbpool): State<SqlitePool>,
    Path(id): Path<i64>,                                
) -> Result<Json<Todo>, Error> {
    Todo::read(dbpool, id).await.map(Json::from)
}
 
pub async fn todo_create(
    State(dbpool): State<SqlitePool>,
    Json(new_todo): Json<CreateTodo>,                   
) -> Result<Json<Todo>, Error> {
    Todo::create(dbpool, new_todo).await.map(Json::from)
}
 
pub async fn todo_update(
    State(dbpool): State<SqlitePool>,
    Path(id): Path<i64>,
    Json(updated_todo): Json<UpdateTodo>,               
) -> Result<Json<Todo>, Error> {
    Todo::update(dbpool, id, updated_todo).await.map(Json::from)
}
 
pub async fn todo_delete(
    State(dbpool): State<SqlitePool>,
    Path(id): Path<i64>,
) -> Result<(), Error> {
    Todo::delete(dbpool, id).await
}

Note how we’re returning a JSON object of Vec<Todo> or, possibly, an error.

The Todo::list() method returns a plain Vec<Todo>, so we map that to a Json object using Json::from, which relies on the Serialize trait we derived for Todo.

A path parameter, which we access using the Path extractor. axum takes care of mapping the ID from the /v1/todos/:id router path to the named parameter in a type-safe manner.

Here, we introduce the CreateTodo struct, which we’re getting from the request body using the Json extractor, which uses the Deserialize implementation we derived using the serde crate.

The UpdateTodo struct, which we’re getting from the request body using the Json extractor, which uses the Deserialize implementation we derived using the serde crate.

The code for our API handlers is quite small. Because we’ve already done most of the hard work; at this point, it’s just about defining the inputs and outputs for each of our handlers. axum will only match requests against handlers that have valid extractors for their given request path and method, and it does so in a way that’s type safe, so we don’t have to think too hard about whether our handlers will work once the code successfully compiles. This is the beauty of Rust and type safety.

Note To bring ourselves back down to earth, it should be noted that this API is designed in a way that’s quite rigid. For example, you don’t allow for optional fields in any of the endpoints—you can only provide exactly the fields required or else the service will return an error. In most cases, this is fine, but as an exercise for the reader, you may want to try making the completed field (for example) optional on PATCH or update requests. If you only need to modify one particular field, it seems reasonable that the API would gracefully handle only the fields that are specified—does it not?

We now have a fully functioning API service, with the main CRUD endpoints completed. We need to discuss one more topic—error handling—and then we can run some tests to see how this baby works.

Before I jump into error handling, let’s quickly discuss how responses are handled in axum. Out of the box, axum will handle converting basic types (unit, String, Json, and axum::http::StatusCode) into HTTP responses. It does this by providing an implementation of the axum::response::IntoResponse trait for the most common response types. If you need to convert your type into a response, you must either transform it into something that implements IntoResponse or implement IntoResponse yourself, which we’ll demonstrate in the next section.

9.9 Error handling

For error handling, I’ve kept things very simple. We’ll define one enum called Error in error.rs.

Listing 9.14 API service Error enum from src/error.rs

#[derive(Debug)]
pub enum Error {
    Sqlx(StatusCode, String),   
    NotFound,                   
}

We’ll convert errors from sqlx::Error into an HTTP status code and message.

Error::NotFound is what we’ll use to conveniently map responses to HTTP 404s.

Note We’re treating 404s (not found) as errors, but 404s are also a normal HTTP response that doesn’t necessarily indicate an error. For convenience, we’re treating anything that’s not a 200 status code as an error.

There is not much to see with our error type. Next, we need to define the From trait for sqlx::Error, which converts SQLx errors to our error type.

Listing 9.15 API service From implementation for sqlx::Error from src/error.rs

impl From<sqlx::Error> for Error {
    fn from(err: sqlx::Error) -> Error {
        match err {
            sqlx::Error::RowNotFound => Error::NotFound,   
            _ => Error::Sqlx(
                StatusCode::INTERNAL_SERVER_ERROR,         
                err.to_string(),                           
            ),
        }
    }
}

For queries that can’t find matching rows, we return an HTTP 404.

For all other SQLx errors, we return an HTTP 500.

We include the string returned by the SQLx error in the response body of our 500s.

Our From<sqlx::Error> for Error implementation is quite simple: we only handle one case as special, which is the RowNotFound case. For that, we map it to an HTTP 404, which is more helpful than returning a generic 500 error.

Next, we need to make it possible for axum to use our error type as a response, and for that, we’ll implement IntoResponse for Error.

Listing 9.16 API service From implementation for sqlx::Error from src/error.rs

impl IntoResponse for Error {
    fn into_response(self) -> Response {
        match self {
            Error::Sqlx(code, body) => (code, body).into_response(),    
            Error::NotFound => StatusCode::NOT_FOUND.into_response(),   
        }
    }
}

Pull the status code and response body out, and then call into_response() on a tuple of (StatusCode, String) because axum provides an implementation of IntoResponse for us.

Call into_response() on StatusCode::NOT_FOUND, which gives us an empty HTTP 404 response.

You may notice in the preceding code that we don’t even bother constructing a Response, as required by IntoResponse. Thanks to the implementations provided by axum, we merely delegate the response construction to axum using an existing implementation of IntoResponse. This is a neat trick that requires minimal effort on our part. The only case where you wouldn’t want to do this is when the default implementation involves a costly conversion and you have enough information to optimize it better.

9.10 Running the service

Let’s run our service and make sure it behaves as expected. When it’s started with cargo run, we’ll see output similar to what’s shown in figure 9.2. The logging output we see in the figure shows the queries from SQLx at startup, which includes running the migrations. We aren’t required to run the migrations automatically, but this is convenient for testing. In a production service, you would likely not run migrations automatically.

CH09_F02_Matthews

Figure 9.2 Running the API service

We need to test our API, but first, let’s ensure the health check endpoints work as expected. For these tests, I will use the HTTPie (https://httpie.io/) tool, but you could just as easily use curl or another CLI HTTP client.

I’ll run http 127.0.0.1:3000/alive followed by http 127.0.0.1:3000/ready, which will generate an HTTP GET request against each endpoint, with the result shown in figure 9.3. In the output shown in the figure, we see the logging output of our service on the left side and the output from HTTPie on the right side. So far, everything looks good; we can see the HTTP status code is 200 for each request, the request body is simply ok, and the CORS headers are present as expected.

CH09_F03_Matthews

Figure 9.3 Checking service health

Now, it’s time to create a todo. For this, we’ll make an HTTP POST request with http post 127.0.0.1:3000/v1/todos body=‘wash the dishes’, as shown in figure 9.4.

CH09_F04_Matthews

Figure 9.4 Creating a todo with POST

Now, let’s test the HTTP GET methods for the read and list endpoints with http 127.0.0.1:3000/v1/todos/1 (read) and http 127.0.0.1:3000/v1/todos (list), as shown in figure 9.5. Note how the first request (for a specific resource) returns just the todo object, and the second request (to list all resources) returns a list of objects. So far, so good. Next, let’s test the PUT method to update our todo by marking it as completed with http put 127.0.0.1:3000/v1/todos/1 body=‘wash the dishes’ completed:=true, as shown in figure 9.6.

CH09_F05_Matthews

Figure 9.5 Reading todos with GET

CH09_F06_Matthews

Figure 9.6 Updating todos with PUT

Notice how we need to specify both the body and completed fields, which is a bit annoying. It would be nice if we gracefully handled only the required fields when updating a record, but I’ll leave that as an exercise for the reader. Finally, let’s check that we can delete our todo with http delete 127.0.0.1:3000/v1/todos/1, as shown in figure 9.7.

CH09_F07_Matthews

Figure 9.7 Deleting todos with DELETE

Success! It looks like everything works. As an exercise for the reader, I suggest running a few more tests and experimenting with some of the following options:

Summary