Skip to main content

Command Palette

Search for a command to run...

Rust Development: Creating a REST API with Actix Web for Beginners

Updated
10 min read
Rust Development: Creating a REST API with Actix Web for Beginners

TL;DR: Le code

Introduction

This article walks through building a REST API in Rust using the Actix Web framework. The best way to learn something new? Try it yourself, make mistakes, and improve.

We're going to implement the REST API for the classic TODO app.

The API will have the following endpoints:

  • Create a new TODO item

  • Get a TODO item by its unique identifier

  • Delete a TODO item by its unique identifier

  • Get all TODO items

  • Update an existing TODO item

Before we start implementing, let's cover some REST API basics.

What is a REST API?

REST stands for REpresentational State Transfer. Roy Fielding introduced the term in his doctoral dissertation "Architectural Styles and the Design of Network-based Software Architectures". REST is an architectural style for building distributed systems (often web services), not a standard. REST-based systems communicate using HTTP.

RESTful systems have two parts: the client initiates requests for resources, and the server sends them back.

Architectural Constraints of a REST API:

Six constraints apply when building a REST API (one is optional):

  • Uniform Interface

  • Stateless

  • Cacheable

  • Client-Server

  • Layered System

  • Code on Demand (optional)

Let's look at each one.

Uniform Interface

Every REST API needs a uniform interface. This means there's a consistent way to interact with server resources regardless of the client device or application type.

Fielding defined four properties for this:

  • Identification of resources

  • Manipulation of resources through representations

  • Self-descriptive messages

  • Hypermedia as the engine of application state (HATEOAS)

In practice:

  • Use nouns instead of verbs in resource names: /todos instead of /getTodos

  • Use HTTP methods like GET, POST, PUT, and DELETE to specify the operation

  • Use plural resource names: /todos instead of /todo

  • Send appropriate HTTP status codes: 200 for success, 201 for resource creation, 404 when not found

Stateless

The server doesn't store any context between requests. All state needed to handle a request is part of the request itself. This helps with scaling and availability.

Cacheable

A good REST API should support caching to reduce unnecessary network traffic. The tradeoff: clients might occasionally receive stale data.

Client-Server

Client and server maintain strict separation of concerns. Each can evolve independently.

Layered System

You can use a layered architecture: one server for the API, another for data storage, another for authentication.

Code on Demand (Optional)

The server can send executable code to the client. I've never used this and honestly don't know when it would be useful.

What is Actix Web?

Actix Web is a popular Rust web framework. It was originally built on the Actix actor framework but is now independent. Applications built with Actix Web expose an HTTP server in a native Rust binary.

Prerequisites

You'll need:

  • Rust

  • An IDE or text editor

Initialize the project

cargo init

Add the actix-web, serde, chrono, uuid, env_logger, and log crates:

cargo add actix-web
cargo add serde --features derive
cargo add chrono --features serde
cargo add uuid --features v4
cargo add env_logger
cargo add log

We add env_logger and log to enable request logging via the actix-web Logger middleware.

Creating the application entry point

Add the following code to main.rs:

use actix_web::{get, web, App, HttpResponse, HttpServer, Responder, Result};
use serde::Serialize;

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

#[get("/health")]
async fn healthcheck() -> impl Responder {
    let response = Response {
        message: "Everything is working fine".to_string(),
    };
    HttpResponse::Ok().json(response)
}

async fn not_found() -> Result<HttpResponse> {
    let response = Response {
        message: "Resource not found".to_string(),
    };
    Ok(HttpResponse::NotFound().json(response))
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| App::new().service(healthcheck).default_service(web::route().to(not_found)))
        .bind(("127.0.0.1", 8080))?
        .run()
        .await
}

Here's what this does:

  • Defines a Response struct for sending JSON responses

  • Creates a health handler that clients can probe to check if the server is running

  • Uses the #[actix_web::main] macro to run main as an async function with the actix-web runtime. The main function:

    • Creates a server using HttpServer with an App instance that registers routes

    • Registers a default not_found handler for unmatched routes

    • Binds to localhost:8080 and starts the server

Run the application:

cargo run

Test the health endpoint:

curl localhost:8080/health -vvv
*   Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /health HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.79.1
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< content-length: 59
< content-type: application/json
< date: Sat, 11 Mar 2023 08:27:37 GMT
<
* Connection #0 to host localhost left intact
{"status":"success","message":"Everything is working fine"}

Organizing the code with modules

