Creating a Resource-Based RESTful API in Rust: A Step-by-Step Guide

Radovan Stevanovic
Level Up Coding
Published in
5 min readJan 3, 2023

--

This tutorial will teach us how to create a resource-based RESTful API in Rust using the repository pattern. We will use popular Rust libraries and tools like Serde, Diesel, and Rocket to build a simple to-do list application that supports CRUD (create, read, update, delete) operations on tasks.

We will start by defining the resource models and creating the repository trait. Then, we will implement the repository trait using Diesel and a SQL database like PostgreSQL. Next, we will create a service struct that depends on the repository trait and contains the business logic for our API. Finally, we will use a web framework like Rocket to create a controller that exposes the service methods as RESTful endpoints.

Let’s get started!

Step 1: Define the resource models

First, we need to define the data models that our API will expose as resources. For example, if we are building an API for a to-do list application, we might have a Task model like this:

#[derive(Debug, Deserialize, Serialize)]
struct Task {
id: Option<i32>,
title: String,
completed: bool,
}

We use the #[derive(Debug, Deserialize, Serialize)] annotation to tell the Serde library that we want to be able to serialize and deserialize instances of this struct. This will allow us to convert between JSON representations of tasks and Rust structs easily.

Step 2: Create the repository trait

Next, we will create a repository trait that defines the methods we want to use for interacting with the Task resource in the database. For example:

trait TaskRepository {
fn get(&self, id: i32) -> Result<Task, RepositoryError>;
fn get_all(&self) -> Result<Vec<Task>, RepositoryError>;
fn save(&self, task: &Task) -> Result<(), RepositoryError>;
fn delete(&self, id: i32) -> Result<(), RepositoryError>;
}

The TaskRepository trait defines four methods: get, get_all, save, and delete. These methods allow us to retrieve a single task, retrieve a list of all tasks, save a new task, and delete an existing task. We use the Result type to indicate success or failure and define a custom RepositoryError type to represent any errors that might occur while interacting with the database.

Step 3: Implement the repository trait

Now we need to implement the TaskRepository trait using a Rust library for interacting with our chosen database. For example, if we are using a SQL database like PostgreSQL, we could use the Diesel library to implement the repository trait like this:

use diesel::prelude::*;
use diesel::pg::PgConnection;

struct DieselTaskRepository {
conn: PgConnection,
}

impl TaskRepository for DieselTaskRepository {
fn get(&self, id: i32) -> Result<Task, RepositoryError> {
use crate::schema::tasks::dsl::*;

let task = tasks.find(id).first::<Task>(&self.conn)?;
Ok(task)
}

fn get_all(&self) -> Result<Vec<Task>, RepositoryError> {
use crate::schema::tasks::dsl::*;

let tasks = tasks.load::<Task>(&self.conn)?;
Ok(tasks)
}

fn save(&self, task: &Task) -> Result<(), RepositoryError> {
use crate::schema::tasks;

diesel::insert_into(tasks::table)
.values(task)
.execute(&self.conn)?;
Ok(())
}

fn delete(&self, id: i32) -> Result<(), RepositoryError> {
use crate::schema::tasks::dsl::*;

diesel::delete(tasks.find(id)).execute(&self.conn)?;
Ok(())
}
}

The DieselTaskRepository struct has a single field, conn, which is a connection to the PostgreSQL database. The trait implementation uses this connection to execute the appropriate SQL queries for each method.

Step 4: Create the service struct

Next, we will create a service struct that depends on the TaskRepository trait. This struct will contain the business logic for our apis, such as validation, authorization, and error handling.

struct TaskService<T: TaskRepository> {
repository: T,
}

impl<T: TaskRepository> TaskService<T> {
fn get(&self, id: i32) -> Result<Task, ServiceError> {
let task = self.repository.get(id)?;
Ok(task)
}

fn get_all(&self) -> Result<Vec<Task>, ServiceError> {
let tasks = self.repository.get_all()?;
Ok(tasks)
}

fn create(&self, task: &Task) -> Result<(), ServiceError> {
if task.title.is_empty() {
return Err(ServiceError::InvalidTask);
}

self.repository.save(task)?;
Ok(())
}

fn update(&self, id: i32, task: &Task) -> Result<(), ServiceError> {
if task.title.is_empty() {
return Err(ServiceError::InvalidTask);
}

self.repository.save(task)?;
Ok(())
}

fn delete(&self, id: i32) -> Result<(), ServiceError> {
self.repository.delete(id)?;
Ok(())
}
}

The TaskService struct has a single generic field, repository, which is an instance of a struct that implements the TaskRepository trait. The TaskService struct implements methods for each of the CRUD (create, read, update, delete) operations that our API will support. Each method delegates to the corresponding method on the repository instance, and adds any additional business logic or error handling as necessary.

Step 5: Create the controller

Finally, we can use a Rust web framework like Rocket or Actix to create a controller that exposes the TaskService methods as RESTful endpoints. Here is an example using the Rocket framework:

#[post("/tasks", format = "application/json", data = "<task>")]
fn create(task: Json<Task>, service: State<TaskService<DieselTaskRepository>>) -> Json<Task> {
let created_task = service.create(&task).unwrap();
Json(created_task)
}

#[get("/tasks/<id>", format = "application/json")]
fn read(id: i32, service: State<TaskService<DieselTaskRepository>>) -> Option<Json<Task>> {
let task = service.get(id).ok()?;
Some(Json(task))
}

#[put("/tasks/<id>", format = "application/json", data = "<task>")]
fn update(id: i32, task: Json<Task>, service: State<TaskService<DieselTaskRepository>>) -> Json<Task> {
let updated_task = service.update(id, &task).unwrap();
Json(updated_task)
}

#[delete("/tasks/<id>")]
fn delete(id: i32, service: State<TaskService<DieselTaskRepository>>) -> Status {
service.delete(id).unwrap();
Status::NoContent
}

Each function in the controller is decorated with a Rocket route attribute that specifies the HTTP method, path, and request/response format for the endpoint. The functions take as arguments the request parameters (such as the id in the read function), and a shared state object containing an instance of the TaskService struct. The functions return a JSON response or a HTTP status code as appropriate.

Step 6: Use a migration tool to manage database schema changes

Finally, we can use a Rust library or tool like Migrate or SQLx Migrate to automate generating and applying database schema changes as we make changes to our model structs.

For example, we can use the Diesel migrations! macro to define our database schema like this:

infer_schema!("dotenv:DATABASE_URL");

migrations!(["migrations/01_create_tasks_table.rs"]);

Then, we can use the diesel migration run command to apply the migrations to the database:

diesel migration run

We can also use the diesel migration redo command to roll back and reapply the latest migration:

diesel migration redo

Using a migration tool like this, we can make changes to our database schema by modifying our model structs and running the appropriate migration command.

Final Words

Rust is a powerful language that offers many flexibility and performance benefits when building web APIs. By following the steps outlined in this tutorial, you should be able to create a resource-based RESTful API in Rust that is maintainable, scalable, and easy to develop and deploy.

I hope you found this tutorial helpful! If you have any questions or feedback, please don’t hesitate to ask.

--

--

Curiosity in functional programming, cybersecurity, and blockchain drives me to create innovative solutions and continually improve my skills