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

Cloud Native Pilgrim | Kubernetes Enthusiast | Serverless Believer | Customer Experience Architect @ Pulumi | (he/him) | CK{A,AD} |
Search for a command to run...

Cloud Native Pilgrim | Kubernetes Enthusiast | Serverless Believer | Customer Experience Architect @ Pulumi | (he/him) | CK{A,AD} |
you get by id first and then pass the current value to the new Todo instance:
pub fn update_todo_by_id(&self, id: &str, todo: Todo) -> Option<Todo> {
let mut todos = self.todos.lock().unwrap();
let updated_at = Utc::now();
let index = todos.iter().position(|todo| todo.id == Some(id.to_string()))?;
let todo = Todo {
id: Some(id.to_string()),
updated_at: Some(updated_at),
created_at: todos[index].created_at,
..todo
};
todos[index] = todo.clone();
Some(todo)
}
Discover the power of Rust - the modern systems programming language - through my insightful blog series. Dive deep into its features, benefits, and examples.
Motivation Most of the developers with a node.js background are pretty familiar with the package called nodemon. https://github.com/remy/nodemon nodemon is a tool is automatically restarting the node application whenever we change a file in a direc...
GPU scheduling in Kubernetes has always felt like buying a mansion when you need a studio apartment. A small inference workload that needs 2GB of GPU memory gets scheduled on an entire 80GB A100, and there's nothing you can do about it. The device pl...

TL;DR: The code https://github.com/dirien/quick-bites Nothing is more controversial in the Kubernetes community than whether to use Helm or Kustomize. I always advocate the philosophy of using the right tool for the right job. It avoids the problem...

TL;DR Le code https://github.com/dirien/quick-bites Introduction This article is part three of my series on secret management on Kubernetes with the help of Pulumi. In my first article, we talked about the Sealed Secrets controller. The second arti...

With AWS Lambda

Disaster Recovery, Data Migration made easy!

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.

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.
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.
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
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.
A good REST API should support caching to reduce unnecessary network traffic. The tradeoff: clients might occasionally receive stale data.
Client and server maintain strict separation of concerns. Each can evolve independently.
You can use a layered architecture: one server for the API, another for data storage, another for authentication.
The server can send executable code to the client. I've never used this and honestly don't know when it would be useful.
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.
You'll need:
An IDE or text editor
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.
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"}
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,
}
// ...
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.
/todosAdd 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()
}
}
/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()
}
}
/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.
/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.
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"
}
]
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).
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