If you’re new to Rust and eager to build your first API, you’re in luck: this article will guide you step by step through building a simple REST API in Rust using the Axum framework.

To keep things simple and reduce the number of moving parts, we’ll store the data in memory using Rust’s smart pointer called Arc and the Mutex provided by the Tokio runtime, rather than using a real database that would require an ORM or database driver.

Why choose Axum? Axum is a Rust web application framework built on top of the Tokio runtime, which uses Rust’s async features to provide scalable web applications. It offers a flexible routing system, middleware, asynchronous handlers, support for JSON and form-encoded request bodies, and integration with other Rust libraries.

Building a Todo list application is a common practice when learning new frameworks and programming languages, so we’ll use that as our example and focus on the API aspect of the Todo list application.

Once you’ve completed this tutorial and understood the concepts, you’ll be able to build the same API using other frameworks like Warp, Actix-Web, Rocket, and more. Without further ado, let’s get started with the tutorial.

More practice:

Create a Simple API in Rust using the Axum Framework

Run the Rust Axum API on your Machine

Follow these steps to run the Rust Axum API on your local machine:

  1. Download or clone the Rust API project from the following GitHub repository: https://github.com/wpcodevo/simple-api-rust-axum. Once downloaded, open the source code in your favourite IDE.
  2. In the terminal of the root directory, run cargo run. This will install the project’s dependencies and start the Axum HTTP server.
  3. To conveniently test the API, you can import the Todo.postman_collection.json file provided in the root directory into either Postman or the Thunder Client VS Code extension. This collection contains a set of predefined requests that you can use to quickly interact with the API and verify its behaviour.

Setup the Rust Project

After completing this tutorial, your file and folder structure should resemble the screenshot below, with the exception of the Makefile and Todo.postman_collection.json files:

Folder and File Structure of the Simple Axum API Project

To get started with initializing the Rust project, navigate to the desired directory where you wish to keep the source code and open a new terminal. Then, run the following command:


mkdir simple-api-rust-axum
cd simple-api-rust-axum
cargo init

After running the commands, a new directory named simple-api-rust-axum will be created and the Rust project will be initialized inside it. Next, open the project in your preferred IDE or text editor, and then navigate to the integrated terminal within the IDE. Finally, execute the following commands in the terminal to install the required dependencies for the project.


cargo add axum
cargo add tokio -F full
cargo add chrono -F serde
cargo add serde -F derive
cargo add serde_json
cargo add uuid -F "v4 serde"
cargo add tower-http -F "cors"

  • axum – A Rust web application framework built on top of Tokio, featuring a flexible routing system, middleware, and support for JSON and form-encoded request bodies.
  • tokio – A Rust runtime for building reliable and asynchronous I/O services, such as network connections, filesystem access, and timers.
  • chrono – A Rust library for working with date and time.
  • serde – A Rust library for serializing and deserializing data structures to and from JSON, YAML, and other formats.
  • serde_json – A Rust library that provides JSON serialization and deserialization based on Serde.
  • uuid – A Rust library for generating, parsing, and manipulating UUIDs.
  • tower-http -A Rust library that provides HTTP middleware and utilities for use with the Tower framework.

As Rust and its crates continue to evolve rapidly, new versions may introduce breaking changes that could cause errors in your application. If you encounter such issues, you can revert to the versions specified in the Cargo.toml file below. Additionally, please leave a comment indicating which crate caused the issue so that I can update both the project and the article accordingly.

Cargo.toml


[package]
name = "simple-api-rust-axum"
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.7.2"
chrono = { version = "0.4.24", features = ["serde"] }
serde = { version = "1.0.159", features = ["derive"] }
serde_json = "1.0.95"
tokio = { version = "1.26.0", features = ["full"] }
tower-http = { version = "0.5.0", features = ["cors"] }
uuid = { version = "1.3.0", features = ["v4","serde"] }

