This article will teach you how to build a simple CRUD API with Rust using the Rocket framework. We’ll create a RESTful API that runs on a Rocket HTTP server and persists data in an in-memory database.

What is Rocket in Rust? Rocket is a web framework for the Rust programming language that makes it easy to write blazingly fast and secure web applications without sacrificing flexibility, usability, or type safety. Its design was inspired by  RailsFlaskBottle, and Yesod. If you need a smashing performance, get yourself a Rocket.

Let’s get to the main focus of this tutorial. The goal is to create an API with CRUD functionalities for handling Create, Read, Update, and Delete operations against a centralized data store.

More Practice:

Build a Simple API with Rust and Rocket

Prerequisites

Before proceeding, you should have the following requirements to fully grasp the concepts presented in this tutorial.

  • Basic knowledge of API design in Rust or frameworks like Flask, FastAPI, Gin Gonic, etc.
  • Familiarity with the basics of Rust will be beneficial.
  • An API testing software like Postman or Insomnia.

Run the Rocket API Project Locally

  • Download or clone the Rust Rocket API Project from https://github.com/wpcodevo/simple-api-rocket and open the source code in a text editor or IDE.
  • Run cargo r -r in the terminal of the root directory to install all the required dependencies and start the Rocket HTTP server.
  • Once the Rocket server is listening on port 8000 and ready to accept requests, open Postman or Thunder Client VS Code extension and import the Todo.postman_collection.json file to access the collection used in testing the API.
  • Now that you’ve access to the API collection in Postman or Thunder Client, make HTTP requests to the Rocket server to test the endpoints.

Setup the Rust Project with Cargo

Apart from the Makefile and Todo.postman_collection.json files, your folder structure will be similar to the screenshot below at the end of this comprehensive guide.

the project structure for the rust rocket api project

To start, navigate to where you would like to set up your project and create a directory to house the source code. For the seek of this tutorial, you can name the folder simple-api-rocket . Once you have done this, change to the newly-created folder and open it in a text editor or IDE.


mkdir simple-api-rocket
cd simple-api-rocket && code .

Now that you’ve opened the project folder in a code editor, run this command in the terminal of the root directory to initialize the Rust project with Cargo.


cargo init

Next, run the following commands to install the required dependencies:


cargo add rocket@0.5.0-rc.2 --features json
cargo add serde --features derive
cargo add chrono --features serde
cargo add uuid --features v4
  • rocket – A web framework for Rust (nightly).
  • serde – A generic serialization/deserialization framework.
  • chrono – Date and time library for Rust.
  • uuid – A library to generate and parse UUIDs in Rust.

If the latest versions of the dependencies break your app, you can use the versions provided in the Cargo.toml file instead.

Cargo.toml


[package]
name = "simple-api-rocket"
version = "0.1.0"
edition = "2021"

[dependencies]
chrono = { version = "0.4.23", features = ["serde"] }
rocket = { version = "0.5.0-rc.2", features = ["json"] }
serde = { version = "1.0.152", features = ["derive"] }
uuid = { version = "1.2.2", features = ["v4"] }

Let’s get our hands dirty by creating a basic Rocket server that returns a simple JSON object. To do that, open the src/main.rs file and replace its content with the following code.

src/main.rs


use rocket::{get, http::Status, serde::json::Json};
use serde::Serialize;

#[macro_use]
extern crate rocket;

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

#[get("/healthchecker")]
pub async fn health_checker_handler() -> Result<Json<GenericResponse>, Status> {
    const MESSAGE: &str = "Build Simple CRUD API with Rust and Rocket";

    let response_json = GenericResponse {
        status: "success".to_string(),
        message: MESSAGE.to_string(),
    };
    Ok(Json(response_json))
}

#[launch]
fn rocket() -> _ {
    rocket::build().mount("/api", routes![health_checker_handler,])
}

Above, we imported the necessary modules and created a GenericResponse struct that Serde will serialize into a JSON object. Then, we created a /healthchecker route with the Rocket macro to return the JSON object to the client.

Finally, we created a new Rocket instance and defined a /api route to evoke the health_checker_handler function.

If you came from a Node.js background, you may be familiar with Nodemon or ts-node-dev which reloads the server when a file is saved. Well, we have a similar library called cargo-watch in Rust which watches the source code for changes and hot-reloads the server when required files change.