Rust modules let you split code into logical units and manage visibility between them.

Create three folders under src: api, models, and repository. Add a mod.rs file to each (all items in a module are private by default):

mkdir src/api src/models src/repository
touch src/api/mod.rs src/models/mod.rs src/repository/mod.rs

Reference these modules in main.rs:

use actix_web::{get, web, App, HttpResponse, HttpServer, Responder, Result};
use serde::Serialize;

mod api;
mod models;
mod repository;

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

Creating the first API endpoint

Create a model for the Todo resource in src/models/todo.rs:

use chrono::prelude::{DateTime, Utc};
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Todo {
    pub id: Option<String>,
    pub title: String,
    pub description: Option<String>,
    pub created_at: Option<DateTime<Utc>>,
    pub updated_at: Option<DateTime<Utc>>,
}

The derive macro generates implementations for formatting, cloning, and serialization. The pub modifier makes fields accessible from other modules.

Register this file in src/models/mod.rs:

pub mod todo;

Now for the database logic. We'll use an in-memory vector with mutexes for thread safety. (A future article will cover real databases with actix-web.)

Create src/repository/database.rs:

use std::fmt::Error;
use chrono::prelude::*;
use std::sync::{Arc, Mutex};

use crate::models::todo::Todo;

pub struct Database {
    pub todos: Arc<Mutex<Vec<Todo>>>,
}

impl Default for Database {
    fn default() -> Self {
        Self::new()
    }
}

impl Database {
    pub fn new() -> Self {
        Database {
            todos: Arc::new(Mutex::new(vec![])),
        }
    }

    pub fn create_todo(&self, todo: Todo) -> Result<Todo, Error> {
        let mut todos = self.todos.lock().unwrap();
        let id = uuid::Uuid::new_v4().to_string();
        let created_at = Utc::now();
        let updated_at = Utc::now();
        let todo = Todo {
            id: Some(id),
            created_at: Some(created_at),
            updated_at: Some(updated_at),
            ..todo
        };
        todos.push(todo.clone());
        Ok(todo)
    }
}

Here's what this does:

  • Defines a Database struct with a todos field of type Arc<Mutex<Vec<Todo>>>. Arc provides thread-safe reference counting; Mutex ensures only one thread accesses the data at a time.

  • Implements the Default trait (good Rust practice, avoids clippy warnings)

  • The new function creates an instance with an empty, thread-safe vector

  • create_todo locks the mutex, generates a UUID, sets timestamps, adds the todo to the vector, and returns it

Now create the API endpoint in src/api/api.rs:

use actix_web::{web, get, post, delete, put, HttpResponse};
use crate::{models::todo::Todo, repository::database::Database};

#[post("/todos")]
pub async fn create_todo(db: web::Data<Database>, new_todo: web::Json<Todo>) -> HttpResponse {
    let todo = db.create_todo(new_todo.into_inner());
    match todo {
        Ok(todo) => HttpResponse::Created().json(todo),
        Err(err) => HttpResponse::InternalServerError().body(err.to_string()),
    }
}

pub fn config(cfg: &mut web::ServiceConfig) {
    cfg.service(
        web::scope("/api")
            .service(create_todo)
    );
}

Note: We use HttpResponse::Created() (201) instead of HttpResponse::Ok() (200). The HTTP spec says 201 is the correct status code when creating a new resource.

The config function registers all API endpoints under /api using web::scope.

Wire everything together in src/main.rs:

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    env_logger::init();

    let todo_db = repository::database::Database::new();
    let app_data = web::Data::new(todo_db);

    HttpServer::new(move ||
        App::new()
            .app_data(app_data.clone())
            .configure(api::api::config)
            .service(healthcheck)
            .default_service(web::route().to(not_found))
            .wrap(actix_web::middleware::Logger::default())
    )
        .bind(("127.0.0.1", 8080))?
        .run()
        .await
}

The env_logger::init() call activates logging. Run with RUST_LOG=info cargo run to see request logs.

Run the application:

cargo run

Create a new TODO:

curl -X POST -H "Content-Type: application/json" -d '{"title": "My first todo", "description": "This is my first todo"}' http://localhost:8080/api/todos

Expected output:

{
  "id": "d70053a9-721d-4c20-9a27-b26b4fbaecae",
  "title": "My first todo",
  "description": "This is my first todo",
  "created_at": "2023-03-11T10:33:56.441332Z",
  "updated_at": "2023-03-11T10:33:56.441390Z"
}