Let’s create a basic Axum HTTP server with a health checker route to get started. We will begin with just one endpoint that returns a simple JSON object. To do this, navigate to the src/main.rs file and replace its content with the following code.

src/main.rs


use axum::{response::IntoResponse, routing::get, Json, Router};

async fn health_checker_handler() -> impl IntoResponse {
    const MESSAGE: &str = "Build Simple CRUD API in Rust using Axum";

    let json_response = serde_json::json!({
        "status": "success",
        "message": MESSAGE
    });

    Json(json_response)
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/api/healthchecker", get(health_checker_handler));

    println!("🚀 Server started successfully");
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

In the code snippet above, we first imported the necessary components of the Axum framework. Next, we defined an asynchronous handler function called health_checker_handler, which returns a JSON response with a success status and message. We then used the Router::new() method to create a new router instance and called the get() method to specify that the health checker endpoint should only respond to GET requests.

Finally, we used the axum::Server struct to bind the HTTP server to the address 0.0.0.0:8000 and called the serve() method to start serving requests on that port.

Starting the Axum HTTP server is typically done using the cargo run command, but it doesn’t have a feature to automatically rebuild the project when changes are made to the source code. To address this, we can install a third-party package called cargo-watch. Simply run the command cargo install cargo-watch to install it.

Once installed, you can use the command cargo watch -q -c -w src/ -x run to build the project and start the server. This command watches the ‘src‘ directory for changes and restarts the server accordingly.

Now that the server is up and running, simply navigate to http://localhost:8000/api/healthchecker in your browser to view the returned JSON object.

Testing the Health Checker Route of the Rust API with Axum

Create an In-memory Database

Let’s now create an in-memory data store that can be shared across the Axum route handlers. To achieve this, we’ll use Rust’s Arc smart pointer, which allows sharing the database across multiple threads.

We’ll also utilize Tokio’s Mutex to ensure the data store is consistent when accessed and modified concurrently by multiple threads. Create a model.rs file in the ‘src‘ directory and add the following code to it:

src/model.rs


use chrono::prelude::*;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::Mutex;

#[allow(non_snake_case)]
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Todo {
    pub id: Option<String>,
    pub title: String,
    pub content: String,
    pub completed: Option<bool>,
    pub createdAt: Option<DateTime<Utc>>,
    pub updatedAt: Option<DateTime<Utc>>,
}

pub type DB = Arc<Mutex<Vec<Todo>>>;

pub fn todo_db() -> DB {
    Arc::new(Mutex::new(Vec::new()))
}

#[derive(Debug, Deserialize, Default)]
pub struct QueryOptions {
    pub page: Option<usize>,
    pub limit: Option<usize>,
}

#[allow(non_snake_case)]
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct UpdateTodoSchema {
    pub title: Option<String>,
    pub content: Option<String>,
    pub completed: Option<bool>,
}


If you review the code above, you may notice other structures like QueryOptions and UpdateTodoSchema. The QueryOptions structure will enable us to deserialize the request query parameters, while the UpdateTodoSchema structure will allow us to deserialize the request body when updating a record. Although these two structures are supposed to be in a separate file, for simplicity’s sake, we will keep them in the src/model.rs file.

Define the API Response Structs

We’ll proceed to create data structures that will help us to correctly type the response bodies. To do this, create a new file named response.rs in the src directory and add the following code to define the necessary structures.

src/response.rs


use crate::model::Todo;
use serde::Serialize;

#[derive(Serialize)]
pub struct GenericResponse {
    pub status: String,
    pub message: String,
}

#[derive(Serialize, Debug)]
pub struct TodoData {
    pub todo: Todo,
}

#[derive(Serialize, Debug)]
pub struct SingleTodoResponse {
    pub status: String,
    pub data: TodoData,
}

#[derive(Serialize, Debug)]
pub struct TodoListResponse {
    pub status: String,
    pub results: usize,
    pub todos: Vec<Todo>,
}


Create the Axum API Route Handlers

With the in-memory database and API response structures set up, the next step is to create Axum route handlers that will implement the CRUD operations on the database. To get started, create a handler.rs file in the ‘src‘ directory and include the following dependencies and crates.

src/handler.rs


use axum::{
    extract::{Path, Query, State},
    http::StatusCode,
    response::IntoResponse,
    Json,
};
use uuid::Uuid;

use crate::{
    model::{QueryOptions, Todo, UpdateTodoSchema, DB},
    response::{SingleTodoResponse, TodoData, TodoListResponse},
};

Axum Route Function to Fetch All Records

This code sets up a function that will handle GET requests to the /api/todos endpoint. When a GET request is made to this endpoint, the function will fetch a list of todos from the in-memory database and return them in a JSON response.

To fetch the todos, the function will first check the URL for any query parameters, such as page and limit. If these parameters are present, they will be parsed into a QueryOptions struct, otherwise default values will be used.

Once the function has the query parameters, it will create a lock on the in-memory database to prevent multiple threads from modifying it simultaneously. It will then fetch the list of todos from the database, filter the results based on the query parameters, and return the filtered list in a JSON response.

To add this functionality to your project, you can copy and paste the code into the src/handler.rs file.

src/handler.rs


pub async fn todos_list_handler(
    opts: Option<Query<QueryOptions>>,
    State(db): State<DB>,
) -> impl IntoResponse {
    let todos = db.lock().await;

    let Query(opts) = opts.unwrap_or_default();

    let limit = opts.limit.unwrap_or(10);
    let offset = (opts.page.unwrap_or(1) - 1) * limit;

    let todos: Vec<Todo> = todos.clone().into_iter().skip(offset).take(limit).collect();

    let json_response = TodoListResponse {
        status: "success".to_string(),
        results: todos.len(),
        todos,
    };

    Json(json_response)
}

Axum Route Function to Add a Record

Now let’s create an Axum route handler function to add a new todo item to the in-memory database. When the handler function is called, it will first deserialize the request body into the Todo struct.

After that, the handler function will acquire a lock on the database and check if a todo item with the same title as provided in the request body already exists. If it does, the function will return an HTTP response with a status code of 409 Conflict and a JSON payload indicating that the todo already exists.

In case the todo item doesn’t exist in the database, the handler function will create a new UUID and timestamp for the todo item, assign those values to the relevant fields of the todo object, add it to the database, and return an HTTP response with a status code of 201 Created, along with a JSON payload containing the newly created todo item.

To implement this handler function, add the following code to the src/handler.rs file.

src/handler.rs


pub async fn create_todo_handler(
    State(db): State<DB>,
    Json(mut body): Json<Todo>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    let mut vec = db.lock().await;

    if let Some(todo) = vec.iter().find(|todo| todo.title == body.title) {
        let error_response = serde_json::json!({
            "status": "fail",
            "message": format!("Todo with title: '{}' already exists", todo.title),
        });
        return Err((StatusCode::CONFLICT, Json(error_response)));
    }

    let uuid_id = Uuid::new_v4();
    let datetime = chrono::Utc::now();

    body.id = Some(uuid_id.to_string());
    body.completed = Some(false);
    body.createdAt = Some(datetime);
    body.updatedAt = Some(datetime);

    let todo = body.to_owned();

    vec.push(body);

    let json_response = SingleTodoResponse {
        status: "success".to_string(),
        data: TodoData { todo },
    };

    Ok((StatusCode::CREATED, Json(json_response)))
}

Axum Route Function to Retrieve a Record

Next, let’s create an Axum route handler function to retrieve a single todo item from the in-memory database. This function will be called when a GET request is made to the /api/todos/:id endpoint.

When the function is called, it will first convert the UUID provided in the request parameter to a string and then acquire a lock on the database. It will then query the database for a todo item with the specified ID. If a match is found, the handler function will return an HTTP status code of 200 OK along with a JSON payload containing the todo item.

If no todo item with the specified ID exists, the handler function will return an HTTP status code of 404 NOT FOUND along with a JSON payload indicating that the requested todo item was not found.

To implement this handler function, add the following code to the src/handler.rs file.

src/handler.rs


pub async fn get_todo_handler(
    Path(id): Path<Uuid>,
    State(db): State<DB>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    let id = id.to_string();
    let vec = db.lock().await;

    if let Some(todo) = vec.iter().find(|todo| todo.id == Some(id.to_owned())) {
        let json_response = SingleTodoResponse {
            status: "success".to_string(),
            data: TodoData { todo: todo.clone() },
        };
        return Ok((StatusCode::OK, Json(json_response)));
    }

    let error_response = serde_json::json!({
        "status": "fail",
        "message": format!("Todo with ID: {} not found", id)
    });
    Err((StatusCode::NOT_FOUND, Json(error_response)))
}

Axum Route Function to Edit a Record

Here, we will create a route handler to update a single todo item in the in-memory database. When a PATCH request is made to the /api/todos/:id endpoint, this route function will be invoked with three arguments – the ID of the todo item to be edited, a reference to the in-memory database, and a JSON payload containing the updates to be made to the todo item.

To begin with, the function will convert the UUID in the request parameter to a string and acquire a lock on the database. After that, it will search the database for a todo item with the specified ID. If a match is found, the function will extract the new title, content, and completion status from the JSON payload. If any of these values are missing, it will fall back to the existing values in the todo item.

Next, the function will create a new todo item with the updated values and replace the original todo item in the database with the new one. It will then return an HTTP status code of 200 OK along with a JSON payload containing the updated todo item.

If the function is unable to find a todo item with the specified ID in the database, it will return an HTTP status code of 404 NOT FOUND along with a JSON payload indicating that the requested todo item was not found.

src/handler.rs


pub async fn edit_todo_handler(
    Path(id): Path<Uuid>,
    State(db): State<DB>,
    Json(body): Json<UpdateTodoSchema>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    let id = id.to_string();
    let mut vec = db.lock().await;

    if let Some(todo) = vec.iter_mut().find(|todo| todo.id == Some(id.clone())) {
        let datetime = chrono::Utc::now();
        let title = body
            .title
            .to_owned()
            .unwrap_or_else(|| todo.title.to_owned());
        let content = body
            .content
            .to_owned()
            .unwrap_or_else(|| todo.content.to_owned());
        let completed = body.completed.unwrap_or(todo.completed.unwrap());
        let payload = Todo {
            id: todo.id.to_owned(),
            title: if !title.is_empty() {
                title
            } else {
                todo.title.to_owned()
            },
            content: if !content.is_empty() {
                content
            } else {
                todo.content.to_owned()
            },
            completed: Some(completed),
            createdAt: todo.createdAt,
            updatedAt: Some(datetime),
        };
        *todo = payload;

        let json_response = SingleTodoResponse {
            status: "success".to_string(),
            data: TodoData { todo: todo.clone() },
        };
        Ok((StatusCode::OK, Json(json_response)))
    } else {
        let error_response = serde_json::json!({
            "status": "fail",
            "message": format!("Todo with ID: {} not found", id)
        });

        Err((StatusCode::NOT_FOUND, Json(error_response)))
    }
}

Axum Route Function to Delete a Record

Finally, let’s create an Axum route handler function that deletes a single todo item from the in-memory database. This function will be called when a DELETE request is made to the /api/todos/:id endpoint.

Upon being called, the handler function will first convert the todo item’s ID from UUID to a string. It will then acquire a lock on the database and check if the todo item with the specified ID exists. If it does, the handler function will remove the todo item from the database and return an HTTP status code of 204 No Content.

If the requested todo item is not found in the database, the handler function will return an HTTP status code of 404 NOT FOUND, accompanied by a JSON payload indicating that the requested todo item could not be found.

src/handler.rs


pub async fn delete_todo_handler(
    Path(id): Path<Uuid>,
    State(db): State<DB>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    let id = id.to_string();
    let mut vec = db.lock().await;

    if let Some(pos) = vec.iter().position(|todo| todo.id == Some(id.clone())) {
        vec.remove(pos);
        return Ok((StatusCode::NO_CONTENT, Json("")));
    }

    let error_response = serde_json::json!({
        "status": "fail",
        "message": format!("Todo with ID: {} not found", id)
    });

    Err((StatusCode::NOT_FOUND, Json(error_response)))
}


The Complete Axum Route Functions

src/handler.rs


use axum::{
    extract::{Path, Query, State},
    http::StatusCode,
    response::IntoResponse,
    Json,
};
use uuid::Uuid;

use crate::{
    model::{QueryOptions, Todo, UpdateTodoSchema, DB},
    response::{SingleTodoResponse, TodoData, TodoListResponse},
};

pub async fn health_checker_handler() -> impl IntoResponse {
    const MESSAGE: &str = "Build Simple CRUD API in Rust using Axum";

    let json_response = serde_json::json!({
        "status": "success",
        "message": MESSAGE
    });

    Json(json_response)
}

pub async fn todos_list_handler(
    opts: Option<Query<QueryOptions>>,
    State(db): State<DB>,
) -> impl IntoResponse {
    let todos = db.lock().await;

    let Query(opts) = opts.unwrap_or_default();

    let limit = opts.limit.unwrap_or(10);
    let offset = (opts.page.unwrap_or(1) - 1) * limit;

    let todos: Vec<Todo> = todos.clone().into_iter().skip(offset).take(limit).collect();

    let json_response = TodoListResponse {
        status: "success".to_string(),
        results: todos.len(),
        todos,
    };

    Json(json_response)
}

pub async fn create_todo_handler(
    State(db): State<DB>,
    Json(mut body): Json<Todo>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    let mut vec = db.lock().await;

    if let Some(todo) = vec.iter().find(|todo| todo.title == body.title) {
        let error_response = serde_json::json!({
            "status": "fail",
            "message": format!("Todo with title: '{}' already exists", todo.title),
        });
        return Err((StatusCode::CONFLICT, Json(error_response)));
    }

    let uuid_id = Uuid::new_v4();
    let datetime = chrono::Utc::now();

    body.id = Some(uuid_id.to_string());
    body.completed = Some(false);
    body.createdAt = Some(datetime);
    body.updatedAt = Some(datetime);

    let todo = body.to_owned();

    vec.push(body);

    let json_response = SingleTodoResponse {
        status: "success".to_string(),
        data: TodoData { todo },
    };

    Ok((StatusCode::CREATED, Json(json_response)))
}

pub async fn get_todo_handler(
    Path(id): Path<Uuid>,
    State(db): State<DB>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    let id = id.to_string();
    let vec = db.lock().await;

    if let Some(todo) = vec.iter().find(|todo| todo.id == Some(id.to_owned())) {
        let json_response = SingleTodoResponse {
            status: "success".to_string(),
            data: TodoData { todo: todo.clone() },
        };
        return Ok((StatusCode::OK, Json(json_response)));
    }

    let error_response = serde_json::json!({
        "status": "fail",
        "message": format!("Todo with ID: {} not found", id)
    });
    Err((StatusCode::NOT_FOUND, Json(error_response)))
}

pub async fn edit_todo_handler(
    Path(id): Path<Uuid>,
    State(db): State<DB>,
    Json(body): Json<UpdateTodoSchema>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    let id = id.to_string();
    let mut vec = db.lock().await;

    if let Some(todo) = vec.iter_mut().find(|todo| todo.id == Some(id.clone())) {
        let datetime = chrono::Utc::now();
        let title = body
            .title
            .to_owned()
            .unwrap_or_else(|| todo.title.to_owned());
        let content = body
            .content
            .to_owned()
            .unwrap_or_else(|| todo.content.to_owned());
        let completed = body.completed.unwrap_or(todo.completed.unwrap());
        let payload = Todo {
            id: todo.id.to_owned(),
            title: if !title.is_empty() {
                title
            } else {
                todo.title.to_owned()
            },
            content: if !content.is_empty() {
                content
            } else {
                todo.content.to_owned()
            },
            completed: Some(completed),
            createdAt: todo.createdAt,
            updatedAt: Some(datetime),
        };
        *todo = payload;

        let json_response = SingleTodoResponse {
            status: "success".to_string(),
            data: TodoData { todo: todo.clone() },
        };
        Ok((StatusCode::OK, Json(json_response)))
    } else {
        let error_response = serde_json::json!({
            "status": "fail",
            "message": format!("Todo with ID: {} not found", id)
        });

        Err((StatusCode::NOT_FOUND, Json(error_response)))
    }
}

pub async fn delete_todo_handler(
    Path(id): Path<Uuid>,
    State(db): State<DB>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
    let id = id.to_string();
    let mut vec = db.lock().await;

    if let Some(pos) = vec.iter().position(|todo| todo.id == Some(id.clone())) {
        vec.remove(pos);
        return Ok((StatusCode::NO_CONTENT, Json("")));
    }

    let error_response = serde_json::json!({
        "status": "fail",
        "message": format!("Todo with ID: {} not found", id)
    });

    Err((StatusCode::NOT_FOUND, Json(error_response)))
}


Create the Axum API Routes

With all the route handlers created, the next step is to build an Axum router that maps the different URLs to their respective HTTP methods and handlers. Additionally, we need to set a state object on the router to make the in-memory database available to all the route handlers. To accomplish this, create a file called route.rs in the src directory and add the following code.

src/route.rs


use axum::{
    routing::{get, post},
    Router,
};

use crate::{
    handler::{
        create_todo_handler, delete_todo_handler, edit_todo_handler, get_todo_handler,
        health_checker_handler, todos_list_handler,
    },
    model,
};

pub fn create_router() -> Router {
    let db = model::todo_db();

    Router::new()
        .route("/api/healthchecker", get(health_checker_handler))
        .route(
            "/api/todos",
            post(create_todo_handler).get(todos_list_handler),
        )
        .route(
            "/api/todos/:id",
            get(get_todo_handler)
                .patch(edit_todo_handler)
                .delete(delete_todo_handler),
        )
        .with_state(db)
}


Register the API Routes and Setup CORS in Axum

Lastly, we need to modify the src/main.rs file to register the Axum router and enable CORS to allow incoming requests from specific sources. To do this, open the src/main.rs file and replace its contents with the following code.

src/main.rs


mod handler;
mod model;
mod response;
mod route;

use axum::http::{
    header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
    HeaderValue, Method,
};
use route::create_router;
use tower_http::cors::CorsLayer;

#[tokio::main]
async fn main() {
    let cors = CorsLayer::new()
        .allow_origin("http://localhost:3000".parse::<HeaderValue>().unwrap())
        .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
        .allow_credentials(true)
        .allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE]);

    let app = create_router().layer(cors);

    println!("🚀 Server started successfully");
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Congratulations! You’ve completed the API implementation for the todo application. To test the endpoints, you can start the server by running cargo run or cargo watch -q -c -w src/ -x run, and then use an API testing software to send requests.

If you want to test the API without manually entering the URLs, you can download or clone the source code from https://github.com/wpcodevo/simple-api-rust-axum and import the Todo.postman_collection.json file into either Postman or the Thunder Client VS Code extension. This file contains predefined requests that you can use to test the todo API and get familiar with its functionality.

Conclusion

You now have a functional API with CRUD operations for a todo application. However, there are many more features you can add to make it even better. For instance, you could implement JWT authentication, restrict access to specific routes, or even try building the same API in other Rust web frameworks that follow a similar architecture but use different syntax.

I hope you enjoyed this tutorial and picked up some new skills. If you have any questions or feedback, feel free to leave them in the comments below. Thank you for reading!