Install the cargo-watch binary with this command:


cargo install cargo-watch 

With that out of the way, run this command to start the Rocket HTTP server and restart the server when any file in the src directory changes.


cargo watch -q -c -w src/ -x run

If you get similar logs in the terminal, then it means the Rocket server is ready to accept requests.

started the rust rocket http server

Now open your browser or an API testing application and make a GET request to the http://localhost:8000/api/healthchecker endpoint. Within a few milliseconds, the Rocket server will return the JSON object.

build simple api with rust and rocket test health checker route

Setup the In-memory Database

For simplicity, we won’t use a real database like Postgres, MySQL, or MongoDB to store the application data. Instead, we’ll create an in-memory database with a vector and wrap it in a Mutex.

Wrapping the vector in a Mutex will provide thread safety when accessing and mutating the data. This ensures that only one thread can access the vector at a time.

To make the in-memory database available to all the route handlers, we’ll use Rust’s smart pointer called Arc along with the Mutex. In the src directory, create a model.rs file and add the following code snippets.

src/model.rs


use chrono::prelude::*;
use serde::{Deserialize, Serialize};
use std::sync::{Arc, 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 struct AppState {
    pub todo_db: Arc<Mutex<Vec<Todo>>>,
}

impl AppState {
    pub fn init() -> AppState {
        AppState {
            todo_db: Arc::new(Mutex::new(Vec::new())),
        }
    }
}

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

Above, we created a Todo struct and added Serde’s macros on top of it. The Deserialize and Serialize macros will allow us to convert the struct to and from JSON. The #[derive( Clone)] macro will allow us to copy the struct into and out of the data store without worrying about the borrow checker.

Then, we created an AppState struct that has a single field todo_db, which is the in-memory database. After that, we implemented the AppState and created an init function to initialize the data store with an empty vector.

Lastly, we created a UpdateTodoSchema struct to help us deserialize the request payload into a struct.

Define the API Response Structs

Here, let’s create structs that implement the [derive(Serialize)] macro to enable us to convert structs into JSON objects before returning them to the client.

So, create a response.rs file in the src directory and add the following structs:

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>,
}

Implement the CRUD API

Oops, quite a lot of configurations. At this point, we’re now ready to create route handlers to implement the CRUD functionalities. Below is the list of routes we’ll create with the get! , post! , patch! , and delete! macros of Rocket.

  • #[get("/healthchecker")] – This route will return a simple health checker JSON object.
  • #[get("/todos?<page>&<limit>")] – This route will return a selected or paginated list of Todo items.
  • #[post("/todos", data = "<body>")] – This route will add a new Todo item to the data store.
  • #[get("/todos/<id>")] – This route will retrieve a single Todo item from the in-memory database and return it to the client.
  • #[patch("/todos/<id>", data = "<body>")] – This route will edit the fields of a Todo item in the data store.
  • #[delete("/todos/<id>")] – This route will delete a Todo item from the in-memory database.

First things first, create a handler.rs file in the src folder and add the following crates and dependencies.

src/handler.rs


use crate::{
    model::{AppState, Todo, UpdateTodoSchema},
    response::{GenericResponse, SingleTodoResponse, TodoData, TodoListResponse},
};
use chrono::prelude::*;
use rocket::{
    delete, get, http::Status, patch, post, response::status::Custom, serde::json::Json, State,
};
use uuid::Uuid;

Create the Health Checker Handler

Here, the #[get("/healthchecker")] macro will define a /healthchecker route for the health_checker_handler function.

When the Rocket server receives a GET request on the /api/healthchecker path, it will delegate the request to the health_checker_handler function.

src/handler.rs


#[get("/healthchecker")]
pub async fn health_checker_handler() -> Result<Json<GenericResponse>, Status> {
    const MESSAGE: &str = "Build Simple CRUD API with Rust and Rocket";

    let response_json = GenericResponse {
        status: "success".to_string(),
        message: MESSAGE.to_string(),
    };
    Ok(Json(response_json))
}

The health_checker_handler function will then create a struct from the GenericResponse type and call Rocket’s Json() method to return the struct as a JSON object.

Get a List of Records Handler

The #[get("/todos?<page>&<limit>")] macro will define a /todos?page=1&limit=10 route for the todos_list_handler function.

