Building REST APIs in Rust with Actix Web

Updated on May 19, 2022
Building REST APIs in Rust with Actix Web header image

Introduction

Actix 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:

  • An endpoint - is a URI that exposes the application’s resources over the web.
  • HTTP method - this describes the operation to perform:
    • POST - create a resource.
    • GET - fetch a resource.
    • PUT - update a resource.
    • DELETE - delete a resource.
  • A header - which contains authentication credentials.
  • A body (optional) - which contains data or additional information.

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:

|table| |thead| |tr| |th|25|HTTP method| |th|25|API endpoint| |th|50|Description| |tbody| |tr| |td|POST| |td|/tickets| |td|Create a new ticket| |tr| |td|GET| |td|/tickets| |td|Fetch all tickets| |tr| |td|GET| |td|/tickets/{id}| |td|Fetch the ticket with the corresponding ID| |tr| |td|PUT| |td|/tickets/{id}| |td|Update a ticket| |tr| |td|DELETE| |td|/tickets/{id}| |td|Delete a ticket|

Prerequisites

  • Working knowledge of Rust.
  • Properly installed Rust toolchain including Cargo (Rust version >= 1.54).
  • Curl or a similar tool for making API requests.

Set Up

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"

Import Libraries

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;
  • get, post, put, delete - gives access to Actix Web's built-in macros for specifying the method and path that a defined handler should respond to.
  • web - Actix Web shares application state with all routes and resources within the same scope. Access the state using the web::Data<T> extractor, where T represents the type of the state. Internally, web::Data uses Arc to offer shared ownership.
  • App - used to create the application's instance and register the request handlers.
  • HttpRequest, HttpResponse - gives access to the HTTP request and response pairs.
  • Responder - Actix Web allows you to return any type as an HttpResponse by implementing a Responder trait that converts into a HttpResponse. User-defined types implement this trait so that they can return directly from handlers.
  • ResponseError - a handler can return a custom error type in a result if the type implements the ResponseError trait.
  • ContentType - allows you set the Content-Type in the header of an HttpResponse.
  • StatusCode - contains bindings and methods for handling HTTP status codes used by Actix Web.
  • BoxBody - a boxed message body type used as an associated type within the Responder trait implementation.
  • Serialize, Deserialize - Serde provides a derive macro used to generate serialization implementations for structs defined in a program at compile time.
  • Display - the ResponseError trait, has a trait bound of fmt::Debug + fmt::Display. To implement the ResponseError trait for a user-defined type, the type must also implement the Debug and Display traits.
  • Mutex - used to control concurrent access by utilizing a locking mechanism on a shared object.

Define Data Structures

  1. 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.

  2. 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.

  3. 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.

  4. 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))
  5. 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.

  6. 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 an Arc<>.

Create Route Handlers

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.

POST /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).

GET /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:

  1. Get a lock on the shared data.
  2. Construct a JSON string response from the underlying vector of Tickets.
  3. Create and return a HttpResponse, with the Content-Type set to JSON, and the body set to the JSON string, along with an OK (status code 200).

GET /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:

  1. Dereference the ticket id passed to the endpoint to get the value of type u32 it points to.
  2. Get a lock on the shared data.
  3. Search the vector for a ticket matching the ticket id passed.
  4. Return an OK response of type Ticket, if the matching ticket exists, otherwise construct an ErrNoId struct to return as an Err response.

PUT /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 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:

  1. Dereference id to get the passed ticket id.
  2. Create a new Ticket type from the request body containing the ticket to update.
  3. Get a lock on the shared mutable state.
  4. Using the position() function, find the index of the ticket with the matching id in the vector of tickets. The position function returns a type 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.

DELETE /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 Server

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.

Final Code

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
}

Running the Code

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.

Making Requests

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.

Conclusion

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.