In this blog article, I show you how to build a RESTful API in Rust using Actix framework. The idea is that we are going to implement the api for MMORPG classes using Final Fantasy XIV as an example
The API will have the following endpoints:
- Create a new character class
- Get the class by the unique ID
- Delete the class by ID
- Get all classes
- Update an existing class by ID
But before we start implementing the API, let us talk about REST APIs in general to get a better understanding of what we are going to build.
REST is an acronym for Representational State Transfer. The term REST was first introduced by Roy Fielding in his doctoral dissertation "Architectural Styles and the Design of Network-based Software Architectures". REST is an architectural style for building distributed systems, which are often web services. REST is not a standard, thus it does not enforce any rules on how to build a REST API. But there are some high-level guidelines that you should follow. REST-based systems interact with each other using HTTP (HyperText Transfer Protocol).
RESTful systems consist of two parts: The client and the server. The client is the system that initiates the request for a resource and the server has the resource and sends it back to the client.
Before we start, we need to make sure we have the following tools installed:
- Rust
- An IDE or text editor of your choice
- Postman (or any other app to make requests)
$ cargo new ffxiv-api
After that, we will enter the repository using cd ffxiv
, and adding the following dependencies in Cargo.toml
# Cargo.toml
[dependencies]
actix-cors = "0.6.4"
actix-web = "4.2.1"
chrono = { version = "0.4.23", features = ["serde"] }
env_logger = "0.10.0"
serde = { version = "1.0.152", features = ["derive"] }
uuid = { version = "1.2.2", features = ["v4"] }
After the project is already initialized, let's create our models and how the api should follow. Create a new file inside the src folder called model.rs
//model.rs
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
pub struct AppState {
pub class_db: Arc<Mutex<Vec<Class>>>,
}
impl AppState {
pub fn init() -> AppState {
AppState {
class_db: Arc::new(Mutex::new(Vec::new())),
}
}
}
#[derive(Debug, Deserialize)]
pub struct QueryOptions {
pub page: Option<usize>,
pub limit: Option<usize>,
}
We have the base of our model by creating the QueryOptions and the AppState, now let's create the base for our classes
//model.rs
#[allow(non_snake_case)]
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Class {
pub id: Option<String>,
pub name: String,
pub description: String,
pub pre_requisite: String,
pub skill: Vec<Skills>,
}
#[allow(non_snake_case)]
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Skills {
pub name: String,
pub description: String,
pub skill_img: String,
}
and finally, the model for updating the schema
//model.rs
#[allow(non_snake_case)]
#[derive(Debug, Deserialize)]
pub struct UpdateClassSchema {
pub name: Option<String>,
pub description: Option<String>,
pub pre_requisite: Option<String>,
pub skill: Option<String>,
}
With our models created, we now go to the Response part of the requests
Now, create another file inside the src folder called response.rs. In the file, we will create a generic response for our requests containing only the status and message, then we will create a response for a single class, and finally one containing more than one class
//response.rs
use serde::Serialize;
use crate::model::Class;
#[derive(Serialize)]
pub struct GenericResponse {
pub status: String,
pub message: String,
}
#[derive(Serialize, Debug)]
pub struct ClassData {
pub class: Class,
}
#[derive(Serialize, Debug)]
pub struct SingleClassResponse {
pub status: String,
pub data: ClassData,
}
#[derive(Serialize, Debug)]
pub struct ClassListResponse {
pub status: String,
pub results: usize,
pub classes: Vec<Class>,
}
Now, let's create our first end points in a new file called handler.rs, inside src folder.
//handler.rs
use crate::{
model::{AppState, Class, QueryOptions, UpdateClassSchema},
response::{ClassData, ClassListResponse, GenericResponse, SingleClassResponse},
};
use actix_web::{delete, get, patch, post, web, HttpResponse, Responder};
#[get("/healthchecker")]
async fn health_checker_handler() -> impl Responder {
const MESSAGE: &str = "Build Simple CRUD API with Rust and Actix Web";
let response_json = &GenericResponse {
status: "success".to_string(),
message: MESSAGE.to_string(),
};
HttpResponse::Ok().json(response_json)
}
With this, we can check if everything is ok with our End Point. Now let's create our end point to create a class
//handler.rs
#[post("/classes")]
async fn create_class_handler(
mut body: web::Json<Class>,
data: web::Data<AppState>,
) -> impl Responder {
let mut vec = data.class_db.lock().unwrap();
let class = vec.iter().find(|classes| classes.name == body.name);
if class.is_some() {
let error_response = GenericResponse {
status: "fail".to_string(),
message: format!("classes with name: '{}' already exists", body.name),
};
return HttpResponse::Conflict().json(error_response);
}
body.id = Some(body.name.to_string().replace(" ", "-").to_lowercase());
body.pre_requisite = body.pre_requisite.to_string();
body.skill = body.skill.to_owned();
let class = body.to_owned();
vec.push(body.into_inner());
let json_response = SingleClassResponse {
status: "success".to_string(),
data: ClassData { class },
};
HttpResponse::Ok().json(json_response)
}
First, we take our model of the class and check if a class with that name already exists, if not, it fills in the body of the request and sends the response as success. Now let's create a request to search for a class by ID
//handler.rs
#[get("/classes/{id}")]
async fn get_class_handler(path: web::Path<String>, data: web::Data<AppState>) -> impl Responder {
let vec = data.class_db.lock().unwrap();
let id = path.into_inner();
let classes = vec.iter().find(|classes| classes.id == Some(id.to_owned()));
if classes.is_none() {
let error_response = GenericResponse {
status: "fail".to_string(),
message: format!("Class with ID: {} not found", id),
};
return HttpResponse::NotFound().json(error_response);
}
let classes = classes.unwrap();
let json_response = SingleClassResponse {
status: "success".to_string(),
data: ClassData {
class: classes.clone(),
},
};
HttpResponse::Ok().json(json_response)
}
We get the class id via the /{id} route, check if there is a class with that id, and then return the class data in the response. Let's go to create the return of all classes.
//handler.rs
#[get("/classes")]
pub async fn class_list_handler(
opts: web::Query<QueryOptions>,
data: web::Data<AppState>,
) -> impl Responder {
let classes = data.class_db.lock().unwrap();
let limit = opts.limit.unwrap_or(10);
let offset = (opts.page.unwrap_or(1) - 1) * limit;
let classes: Vec<Class> = classes
.clone()
.into_iter()
.skip(offset)
.take(limit)
.collect();
let json_response = ClassListResponse {
status: "success".to_string(),
results: classes.len(),
classes,
};
HttpResponse::Ok().json(json_response)
}
There's not much of a secret here, we return all classes according to model and if none exist, we return an empty array. Let's update a specific class.
//handler.rs
#[patch("/classes/{id}")]
async fn edit_class_handler(
path: web::Path<String>,
body: web::Json<UpdateClassSchema>,
data: web::Data<AppState>,
) -> impl Responder {
let mut vec = data.class_db.lock().unwrap();
let id = path.into_inner();
let classes = vec
.iter_mut()
.find(|classes| classes.id == Some(id.to_owned()));
if classes.is_none() {
let error_response = GenericResponse {
status: "fail".to_string(),
message: format!("classes with ID: {} not found", id),
};
return HttpResponse::NotFound().json(error_response);
}
let classes = classes.unwrap();
let name = body.name.to_owned().unwrap_or(classes.name.to_owned());
let description = body
.description
.to_owned()
.unwrap_or(classes.description.to_owned());
let pre_requisite = body
.pre_requisite
.to_owned()
.unwrap_or(classes.pre_requisite.to_owned());
let skill = body.skill.to_owned().unwrap();
let payload = Class {
id: classes.id.to_owned(),
name: if !name.is_empty() {
name
} else {
classes.name.to_owned()
},
description: if !description.is_empty() {
description
} else {
classes.description.to_owned()
},
pre_requisite: if !pre_requisite.is_empty() {
pre_requisite
} else {
classes.pre_requisite.to_owned()
},
skill: if !skill.is_empty() {
classes.skill.to_owned()
} else {
classes.skill.to_owned()
},
};
*classes = payload;
let json_response = SingleClassResponse {
status: "success".to_string(),
data: ClassData {
class: classes.clone(),
},
};
HttpResponse::Ok().json(json_response)
}
First we get the class id and check if there really is a class with that value, if so, we go through each field to find out if there is a need to replace them or not, and finally, we send the new data in the response. And finally, let's delete a class
//handler.rs
#[delete("/classes/{id}")]
async fn delete_class_handler(
path: web::Path<String>,
data: web::Data<AppState>,
) -> impl Responder {
let mut vec = data.class_db.lock().unwrap();
let id = path.into_inner();
let classes = vec
.iter_mut()
.find(|classes| classes.id == Some(id.to_owned()));
if classes.is_none() {
let error_response = GenericResponse {
status: "fail".to_string(),
message: format!("classes with ID: {} not found", id),
};
return HttpResponse::NotFound().json(error_response);
}
vec.retain(|classes| classes.id != Some(id.to_owned()));
HttpResponse::NoContent().finish()
}
We take the ID we want to delete and check if it exists, if it is true, we delete it. At the end of our file, we will place the configuration for our services in the endpoints
//handler.rs
pub fn config(conf: &mut web::ServiceConfig) {
let scope = web::scope("/api")
.service(health_checker_handler)
.service(class_list_handler)
.service(create_class_handler)
.service(get_class_handler)
.service(edit_class_handler)
.service(delete_class_handler);
conf.service(scope);
}
DOne, our handler is finished, now let's go to our main.rs and configure everything at the end. Let's put the following code
//main.rs
mod handler;
mod model;
mod response;
use actix_cors::Cors;
use actix_web::middleware::Logger;
use actix_web::{http::header, web, App, HttpServer};
use model::AppState;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
if std::env::var_os("RUST_LOG").is_none() {
std::env::set_var("RUST_LOG", "actix_web=info");
}
env_logger::init();
let class_db = AppState::init();
let app_data = web::Data::new(class_db);
println!("🚀 Server started successfully");
HttpServer::new(move || {
let cors = Cors::default()
.allowed_origin("http://localhost:3000")
.allowed_origin("http://localhost:3000/")
.allowed_methods(vec!["GET", "POST"])
.allowed_headers(vec![
header::CONTENT_TYPE,
header::AUTHORIZATION,
header::ACCEPT,
])
.supports_credentials();
App::new()
.app_data(app_data.clone())
.configure(handler::config)
.wrap(cors)
.wrap(Logger::default())
})
.bind(("127.0.0.1", 8000))?
.run()
.await
}
We take our models, our response and our handlers and start the server with them, our route will be opened on port 8000. To test everything we did, I'll leave an example json for our MMO character
{
"id": "Paladin",
"name": "Paladin",
"description": "For centuries, the elite of the Sultansworn have served as personal bodyguards to the royal family of Ul'dah. Known as paladins, these men and women marry exquisite swordplay with stalwart shieldwork to create a style of combat uncompromising in its defense. Clad in brilliant silver armor, they charge fearlessly into battle, ever ready to lay down their lives for their liege. To be a paladin is to protect, and those who choose to walk this path will become the iron foundation upon which the party's",
"pre_requisite": "Gladiator",
"skill": [
{
"name": "Fast Blade",
"level": "Lv. 1",
"description": "Delivers an attack with a potency of 200.",
"skill_img": "https://img.finalfantasyxiv.com/lds/d/8325a7bb54f039c5bd3cd2af4430dccfd525e0b9.png"
},
{
"name": "Fight or Flight",
"level": "Lv. 2",
"description": "Increases damage dealt by 25%.",
"skill_img": "https://img.finalfantasyxiv.com/lds/d/87405b9b9f00d9957df252ea0116e4137bc4dbd1.png"
},
{
"name": "Riot Blade",
"level": "Lv. 4",
"description": "Delivers an attack with a potency of 120.",
"skill_img": "https://img.finalfantasyxiv.com/lds/d/bdda04f3b284e3cbf7d07e50d9bffb21b74ce869.png"
}
]
}
You can use the following routes to test if everything is working, remember to put the json in the body of the request if necessary
- POST:
http://localhost:8000/api/classes
- GET:
http://localhost:8000/api/classes
- GET BY ID:
http://localhost:8000/api/classes/paladin
- UPDATE:
http://localhost:8000/api/classes/paladin
- DELETE:
http://localhost:8000/api/classes/paladin
Congratulations! If you made it this far, you have successfully created a own API with Rust and Actix. You learned some basic concepts of RESTful APIs and how a possible implementation could look like.