When a GET request is made to the /api/todos?page=1&limit=10 endpoint, Rocket will call this route handler to return a vector of Todo items to the client.

This function will take a limit, page, and State type as arguments. To access the vector in the data store, we’ll call the .lock() method on the data.todo_db Mutex. The data.todo_db.lock() method will attempt to acquire the lock on the data store. If the lock is currently held by another thread, the calling thread will block until the lock is released.

If the lock is successfully acquired, a MutexGuard will be returned where we’ll use the unwrap() method to access the vector.

src/handler.rs


#[get("/todos?<page>&<limit>")]
pub async fn todos_list_handler(
    page: Option<usize>,
    limit: Option<usize>,
    data: &State<AppState>,
) -> Result<Json<TodoListResponse>, Status> {
    let vec = data.todo_db.lock().unwrap();

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

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

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

Above, we looped through the vector, skipped a set number of elements, and limited the result before returning the list of Todo items to the client.

Add New Record Handler

Here, the #[post("/todos", data = "<body>")] macro will define a /todos route on the create_todo_handler function.

The Rocket server will call this route handler to add a Todo item to the data store when a POST request is made to the /api/todos endpoint. When a Todo item with that title already exists in the in-memory database, a 409 Conflict error will be sent to the client.

Otherwise, the newly-created Todo item will be returned to the client in the JSON response.

src/handler.rs


#[post("/todos", data = "<body>")]
pub async fn create_todo_handler(
    mut body: Json<Todo>,
    data: &State<AppState>,
) -> Result<Json<SingleTodoResponse>, Custom<Json<GenericResponse>>> {
    let mut vec = data.todo_db.lock().unwrap();

    for todo in vec.iter() {
        if todo.title == body.title {
            let error_response = GenericResponse {
                status: "fail".to_string(),
                message: format!("Todo with title: '{}' already exists", todo.title),
            };
            return Err(Custom(Status::Conflict, Json(error_response)));
        }
    }

    let uuid_id = Uuid::new_v4();
    let datetime = 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.into_inner());

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

    Ok(Json(json_response))
}

Get a Single Record Handler

Here, the #[get("/todos/<id>")] macro will define a /todos/<id> route for the get_todo_handler function. The Rocket server will call this route function to return a Todo item to the client when a GET request is made to the /api/todos/<id> endpoint.

If no Todo item with that ID exists in the data store, a 404 Not Found error response will be sent to the client.

src/handler.rs


#[get("/todos/<id>")]
pub async fn get_todo_handler(
    id: String,
    data: &State<AppState>,
) -> Result<Json<SingleTodoResponse>, Custom<Json<GenericResponse>>> {
    let vec = data.todo_db.lock().unwrap();

    for todo in vec.iter() {
        if todo.id == Some(id.to_owned()) {
            let json_response = SingleTodoResponse {
                status: "success".to_string(),
                data: TodoData { todo: todo.clone() },
            };

            return Ok(Json(json_response));
        }
    }

    let error_response = GenericResponse {
        status: "fail".to_string(),
        message: format!("Todo with ID: {} not found", id),
    };
    Err(Custom(Status::NotFound, Json(error_response)))
}

Edit Record Handler

Now let’s create a route handler to perform the UPDATE operation of CRUD. To do this, we’ll use the #[patch("/todos/<id>", data = "<body>")] macro to define a /todos/<id> route for the edit_todo_handler function.

The Rocket server will call this route function to modify the fields of a Todo item when a PATCH request is made to the /api/todos/<id> endpoint.

src/handler.rs