Now we can implement the remaining API endpoints.

Implementing the remaining API endpoints

GET /todos

Add to src/api/api.rs:

#[get("/todos")]
pub async fn get_todos(db: web::Data<Database>) -> HttpResponse {
    let todos = db.get_todos();
    HttpResponse::Ok().json(todos)
}

Add get_todos to the config function:

pub fn config(cfg: &mut web::ServiceConfig) {
    cfg.service(
        web::scope("/api")
            .service(create_todo)
            .service(get_todos)
    );
}

Add to src/repository/database.rs:

impl Database {
    // ...

    pub fn get_todos(&self) -> Vec<Todo> {
        let todos = self.todos.lock().unwrap();
        todos.clone()
    }
}

GET /todos/{id}

Add to src/api/api.rs:

#[get("/todos/{id}")]
pub async fn get_todo_by_id(db: web::Data<Database>, id: web::Path<String>) -> HttpResponse {
    let todo = db.get_todo_by_id(&id);
    match todo {
        Some(todo) => HttpResponse::Ok().json(todo),
        None => HttpResponse::NotFound().body("Todo not found"),
    }
}

Add get_todo_by_id to the config function:

pub fn config(cfg: &mut web::ServiceConfig) {
    cfg.service(
        web::scope("/api")
            .service(create_todo)
            .service(get_todos)
            .service(get_todo_by_id)
    );
}

Add to src/repository/database.rs:

impl Database {
    // ...

    pub fn get_todo_by_id(&self, id: &str) -> Option<Todo> {
        let todos = self.todos.lock().unwrap();
        todos.iter().find(|todo| todo.id == Some(id.to_string())).cloned()
    }
}

PUT /todos/{id}

Add to src/api/api.rs:

#[put("/todos/{id}")]
pub async fn update_todo_by_id(db: web::Data<Database>, id: web::Path<String>, updated_todo: web::Json<Todo>) -> HttpResponse {
    let todo = db.update_todo_by_id(&id, updated_todo.into_inner());
    match todo {
        Some(todo) => HttpResponse::Ok().json(todo),
        None => HttpResponse::NotFound().body("Todo not found"),
    }
}

Add update_todo_by_id to the config function:

pub fn config(cfg: &mut web::ServiceConfig) {
    cfg.service(
        web::scope("/api")
            .service(create_todo)
            .service(get_todos)
            .service(get_todo_by_id)
            .service(update_todo_by_id)
    );
}

Add to src/repository/database.rs:

impl Database {
    // ...

    pub fn update_todo_by_id(&self, id: &str, todo: Todo) -> Option<Todo> {
        let mut todos = self.todos.lock().unwrap();
        let index = todos.iter().position(|t| t.id == Some(id.to_string()))?;
        let existing_todo = &todos[index];
        let updated_todo = Todo {
            id: Some(id.to_string()),
            title: todo.title,
            description: todo.description,
            created_at: existing_todo.created_at,
            updated_at: Some(Utc::now()),
        };
        todos[index] = updated_todo.clone();
        Some(updated_todo)
    }
}

Note: We explicitly preserve created_at from the existing todo. Using ..todo would overwrite it with None since the incoming JSON doesn't include created_at.

DELETE /todos/{id}

Add to src/api/api.rs:

#[delete("/todos/{id}")]
pub async fn delete_todo_by_id(db: web::Data<Database>, id: web::Path<String>) -> HttpResponse {
    let todo = db.delete_todo_by_id(&id);
    match todo {
        Some(todo) => HttpResponse::Ok().json(todo),
        None => HttpResponse::NotFound().body("Todo not found"),
    }
}

Add to the config function:

pub fn config(cfg: &mut web::ServiceConfig) {
    cfg.service(
        web::scope("/api")
            .service(create_todo)
            .service(get_todos)
            .service(get_todo_by_id)
            .service(update_todo_by_id)
            .service(delete_todo_by_id)
    );
}

Add to src/repository/database.rs:

impl Database {
    // ...

    pub fn delete_todo_by_id(&self, id: &str) -> Option<Todo> {
        let mut todos = self.todos.lock().unwrap();
        let index = todos.iter().position(|todo| todo.id == Some(id.to_string()))?;
        Some(todos.remove(index))
    }
}

Done! Now we can start the server and test the API.

Testing the API

Start the server with logging enabled:

RUST_LOG=info cargo run

Test with curl:

