Author: Josh Amata
Last Updated: Thu, May 19, 2022Actix Web is a small, fast and powerful asynchronous Rust web framework for building APIs and web applications. It relies internally on Tokio and the futures crate. Actix Web also provides a synchronous API, which can be seamlessly integrated with its asynchronous API. In addition, it supports out-of-the-box logging, static file serving, TLS, HTTP/2, and a lot more.
A web API (Application Programming Interface) is a set of functionality that allows data transmission between web services. It represents a client-server architecture delivering requests from clients to the server and responses from the server back to the client over the HTTP protocol.
REST (Representational State Transfer) refers to a set of architectural constraints used to build an API. A RESTful API is an API compliant with the REST architecture.
Client requests made over a RESTful API consist of the following:
This guide describes how to build a RESTful API in Rust with Actix Web. The example application allows users to perform Create, Read, Update and Delete (CRUD) operations on ticket objects stored in the database, and has the following resources:
HTTP method | API endpoint | Description |
---|---|---|
POST | /tickets | Create a new ticket |
GET | /tickets | Fetch all tickets |
GET | /tickets/{id} | Fetch the ticket with the corresponding ID |
PUT | /tickets/{id} | Update a ticket |
DELETE | /tickets/{id} | Delete a ticket |
Initialize the project crate using Cargo:
cargo new actix_demo --bin
Pass the --bin
flag because you're making a binary program. This also initializes a new git repository by default.
Switch to the newly created directory:
cd actix_demo
Your project directory should look like this:
.
âââ Cargo.toml
âââ src
âââ main.rs
1 directory, 2 files
Open the Cargo.toml file, and add the following dependencies:
actix-web = "4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Serde is a framework for serializing and deserializing data structures in Rust and supports a wide range of formats, including JSON, YAML, and Binary JSON (BSON).
The Cargo.toml file should look like this:
[package]
name = "actix_demo"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
actix-web = "4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Open the main.rs file in the src/ directory, and overwrite its contents with the following code:
use actix_web::{get, post, put, delete, web, App, HttpRequest, HttpResponse, HttpServer, Responder, ResponseError};
use actix_web::http::header::ContentType;
use actix_web::http::StatusCode;
use actix_web::body::BoxBody;
use serde::{Serialize, Deserialize};
use std::fmt::Display;
use std::sync::Mutex;
web::Data<T>
extractor, where T
represents the type of the state. Internally, web::Data uses Arc to offer shared ownership.fmt::Debug + fmt::Display
. To implement the ResponseError trait for a user-defined type, the type must also implement the Debug and Display traits.Create the ticket data structure:
#[derive(Serialize, Deserialize)]
struct Ticket{
id: u32,
author: String,
}
The derive macro used on the Ticket struct allows Serde to generate serialization and deserialization implementations for Ticket.
Implement Responder for Ticket:
To return the Ticket type directly as an HttpResponse, implement the Responder trait:
// Implement Responder Trait for Ticket
impl Responder for Ticket {
type Body = BoxBody;
fn respond_to(self, _req: &HttpRequest) -> HttpResponse<Self::Body> {
let res_body = serde_json::to_string(&self).unwrap();
// Create HttpResponse and set Content Type
HttpResponse::Ok()
.content_type(ContentType::json())
.body(res_body)
}
}
Responder requires a function respond_to
which converts self
to a HttpResponse.
type Body = BoxBody;
This assigns a BoxBody type to the associated type, Body. The respond_to function takes two parameters - self and HttpRequest, and returns a HttpResponse of type Self::Body
.
Ticket implements the Serialize trait and serializes into JSON using the to_string
function of serde_json:
let res_body = serde_json::to_string(&self).unwrap();
This serializes the ticket struct into JSON format and assigns the value of the serialized data to a variable - res_body. Construct an OK HttpResponse (status code 200) with the content-type set to JSON using the content_type method of HttpResponse, and set the body of the response to the serialized data:
HttpResponse::Ok()
.content_type(ContentType::json())
.body(res_body)
The Ticket type can now return directly from a handler function, as it implements the Responder trait.
Define Custom Error Struct
The application needs to be able to send a custom error message if a user requests or tries to delete a ticket id that does not exist. Implement an ErrNoId struct, that holds an id and an error message:
#[derive(Debug, Serialize)]
struct ErrNoId {
id: u32,
err: String,
}
The derive macro enables the struct's serialization into JSON using serde_json.
Implement ResponseError Trait
Custom error responses in Actix Web must implement the ResponseError trait, which requires two methods - status_code and error_response
// Implement ResponseError for ErrNoId
impl ResponseError for ErrNoId {
fn status_code(&self) -> StatusCode {
StatusCode::NOT_FOUND
}
fn error_response(&self) -> HttpResponse<BoxBody> {
let body = serde_json::to_string(&self).unwrap();
let res = HttpResponse::new(self.status_code());
res.set_body(BoxBody::new(body))
}
}
The status_code function returns StatusCode::NOT_FOUND
(status code 404).
In the error_response function, the defined struct itself gets serialized using serde_json::to_string()
. A new response is then constructed using HttpResponse::new()
function, with the status_code passed as an argument.
Then, the body of the HttpResponse gets set using the set_body method, which takes a BoxBody struct as an argument.
res.set_body(BoxBody::new(body))
Implement Display Trait For ErrNoId Struct
ResponseError requires a trait bound of fmt::Debug + fmt::Display
. Using the #[derive(Debug, Serialize)]
macro on ErrNoId, the Debug trait bound satisfies, but the Display doesn't. Implement the Display trait:
// Implement Display for ErrNoId
impl Display for ErrNoId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
The defined structs (Ticket and ErrNoId) implement all trait bounds and can return as responses from the handlers.
Define AppState Struct
All the routes and resources within the same scope share an application state. Define a struct that holds the shared data:
struct AppState {
tickets: Mutex<Vec<Ticket>>,
}
Here, the shared state is a struct that holds a vector of type Ticket. A mutex wraps the vector to mutate it safely across threads.
Note: Actix Web uses
Arc<>
underneath the shared application data. This removes the need to wrap the mutex with anArc<>
.
Handler functions in Actix Web are async
to enable asynchronous processing.
Create route handler functions for each of the endpoints defined in the table earlier.
/tickets
Handler// Create a ticket
#[post("/tickets")]
async fn post_ticket(req: web::Json<Ticket>, data: web::Data<AppState>) -> impl Responder {
let new_ticket = Ticket {
id: req.id,
author: String::from(&req.author),
};
let mut tickets = data.tickets.lock().unwrap();
let response = serde_json::to_string(&new_ticket).unwrap();
tickets.push(new_ticket);
HttpResponse::Created()
.content_type(ContentType::json())
.body(response)
}
The function uses the post
macro provided by Actix Web, this registers POST requests made to the /tickets endpoint to the handler function post_ticket. The handler takes two arguments of types - web::Json<T>
and web::Data<T>
.
web::Json<T>
extracts typed information from the request body. The type T
must implement the Deserialize
trait from Serde. As used in this handler, it extracts the request body into the struct type Ticket defined earlier.
The second argument of type web::Data<T>
gives the handler access to the shared mutable application data of type T, which in this case is an AppState defined earlier.
Actix Web allows you to return a wide range of types from handlers, as long as that type implements the Responder trait that can convert into a HttpResponse. This function returns any type that implements the Responder trait.
Within the function, construct a new Ticket from the data passed in the request body.
let mut tickets = data.tickets.lock().unwrap();
The above line gets a lock on the tickets field of the shared mutable application data. Then, create a JSON string from the newly constructed Ticket:
let response = serde_json::to_string(&new_ticket).unwrap();
You create a JSON string from the new Ticket before pushing it to the vector, as the vector push()
function takes ownership of the variable. Trying to construct a JSON string from the new ticket after the push would yield an error.
Return a HttpResponse from the handler with the Content-Type set to JSON and pass the new string as the body of the response, and return a created status (code 201).
/tickets
Handler// Get all tickets
#[get("/tickets")]
async fn get_tickets(data: web::Data<AppState>) -> impl Responder {
let tickets = data.tickets.lock().unwrap();
let response = serde_json::to_string(&(*tickets)).unwrap();
HttpResponse::Ok()
.content_type(ContentType::json())
.body(response)
}
The get
macro registers GET requests to the /tickets endpoint to the get_tickets handler. The handler returns any type that implements Responder.
Within the function:
/tickets/<id>
Handler// Get a ticket with the corresponding id
#[get("/tickets/{id}")]
async fn get_ticket(id: web::Path<u32>, data: web::Data<AppState>) -> Result<Ticket, ErrNoId> {
let ticket_id: u32 = *id;
let tickets = data.tickets.lock().unwrap();
let ticket: Vec<_> = tickets.iter()
.filter(|x| x.id == ticket_id)
.collect();
if !ticket.is_empty() {
Ok(Ticket {
id: ticket[0].id,
author: String::from(&ticket[0].author)
})
} else {
let response = ErrNoId {
id: ticket_id,
err: String::from("ticket not found")
};
Err(response)
}
}
Map the get_ticket handler to GET requests made to /tickets/{id} endpoint, where id represents the ticket id to fetch.
The handler accepts two arguments of types web::Path<T>
and web::Data<T>
.
web::Path<T>
extracts typed information from the request's path - this allows the handler to extract the ticket id of type u32 from the request path.
The handler also returns a Result<T, E>
type, where T is a type implementing Responder, and E is a type implementing ResponseError.
Within the function:
/tickets/<id>
Handler// Update the ticket with the corresponding id
#[put("/tickets/{id}")]
async fn update_ticket(id: web::Path<u32>, req: web::Json<Ticket>, data: web::Data<AppState>) -> Result<HttpResponse, ErrNoId> {
let ticket_id: u32 = *id;
let new_ticket = Ticket {
id: req.id,
author: String::from(&req.author),
};
let mut tickets = data.tickets.lock().unwrap();
let id_index = tickets.iter()
.position(|x| x.id == ticket_id);
match id_index {
Some(id) => {
let response = serde_json::to_string(&new_ticket).unwrap();
tickets[id] = new_ticket;
Ok(HttpResponse::Ok()
.content_type(ContentType::json())
.body(response)
)
},
None => {
let response = ErrNoId {
id: ticket_id,
err: String::from("ticket not found")
};
Err(response)
}
}
}
The put
macro maps PUT requests made to the /tickets/{id} endpoint to the update_ticket handler function. The function returns a Result<T, E> type.
The handler takes the following arguments:
web::Path<32>
- to extract a path parameter of type u32 as the ticket id to update.web::Json<Ticket>
- to extract the request body into a struct of type Ticket.web::Data<AppState>
- to give the handler access to the shared mutable application data.Inside the function:
Option<T>
. Using the match expression, if it contains Some(id)
, update that index in the vector with the new ticket, and return an HttpResponse. Else, if it contains a None value, return a ErrNoId struct as the error./tickets/<id>
Handler:// Delete the ticket with the corresponding id
#[delete("/tickets/{id}")]
async fn delete_ticket(id: web::Path<u32>, data: web::Data<AppState>) -> Result<Ticket, ErrNoId> {
let ticket_id: u32 = *id;
let mut tickets = data.tickets.lock().unwrap();
let id_index = tickets.iter()
.position(|x| x.id == ticket_id);
match id_index {
Some(id) => {
let deleted_ticket = tickets.remove(id);
Ok(deleted_ticket)
},
None => {
let response = ErrNoId {
id: ticket_id,
err: String::from("ticket not found")
};
Err(response)
}
}
}
The delete
macro registers the delete_ticket handler to DELETE requests made to the endpoint - /tickets/{id}.
The handler function searches the shared mutable data for a ticket with the corresponding id passed in the endpoint. If the ticket exists, it's removed from the underlying vector and returned as a response. Otherwise, construct and return a ErrNoId struct as an error response.
Create the application's server:
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let app_state = web::Data::new(AppState {
tickets: Mutex::new(vec![
Ticket {
id: 1,
author: String::from("Jane Doe")
},
Ticket {
id: 2,
author: String::from("Patrick Star")
}
])
});
HttpServer::new(move || {
App::new()
.app_data(app_state.clone())
.service(post_ticket)
.service(get_ticket)
.service(get_tickets)
.service(update_ticket)
.service(delete_ticket)
})
.bind(("127.0.0.1", 8000))?
.run()
.await
}
The macro #[actix_web::main]
marks an async main function as the Actix system's entry point. This macro executes the async main function with the Actix runtime. The main function returns a type std::io::Result<()>
.
To create the application's shared mutable state, use web::Data::new()
:
let app_state = web::Data::new(AppState {
tickets: Mutex::new(vec![
Ticket {
id: 1,
author: String::from("Jane Doe")
},
Ticket {
id: 2,
author: String::from("Patrick Star")
}
])
});
The vector of tickets is also populated with a few entries.
Actix Web servers build around the App instance, which registers routes for resources and middleware. It also stores the application state shared across all handlers.
HttpServer::new()
takes an application factory as an argument rather than an instance.
App::new()
.app_data(app_state.clone())
.service(post_ticket)
.service(get_ticket)
.service(get_tickets)
.service(update_ticket)
.service(delete_ticket)
This creates an application builder using new() and sets the application level shared mutable data using the app_data method. Register the handlers to the App using the service method.
The bind method of HttpServer binds a socket address to the server. To run the server, call the run()
method. The server must then be await'ed or
spawn'ed to start processing requests and runs until it receives a shutdown signal.
For reference, the final code in the src/main.rs file:
use actix_web::{get, post, put, delete, web, App, HttpRequest, HttpResponse, HttpServer, Responder, ResponseError};
use actix_web::http::header::ContentType;
use actix_web::http::StatusCode;
use actix_web::body::BoxBody;
use serde::{Serialize, Deserialize};
use std::fmt::Display;
use std::sync::Mutex;
#[derive(Serialize, Deserialize)]
struct Ticket{
id: u32,
author: String,
}
// Implement Responder Trait for Ticket
impl Responder for Ticket {
type Body = BoxBody;
fn respond_to(self, _req: &HttpRequest) -> HttpResponse<Self::Body> {
let res_body = serde_json::to_string(&self).unwrap();
// Create HttpResponse and set Content Type
HttpResponse::Ok()
.content_type(ContentType::json())
.body(res_body)
}
}
#[derive(Debug, Serialize)]
struct ErrNoId {
id: u32,
err: String,
}
// Implement ResponseError for ErrNoId
impl ResponseError for ErrNoId {
fn status_code(&self) -> StatusCode {
StatusCode::NOT_FOUND
}
fn error_response(&self) -> HttpResponse<BoxBody> {
let body = serde_json::to_string(&self).unwrap();
let res = HttpResponse::new(self.status_code());
res.set_body(BoxBody::new(body))
}
}
// Implement Display for ErrNoId
impl Display for ErrNoId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
struct AppState {
tickets: Mutex<Vec<Ticket>>,
}
// Create a ticket
#[post("/tickets")]
async fn post_ticket(req: web::Json<Ticket>, data: web::Data<AppState>) -> impl Responder {
let new_ticket = Ticket {
id: req.id,
author: String::from(&req.author),
};
let mut tickets = data.tickets.lock().unwrap();
let response = serde_json::to_string(&new_ticket).unwrap();
tickets.push(new_ticket);
HttpResponse::Created()
.content_type(ContentType::json())
.body(response)
}
// Get all tickets
#[get("/tickets")]
async fn get_tickets(data: web::Data<AppState>) -> impl Responder {
let tickets = data.tickets.lock().unwrap();
let response = serde_json::to_string(&(*tickets)).unwrap();
HttpResponse::Ok()
.content_type(ContentType::json())
.body(response)
}
// Get a ticket with the corresponding id
#[get("/tickets/{id}")]
async fn get_ticket(id: web::Path<u32>, data: web::Data<AppState>) -> Result<Ticket, ErrNoId> {
let ticket_id: u32 = *id;
let tickets = data.tickets.lock().unwrap();
let ticket: Vec<_> = tickets.iter()
.filter(|x| x.id == ticket_id)
.collect();
if !ticket.is_empty() {
Ok(Ticket {
id: ticket[0].id,
author: String::from(&ticket[0].author)
})
} else {
let response = ErrNoId {
id: ticket_id,
err: String::from("ticket not found")
};
Err(response)
}
}
// Update the ticket with the corresponding id
#[put("/tickets/{id}")]
async fn update_ticket(id: web::Path<u32>, req: web::Json<Ticket>, data: web::Data<AppState>) -> Result<HttpResponse, ErrNoId> {
let ticket_id: u32 = *id;
let new_ticket = Ticket {
id: req.id,
author: String::from(&req.author),
};
let mut tickets = data.tickets.lock().unwrap();
let id_index = tickets.iter()
.position(|x| x.id == ticket_id);
match id_index {
Some(id) => {
let response = serde_json::to_string(&new_ticket).unwrap();
tickets[id] = new_ticket;
Ok(HttpResponse::Ok()
.content_type(ContentType::json())
.body(response)
)
},
None => {
let response = ErrNoId {
id: ticket_id,
err: String::from("ticket not found")
};
Err(response)
}
}
}
// Delete the ticket with the corresponding id
#[delete("/tickets/{id}")]
async fn delete_ticket(id: web::Path<u32>, data: web::Data<AppState>) -> Result<Ticket, ErrNoId> {
let ticket_id: u32 = *id;
let mut tickets = data.tickets.lock().unwrap();
let id_index = tickets.iter()
.position(|x| x.id == ticket_id);
match id_index {
Some(id) => {
let deleted_ticket = tickets.remove(id);
Ok(deleted_ticket)
},
None => {
let response = ErrNoId {
id: ticket_id,
err: String::from("ticket not found")
};
Err(response)
}
}
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let app_state = web::Data::new(AppState {
tickets: Mutex::new(vec![
Ticket {
id: 1,
author: String::from("Jane Doe")
},
Ticket {
id: 2,
author: String::from("Patrick Star")
}
])
});
HttpServer::new(move || {
App::new()
.app_data(app_state.clone())
.service(post_ticket)
.service(get_ticket)
.service(get_tickets)
.service(update_ticket)
.service(delete_ticket)
})
.bind(("127.0.0.1", 8000))?
.run()
.await
}
To run the code from the project directory:
$ cargo run
When this is run for the first time it downloads the required project dependencies, compiles, and runs the executable.
While the server is running, make a POST request to the /tickets endpoint:
curl -XPOST 127.0.0.1:8000/tickets -H "Content-Type: application/json" -d '{"id":3, "author":"Barry Allen"}'
This gives the newly created ticket back as a JSON response:
{"id":3,"author":"Barry Allen"}
Make a GET request to the /tickets/{id} endpoint for a ticket, using the -i flag to display response headers:
curl -XGET -i 127.0.0.1:8000/tickets/80
This gives a similar error response with the status code Not Found (404), because the ticket with the id - 80 does not exist:
HTTP/1.1 404 Not Found
content-length: 34
date: Tue, 12 Apr 2022 07:44:28 GMT
{"id":80,"err":"ticket not found"}
Make another GET request to the /tickets/{id} endpoint for a ticket that exists in the database:
curl -XGET 127.0.0.1:8000/tickets/1
This returns a response:
{"id":1,"author":"Jane Doe"}
Make a PUT request to update the ticket with id - 1:
curl -XPUT 127.0.0.1:8000/tickets/1 -i -H "Content-Type: application/json" -d '{"id":1, "author":"Frodo Baggins"}'
This returns the newly updated ticket as a JSON response:
{"id":1,"author":"Frodo Baggins"}
Make a request to delete the ticket with id - 3:
curl -XDELETE -i 127.0.0.1:8000/tickets/3
The deleted ticket returned as a response:
{"id":3,"author":"Barry Allen"}
Test the final endpoint by making a GET request to /tickets:
curl -XGET -i 127.0.0.1:8000/tickets
This returns all tickets as a response:
[{"id":1,"author":"Frodo Baggins"},{"id":2,"author":"Patrick Star"}]
All endpoints work.
In this guide, you learned how to build an Actix Web REST API with CRUD operations. For more information on Actix Web, check the official Actix Web website.