#[patch("/todos/<id>", data = "<body>")]
pub async fn edit_todo_handler(
    id: String,
    body: Json<UpdateTodoSchema>,
    data: &State<AppState>,
) -> Result<Json<SingleTodoResponse>, Custom<Json<GenericResponse>>> {
    let mut vec = data.todo_db.lock().unwrap();

    for todo in vec.iter_mut() {
        if todo.id == Some(id.clone()) {
            let datetime = Utc::now();
            let title = body.title.to_owned().unwrap_or(todo.title.to_owned());
            let content = body.content.to_owned().unwrap_or(todo.content.to_owned());
            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: if body.completed.is_some() {
                    body.completed
                } else {
                    todo.completed
                },
                createdAt: todo.createdAt,
                updatedAt: Some(datetime),
            };
            *todo = payload;

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

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

    Err(Custom(Status::NotFound, Json(error_response)))
}

Delete a Record Handler

Here, the #[delete("/todos/<id>")] macro will define a /todos/<id> route for the delete_todo_handler function. The Rocket server will call this route function to delete a Todo item in the data store when a DELETE request is made to the /api/todos/<id> endpoint.

If no record with that ID exists in the in-memory database, a 404 Not Found error response will be sent to the client.

src/handler.rs


#[delete("/todos/<id>")]
pub async fn delete_todo_handler(
    id: String,
    data: &State<AppState>,
) -> Result<Status, Custom<Json<GenericResponse>>> {
    let mut vec = data.todo_db.lock().unwrap();

    for todo in vec.iter_mut() {
        if todo.id == Some(id.clone()) {
            vec.retain(|todo| todo.id != Some(id.to_owned()));
            return Ok(Status::NoContent);
        }
    }

    let error_response = GenericResponse {
        status: "fail".to_string(),
        message: format!("Todo with ID: {} not found", id),
    };
    Err(Custom(Status::NotFound, Json(error_response)))
}

Complete Route Handlers

src/handler.rs


use crate::{
    model::{AppState, Todo, UpdateTodoSchema},
    response::{GenericResponse, SingleTodoResponse, TodoData, TodoListResponse},
};
use chrono::prelude::*;
use rocket::{
    delete, get, http::Status, patch, post, response::status::Custom, serde::json::Json, State,
};
use uuid::Uuid;

#[get("/healthchecker")]
pub async fn health_checker_handler() -> Result<Json<GenericResponse>, Status> {
    const MESSAGE: &str = "Build Simple CRUD API with Rust and Rocket";

    let response_json = GenericResponse {
        status: "success".to_string(),
        message: MESSAGE.to_string(),
    };
    Ok(Json(response_json))
}

#[get("/todos?<page>&<limit>")]
pub async fn todos_list_handler(
    page: Option<usize>,
    limit: Option<usize>,
    data: &State<AppState>,
) -> Result<Json<TodoListResponse>, Status> {
    let vec = data.todo_db.lock().unwrap();

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

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

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

#[post("/todos", data = "<body>")]
pub async fn create_todo_handler(
    mut body: Json<Todo>,
    data: &State<AppState>,
) -> Result<Json<SingleTodoResponse>, Custom<Json<GenericResponse>>> {
    let mut vec = data.todo_db.lock().unwrap();

    for todo in vec.iter() {
        if todo.title == body.title {
            let error_response = GenericResponse {
                status: "fail".to_string(),
                message: format!("Todo with title: '{}' already exists", todo.title),
            };
            return Err(Custom(Status::Conflict, Json(error_response)));
        }
    }

    let uuid_id = Uuid::new_v4();
    let datetime = 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.into_inner());

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

    Ok(Json(json_response))
}

#[get("/todos/<id>")]
pub async fn get_todo_handler(
    id: String,
    data: &State<AppState>,
) -> Result<Json<SingleTodoResponse>, Custom<Json<GenericResponse>>> {
    let vec = data.todo_db.lock().unwrap();

    for todo in vec.iter() {
        if todo.id == Some(id.to_owned()) {
            let json_response = SingleTodoResponse {
                status: "success".to_string(),
                data: TodoData { todo: todo.clone() },
            };

            return Ok(Json(json_response));
        }
    }

    let error_response = GenericResponse {
        status: "fail".to_string(),
        message: format!("Todo with ID: {} not found", id),
    };
    Err(Custom(Status::NotFound, Json(error_response)))
}

#[patch("/todos/<id>", data = "<body>")]
pub async fn edit_todo_handler(
    id: String,
    body: Json<UpdateTodoSchema>,
    data: &State<AppState>,
) -> Result<Json<SingleTodoResponse>, Custom<Json<GenericResponse>>> {
    let mut vec = data.todo_db.lock().unwrap();

    for todo in vec.iter_mut() {
        if todo.id == Some(id.clone()) {
            let datetime = Utc::now();
            let title = body.title.to_owned().unwrap_or(todo.title.to_owned());
            let content = body.content.to_owned().unwrap_or(todo.content.to_owned());
            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: if body.completed.is_some() {
                    body.completed
                } else {
                    todo.completed
                },
                createdAt: todo.createdAt,
                updatedAt: Some(datetime),
            };
            *todo = payload;

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

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

    Err(Custom(Status::NotFound, Json(error_response)))
}

#[delete("/todos/<id>")]
pub async fn delete_todo_handler(
    id: String,
    data: &State<AppState>,
) -> Result<Status, Custom<Json<GenericResponse>>> {
    let mut vec = data.todo_db.lock().unwrap();

    for todo in vec.iter_mut() {
        if todo.id == Some(id.clone()) {
            vec.retain(|todo| todo.id != Some(id.to_owned()));
            return Ok(Status::NoContent);
        }
    }

    let error_response = GenericResponse {
        status: "fail".to_string(),
        message: format!("Todo with ID: {} not found", id),
    };
    Err(Custom(Status::NotFound, Json(error_response)))
}

Create Route Paths with Rocket

We are now ready to attach the route handlers to the Rocket server. To do that, import the handler functions into the src/main.rs file and add them to Rocket’s routes![] macro.

src/main.rs


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

#[macro_use]
extern crate rocket;

mod handler;
mod model;
mod response;

#[launch]
fn rocket() -> _ {
    let app_data = model::AppState::init();
    rocket::build().manage(app_data).mount(
        "/api",
        routes![
            health_checker_handler,
            todos_list_handler,
            create_todo_handler,
            get_todo_handler,
            edit_todo_handler,
            delete_todo_handler
        ],
    )
}

Now run the command below to start the Rocket HTTP server:


cargo watch -q -c -w src/ -x run

If you followed the instructions correctly, the Rocket server will start without errors and you’ll see the route handlers under the Routes: section in the terminal.

started the rust rocket server successfully

Test the Rust Rocket API

Now that the Rocket server is ready to accept requests, open Postman and test the endpoints. To access the collection I used in testing the Rust API, clone the Rocket API project and import the Todo.postman_collection.json file into Postman.

Perform CREATE Operation

To add a new todo item to the in-memory database, add the JSON object below to the request body tab in Postman and make a POST request to the http://localhost:8000/api/todos endpoint.


{
    "title": "Learn how to create a CRUD API with Rocket framework in Rust",
    "content": "Rust will soon be the most popular programming language"
}

The Rocket server will then delegate the request to the create_todo_handler route function where the request payload will be extracted and assigned to a body variable.

Next, the route handler will query the in-memory database to check if a record with that title already exists. If true, a 204 Conflict error will be returned to the client. Otherwise, a UUID will be generated and assigned to the id field before the vec.push() method will be called to add the new record to the data store.

rust rocket crud api add new todo item

After that, the newly-created Todo item will be returned to the client in the JSON response.

Perform UPDATE Operation

To edit the fields of a Todo item, modify the fields in the JSON object and make a PATCH request to the http://localhost:8000/api/todos/<id> endpoint.


{
    "title": "This is a new title for the Rocket CRUD API 😂",
    "completed": true
}

Once the Rocket server receives the request, the edit_todo_handler function will be called to modify the fields of the Todo item that matches the ID. If no record with that ID exists in the database, a 404 Not Found error will be returned to the client.

rust rocket crud api edit a todo item

After the fields have been updated, the newly-updated record will be returned in the JSON response.

Perform GET Operation

Here, you can make a GET request to the http://localhost:8000/api/todos?page=1&limit=10 endpoint to retrieve a selected list of Todo items.

If you omit the page and limit query parameters in the URL, the Rocket server will only return the first 10 results.

rust rocket crud api get all records

Perform DELETE Operation

Now let’s perform the last CRUD operation. To delete a Todo item from the in-memory database, append the record ID to the URL, and make a DELETE request to the http://localhost:8000/api/todos/<id> endpoint. If no record with that ID exists, the Rocket server will return a 404 Not Found error.

rust rocket crud api delete a record

However, if a record with that ID exists, the vec.retain() method will be called to delete it from the data store. After that, a 204 No Content status code will be returned to the client.

Conclusion

If you made it this far, am proud of you. In this tutorial, you learned how to build a simple CRUD API in Rust using the Rocket framework. Also, you learned how to use a vector, Mutex, and Arc to set up an in-memory database.

You can find the complete source code of the Rust Rocket API on GitHub.