# Create new Todo items
curl -X POST -H "Content-Type: application/json" -d '{"title": "Buy milk", "description": "Buy 2 liters of milk"}' http://localhost:8080/api/todos
curl -X POST -H "Content-Type: application/json" -d '{"title": "Buy eggs", "description": "Buy 12 eggs"}' http://localhost:8080/api/todos
curl -X POST -H "Content-Type: application/json" -d '{"title": "Buy bread", "description": "Buy 1 loaf of bread"}' http://localhost:8080/api/todos

# Get all Todo items
curl -s http://localhost:8080/api/todos | jq
[
  {
    "id": "590538de-56c4-4057-b4e6-c91021fc04be",
    "title": "Buy milk",
    "description": "Buy 2 liters of milk",
    "created_at": "2023-03-11T11:58:28.176321Z",
    "updated_at": "2023-03-11T11:58:28.176376Z"
  },
  {
    "id": "54f7695f-55a0-423f-9aba-0d2ec323eef3",
    "title": "Buy eggs",
    "description": "Buy 12 eggs",
    "created_at": "2023-03-11T11:58:28.183312Z",
    "updated_at": "2023-03-11T11:58:28.183314Z"
  },
  {
    "id": "cd574ca3-0d18-4e34-adad-c493140607a5",
    "title": "Buy bread",
    "description": "Buy 1 loaf of bread",
    "created_at": "2023-03-11T11:58:28.189685Z",
    "updated_at": "2023-03-11T11:58:28.189687Z"
  }
]

# Get a Todo item by id
curl -s http://localhost:8080/api/todos/590538de-56c4-4057-b4e6-c91021fc04be | jq
{
  "id": "590538de-56c4-4057-b4e6-c91021fc04be",
  "title": "Buy milk",
  "description": "Buy 2 liters of milk",
  "created_at": "2023-03-11T11:58:28.176321Z",
  "updated_at": "2023-03-11T11:58:28.176376Z"
}

# Update a Todo item by id
curl -s -X PUT -H "Content-Type: application/json" -d '{"title": "Buy 2 liters of milk", "description": "Buy 2 liters of milk"}' http://localhost:8080/api/todos/590538de-56c4-4057-b4e6-c91021fc04be | jq
{
  "id": "590538de-56c4-4057-b4e6-c91021fc04be",
  "title": "Buy 2 liters of milk",
  "description": "Buy 2 liters of milk",
  "created_at": "2023-03-11T11:58:28.176321Z",
  "updated_at": "2023-03-11T11:58:28.176376Z"
}

# Delete a Todo item by id
curl -s -X DELETE http://localhost:8080/api/todos/590538de-56c4-4057-b4e6-c91021fc04be | jq
{
  "id": "590538de-56c4-4057-b4e6-c91021fc04be",
  "title": "Buy 2 liters of milk",
  "description": "Buy 2 liters of milk",
  "created_at": "2023-03-11T11:58:28.176321Z",
  "updated_at": "2023-03-11T11:58:28.176376Z"
}

# Get all Todo items
curl -s http://localhost:8080/api/todos | jq
[
  {
    "id": "54f7695f-55a0-423f-9aba-0d2ec323eef3",
    "title": "Buy eggs",
    "description": "Buy 12 eggs",
    "created_at": "2023-03-11T11:58:28.183312Z",
    "updated_at": "2023-03-11T11:58:28.183314Z"
  },
  {
    "id": "cd574ca3-0d18-4e34-adad-c493140607a5",
    "title": "Buy bread",
    "description": "Buy 1 loaf of bread",
    "created_at": "2023-03-11T11:58:28.189685Z",
    "updated_at": "2023-03-11T11:58:28.189687Z"
  }
]

Conclusion

You've built a RESTful API with Rust and actix-web. You learned basic REST API concepts and set up an in-memory database using Mutex and Arc.

In the next article, we'll replace the in-memory database with a real one. Tell me in the comments what database you'd like to see (and why it should be postgres).

Resources

  • https://restfulapi.net/rest-architectural-constraints/

  • https://restfulapi.net/

  • https://www.techtarget.com/searchapparchitecture/tip/The-5-essential-HTTP-methods-in-RESTful-API-development

  • https://medium.com/@andreasreiser94/why-hateoas-is-useless-and-what-that-means-for-rest-a65194471bc8

  • https://actix.rs/

  • https://hackernoon.com/easily-understand-rust-modules-across-multiple-files-with-this-guide

  • https://aeshirey.github.io/code/2020/12/23/arc-mutex-in-rust.html