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:
/todosinstead of/getTodosUse HTTP methods like GET, POST, PUT, and DELETE to specify the operation
Use plural resource names:
/todosinstead of/todoSend appropriate HTTP status codes:
200for success,201for resource creation,404when 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:
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
Responsestruct for sending JSON responsesCreates a
healthhandler that clients can probe to check if the server is runningUses the
#[actix_web::main]macro to runmainas an async function with the actix-web runtime. Themainfunction:Creates a server using
HttpServerwith anAppinstance that registers routesRegisters a default
not_foundhandler for unmatched routesBinds to
localhost:8080and 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
Databasestruct with atodosfield of typeArc<Mutex<Vec<Todo>>>.Arcprovides thread-safe reference counting;Mutexensures only one thread accesses the data at a time.Implements the
Defaulttrait (good Rust practice, avoids clippy warnings)The
newfunction creates an instance with an empty, thread-safe vectorcreate_todolocks 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




