Building REST API's with Actix web and Rust
Introduction
The Rust programming language has been gaining momentum as the most loved programming on the StackOverflow survey for five years, According to Wikipedia it is a multi-paradigm, general-purpose programming language aimed at speed and safety, with a focus on concurrency safety, As a result of this, it used and supported by top tech companies such as Microsoft.
Actix Web is a fast and performant web micro framework used to build restful API's, In this article, we would explore the actix web framework along with the rust programming language by writing a simple crud API that would demonstrate each of the common HTTP verbs such as POST, GET, PATCH, DELETE.
REST API’s
A REpresentational State Transfer (REST) or RESTful is an architectural style as well as a communication method that is frequently employed in the creation of various online applications. To put it another way, it's an application program interface (API) that uses HTTP requests to GET, PUT, POST, and DELETE data over the internet.
REST Verbs
REST verbs are HTTP methods supported by the REST architecture, which specify an action to be executed on a specific resource or group of resources. When a request is sent by the client it carries along with the following payload:
- REST Verb eg. GET, POST
- Headers - They carry metadata that indicate the type of request and the allowed response.
- Body - Typically holds data being sent to the backend.
Status Codes
Status codes are issued by a server in response to a clients request made to the server, they determine the state of the request that has been sent to the server and how the client discern if the request was successful.
Category 1xx (Information code): Status codes in this category starting with "1" are classified as information codes. Unexpected 1xx status responses can be ignored by user agents, so clients must accept one or more 1xx codes even if they do not expect them.
Category 2xx (Success): This status code family, starting with "2", is classified as a success code and indicates a successful operation (eg 200, 201, etc.).
Category 3xx (redirect): These 3xx status codes are used to send redirect messages. This means that the user agent needs to take further steps to meet the request. These codes can be used if you have moved your domain or moved to a new location and need a URL redirect.
Category 4xx (client error) These codes are reserved in case there is an error on the client side, such as an incorrect REST method format, an incorrect request format, or an attempt to access an invalid page. increase. B. 400, 401, 403, 404, 405, etc.
Category 5xx (Server Error) These are errors from the server. The client request can be complete, but the server can fail. The most common status codes are 500, 501, 502, 503, and 504.
Building a REST API
In this article, we would build a simple rest API that showcases each of the HTTP verbs mentioned and implements CRUD. Here are some of the endpoints we would be creating
GET /todos - returns a list of todo items
POST /todos - create a new todo item
GET /todos/{id} - returns one todo
PATCH /todos/{id} - updates todo item details
DELETE /todos/{id} - delete todo item
Getting Started
Firstly, we need to have rust installed, you can follow the instruction here, Once installed we would initialize an empty project using cargo, Cargo is rust's package manager, similar to npm for Node.js or pip for python. To create an empty project we run the following command
cargon init --bin crudapi
This command would create a Cargo.toml
file and a src folder. Open the Cargo.toml
file and edit to add packages needed. The file should look like this:
[package]
name = "crudapi"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
After adding the packages the file should look like this:
[package]
name = "crudapi"
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 = "2.0"
actix-rt = "1.1.1"
bson = "1.0.0"
chrono = "0.4.11"
futures = "0.3.5"
MongoDB = "1.0.0"
rustc-serialize = "0.3.24"
serde = { version = "1.0", features = ["derive"] }
Open the main.rs
file that cargo creates, import the actix web dependency to use in the file like so
use actix_web::{App, HttpServer};
We will create five routes in our application to handle the endpoints described. To keep our code well organised, we will put them in a different module called controllers
and declare it in main.rs
.
In the main.rs
we proceed to create a simple server in our main function which is the entry point of our application
// imports
#[actix_rt::main]
async fn main() -> std::io::Result<()> {
std::env::set_var("RUST_LOG", "actix_web=debug");
HttpServer::new(move || {
App::new()
.route("/todos", web::get().to(controllers::get_todos))
.route("/todos", web::post().to(controllers::create_todo))
.route("/todos/{id}", web::get().to(controllers::fetch_one))
.route("/todos/{id}", web::patch().to(controllers::update_todo))
.route("/todos/{id}", web::delete().to(controllers::delete_todo))
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
The main function is the entry point for the application which returns a Result type. In the main function, we use the attribute #[actix_rt::main] to ensure it’s executed with the actix runtime and proceed to create a new HttpServer instance and also add an App instance to it, add a few routes that point to our controllers
module which would handle the logic for each route and serve it on port 8080.
We proceed to create the controllers
module by creating a simple file inside the src
folder that contains main.rs
file. Inside the controllers
module, create functions that each route points to like so;
// src/controllers.rs
use actix_web::Responder;
pub async fn get_todos() -> impl Responder {
format!("fetch all todos");
}
pub async fn create_todo() -> impl Responder {
format!("Creating a new todo item");
}
pub async fn fetch_one() -> impl Responder {
format!("Fetch one todo item");
}
pub async fn update_todo() -> impl Responder {
format!("Update a todo item");
}
pub async fn delete_todo() -> impl Responder {
format!("Delete a todo item");
}
These are the handlers for each route we have specified above, they are each asynchronous functions that return a Responder
trait provided by actix-web
. For now, they return a string, later we would modify each function to implement some logic interacting with a database.
Let’s proceed to run the project:
cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.53s
Running `target/debug/crudapi`
We can test each endpoint with curl
, in another terminal.
curl 127.0.0.1:8080/todos
fetch all todos
Connect MongoDB Database
We would use the official MongoDB rust crate to allow us to store information in a local database. We initiate the connection in the main function to ensure connection when our server starts running and include it in the app state to be able to pass it into our controllers.
Firstly we import the modules needed in the main.rs
// src/main.rs
use MongoDB::{options::ClientOptions, Client};
use std::sync::*;
Then proceed to modify the main
function to look like this:
// src/main.rs
#[actix_rt::main]
async fn main() -> std::io::Result<()> {
std::env::set_var("RUST_LOG", "actix_web=debug");
let mut client_options = ClientOptions::parse("MongoDB://127.0.0.1:27017/todolist").await.unwrap();
client_options.app_name = Some("Todolist".to_string());
let client = web::Data::new(Mutex::new(Client::with_options(client_options).unwrap()));
HttpServer::new(move || {
App::new()
.app_data(client.clone())
.route("/todos", web::get().to(controllers::get_todos))
.route("/todos", web::post().to(controllers::create_todo))
.route("/todos/{id}", web::get().to(controllers::fetch_one))
.route("/todos/{id}", web::patch().to(controllers::update_todo))
.route("/todos/{id}", web::delete().to(controllers::delete_todo))
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
The above code creates a MongoDB
client that is wrapped in a Mutex
for thread safety which is then passed into the app state to be used by our controllers.
Creating a todolist
Now that the database connection is ready and in our app state, we proceed to modify our create_todo
function in our controller to create a new document in the database, firstly you import the modules needed and model the type of data coming as a payload, this can be easily done with structs like so:
// src/controllers.rs
use actix_web::{web, HttpResponse, Responder};
use MongoDB::{options::FindOptions, Client};
use bson::{ doc, oid };
use std::sync::*;
use futures::stream::StreamExt;
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize)]
pub struct Todo {
pub content: String,
pub is_done: bool,
}
#[derive(Serialize)]
struct Response {
message: String,
}
const MONGO_DB: &'static str = "crudapidb";
const MONGOCOLLECTION: &'static str = "todo";
We imported the needed modules, created two structs Todo
and Response
and two const variables, The Todo
struct is responsible for how model data would be inputted into the database, The Response
handles how response messages would be sent back on an endpoint. The MONGO_DB
and MONGOCOLLECTION
holds the constant strings of our database
name and collection
name.
Now we are ready to create the function that creates a new item in the database
// src/controllers.rs
// imports
// structs
// constants
pub async fn create_todo(data: web::Data<Mutex<Client>>, todo: web::Json<Todo>) -> impl Responder {
let todos_collection = data.lock().unwrap().database(MONGO_DB).collection(MONGOCOLLECTION);
match todos_collection.insert_one(doc! {"content": &todo.content, "is_done": &todo.is_done}, None).await {
Ok(db_result) => {
if let Some(new_id) = db_result.inserted_id.as_object_id() {
println!("New document inserted with id {}", new_id);
}
let response = Response {
message: "Successful".to_string(),
};
return HttpResponse::Created().json(response);
}
Err(err) =>
{
println!("Failed! {}", err);
return HttpResponse::InternalServerError().finish()
}
}
}
This function takes the appstate data
and the payload todo
, firstly we get the todo collection from MongoDB
client in our appstate, then we dynamically create a new document using the insert_one
function and add the todo
payload which returns a Result
which we match
to see if it’s successful or an error was returned. If it is successful we return a created
status code 201
and success message, else return a 500
internal server error.
Fetching todolist
Using a get request we can pull out data from our database. According to the routes implemented above, we create two functions to respond to fetching all the todo items in the database and fetching only one from using its id. we modify the controllers.rs
like so:
// src/controllers.rs
pub async fn get_todos(data: web::Data<Mutex<Client>>) -> impl Responder {
let todos_collection = data.lock().unwrap().database(MONGO_DB).collection(MONGOCOLLECTION);
let filter = doc! {};
let find_options = FindOptions::builder().sort(doc! { "_id": -1}).build();
let mut cursor = todos_collection.find(filter, find_options).await.unwrap();
let mut results = Vec::new();
while let Some(result) = cursor.next().await {
match result {
Ok(document) => {
results.push(document);
}
_ => {
return HttpResponse::InternalServerError().finish();
}
}
}
HttpResponse::Ok().json(results)
}
pub async fn fetch_one(data: web::Data<Mutex<Client>>, todo_id: web::Path<String>) -> impl Responder {
let todos_collection = data.lock().unwrap().database(MONGO_DB).collection(MONGOCOLLECTION);
let filter = doc! {"_id": oid::ObjectId::with_string(&todo_id.to_string()).unwrap() };
let obj = todos_collection.find_one(filter, None).await.unwrap();
return HttpResponse::Ok().json(obj);
}
The get_todos
function returns all the items in the database using the find function we pass in a filter
and find_options
which sorts the results from newest to oldest, then proceed to iterate the results using the cursor returned by the find
function, populating the result vector with incoming documents before returning them in json format.
The fetch_one
function returns a single todo item from the database, the id is passed from the route into the function as a web::Path
. The filter
is passed into the find_one
function to filter out the item based on the id and it is returned as a response.
Updating an item in the todolist
The patch request would be responsible for update an item in database.
// src/controllers.rs
pub async fn update_todo(data: web::Data<Mutex<Client>>, todo_id: web::Path<String>, todo: web::Json<Todo>) -> impl Responder {
let todos_collection = data.lock().unwrap().database(MONGO_DB).collection(MONGOCOLLECTION);
let filter = doc! {"_id": oid::ObjectId::with_string(&todo_id.to_string()).unwrap() };
let data = doc! { "$set": { "content": &todo.content, "is_done": &todo.is_done } };
todos_collection.update_one(filter, data, None).await.unwrap();
let response = Response {
message: "Updated Successfully".to_string(),
};
return HttpResponse::Ok().json(response);
}
The update_todo
accepts the appstate
and todo_id
as the id passed from the route and the update payload
, it filters the document using the id passed and proceeds to update the document in the database and returns a successful message.
Deleting an item in the todolist
Our final controller which deletes an item corresponding to the id passed to the route.
// src/controllers.rs
pub async fn delete_todo(data: web::Data<Mutex<Client>>, todo_id: web::Path<String>) -> impl Responder {
let todos_collection = data.lock().unwrap().database(MONGO_DB).collection(MONGOCOLLECTION);
let filter = doc! {"_id": oid::ObjectId::with_string(&todo_id.to_string()).unwrap() };
todos_collection.delete_one(filter, None).await.unwrap();
return HttpResponse::NoContent();
}
The delete_one
function filters by id provided in the route and deletes the document from the database, the proceeds to return a 204
status code.
Testing the server
We've successfully built a simple todolist API, now to make client requests. Using cURL
we can easily test each of the routes in the application.
First, we run the application using the following the command
cargo run
Once the server is running open another terminal to test each endpoint.
POST 127.0.0.1:8080/todos
- create an item in the todolist
$ curl -H "Content-Type: application/json" -XPOST 127.0.0.1:8080/todos -d '{"content": "Read one paragraph of a book", "is_done": false}'
This sends a POST request to our /todo endpoint and inserts the payload and associated details into our database. As a response, we receive a success message:
{"message":"Successful"}
GET 127.0.0.1:8080/todos
- fetch all items
$ curl -H "Content-Type: application/json" -XGET 127.0.0.1:8080/todos
This returns all the items in our todolist and we get a response like:
[{"_id":{"$oid":"620d1e64fad81254efb04383"},"content":"Read one paragraph of a book","is_done":false}]
GET 127.0.0.1:8080/todos/{id}
- fetch one item
$ curl -H "Content-Type: application/json" -XGET 127.0.0.1:8080/todos/620d1e64fad81254efb04383
This returns a todo item based on the id passed into the route and you should get a response like so:
{"_id":{"$oid":"620d1e64fad81254efb04383"},"content":"Read one paragraph of a book","is_done":false}
PATCH 127.0.0.1:8080/todos/{id}
- update one item
$ curl -H "Content-Type: application/json" -XPATCH 127.0.0.1:8080/todos/620d1e64fad81254efb04383 -d '{"content":"Read one paragraph of a book", "is_done": true }'
This updates the document in the database with the payload sent to it, you should get a success message
{"message":"Updated Successfully"}
DELETE 127.0.0.1:8080/todos/{id}
- delete one item
$ curl -H "Content-Type: application/json" -XDELETE 127.0.0.1:8080/todos/620d1e64fad81254efb04383
This removes the item from out database an empty response without a message, because we are returning a 204
status code.
Conclusion
In this article, you have learned about REST API’s, HTTP verbs and status codes and how to build a REST API service in rust, actix web and MongoDB. You can further extend the code by adding logging, encryption, rate limiting, etc. to scale and improve your application.
Top comments (1)
Loved it🙌, do you have any repo for it?