Rust Web API
Introduction
This page is to point me in the right direction should someone want to do this in rust. I think that Golang, C# or NodeJS (to reduce technology) might be a better choice. This language seems a lot harder to learn. Maybe if you already use it for other stuff in you company. Doing this I was surprise how easy it was. Maybe microcontrollers have clouded my judgement
Server
For the server I am going to use actix_web. I am sure there are many choices knowing rust but this is the first one I came across. May rewrite this as we go but here we are
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(AppState { db: db.clone() }))
.wrap(Logger::default())
.configure(routes::home_routes::config) // Better Approach
.service(hello) // Sample route 1
.service(echo) // Sample route 2
.route("/hey", web::get().to(manual_hello))
})
.bind((address, port))?
.run()
.await
Routes
Here is the example for the routes. We will need to look at middleware no doubt
pub fn config(config: &mut web::ServiceConfig) {
config.service(
web::scope("/home")
.service(handlers::home_handlers::greet)
.service(handlers::home_handlers::test),
);
}
Handlers
And here are the handlers which I will hopefully update to have your 401, 404 etc
use actix_web::{get, web, Responder};
#[get("/greet/{name}")]
pub async fn greet(name: web::Path<String>) -> impl Responder {
format!("Hello {name}!",)
}
#[get("/test")]
pub async fn test() -> impl Responder {
format!("Hello world!")
}
Database
Setup
Well this is only meant to be brief as there are many ways to solve this. Here we are with the database bit. They use sea-orm, and you know how I feel about orms. So for postgress we add
sea-orm = { version = "1.1.3", features = [
"sqlx-postgres",
"runtime-tokio-rustls",
"macros",
] }
Create a database and user. Then you can make a connection. Note migrations rely on DATABASE_URL so you may want to consider this. I have put the Migrator script to run before creating the server. We will need to complete migration before this will run again
let database_url = format!(
"postgres://{}:{}@{}:{}/{}",
std::env::var("DB_USER").unwrap(),
std::env::var("DB_PASSWORD").unwrap(),
std::env::var("DB_HOST").unwrap(),
std::env::var("DB_PORT").unwrap(),
std::env::var("DB_NAME").unwrap(),
);
let db = Database::connect(&database_url).await.unwrap();
Migrator::up(&db, None).await.unwrap();
Migration
We can now use the sea-orm-cli to make a migration script
cargo install sea-orm-cli
sea-orm-cli migrate init
sea-orm-cli migrate generate create_user_table
The first command creates a separate project which we can create out migration scripts in. And the second one adds a script for our user table we amend this to our needs
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(User::Table)
.if_not_exists()
.col(
ColumnDef::new(User::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(User::Name).string().not_null())
.col(ColumnDef::new(User::Email).string().not_null().unique_key())
.col(ColumnDef::new(User::Password).string().not_null())
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(User::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
pub enum User {
Table,
Id,
Name,
Email,
Password,
}
You will need to change the migration Cargo.toml so have the relevant drivers and then you can run the migration
sea-orm-cli migrate up
You can use up for migrating forward, down for backward and others see [options]
Entities
We can now generate the entities we want to use in our code. This is done by the sea-orm-cli. I did find it a bit nasty that you have to create the Cargo.toml manually afterwards
sea-orm-cli generate entity -o entity/src
For the Cargo.toml we just add serde and sea-orm
[package]
name = "entity"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "entity"
path = "src/mod.rs"
[dependencies]
serde = { verison = "1.0.197", features = [ "derive" ] }
[dependencies.sea-orm]
version = "1.1.3"
Project Cargo Changes
The migration and the entities are libraries so we need to reference them in the root Cargo project we do this with the workspace and adding them as a dependency
...
[workspace]
members = [".", "entity", "migration"]
[dependencies]
entity = { path = "entity" }
migration = { path = "migration" } # depends on your needs
sea-orm = { version = "1.1.3", features = [
"sqlx-postgres",
"runtime-tokio-rustls",
"macros",
] }
...
Authentication
There a fair bit of copied source but this is about providing what to look for rather than the detail itself.
Setup
The main takeaway was probably how to go about this. Basically we
- set up a /register /login endpoints.
- implement insert into DB with sea-orm.
- add token to response
- use API Response to respond
One thing I did notice is that in format when you curly braces you escape them with double curly braces
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RegisterModel {
name: String,
email: String,
password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LoginModel {
email: String,
password: String,
}
#[post("/register")]
pub async fn register(
app_state: web::Data<app_state::AppState>,
register_json: web::Json<RegisterModel>,
) -> Result<ApiResponse, ApiResponse> {
let user_model = entity::user::ActiveModel {
name: Set(register_json.name.clone()),
email: Set(register_json.email.clone()),
password: Set(digest(®ister_json.password)),
..Default::default()
}
.insert(&app_state.db)
.await
.map_err(|err| ApiResponse::new(500, err.to_string()))?;
Ok(ApiResponse::new(200, format!("{}", user_model.id)))
}
#[post("/login")]
pub async fn login(
app_state: web::Data<app_state::AppState>,
login_json: web::Json<LoginModel>,
) -> Result<ApiResponse, ApiResponse> {
let user_data = entity::user::Entity::find()
.filter(
Condition::all()
.add(entity::user::Column::Email.eq(&login_json.email))
.add(entity::user::Column::Password.eq(digest(&login_json.password))),
)
.one(&app_state.db)
.await
.map_err(|err| ApiResponse::new(500, err.to_string()))?
.ok_or(ApiResponse::new(404, "User Not Found".to_owned()))?;
let token = encode_jwt(user_data.email, user_data.id)
.map_err(|err| ApiResponse::new(500, err.to_string()))?;
Ok(ApiResponse::new(200, format!("{{ 'token':'{}' }}", token)))
}
Jwt
This is a far better approach for people to realize what goes on with tokens. You can see there is an encrypt and decrypt and a sample of what would be included.
#[derive(Serialize, Deserialize, Clone)]
pub struct Claims {
pub exp: usize,
pub iat: usize,
pub email: String,
pub id: i32,
}
impl FromRequest for Claims {
type Error = actix_web::Error;
type Future = future::Ready<Result<Self, Self::Error>>;
fn from_request(
req: &actix_web::HttpRequest,
_payload: &mut actix_web::dev::Payload,
) -> std::future::Ready<Result<Claims, actix_web::Error>> {
match req.extensions().get::<Claims>() {
Some(claim) => future::ready(Ok(claim.clone())),
None => future::ready(Err(actix_web::error::ErrorBadRequest("Bad Claims"))),
}
}
}
pub fn encode_jwt(email: String, id: i32) -> Result<String, jsonwebtoken::errors::Error> {
let now = Utc::now();
let expire = Duration::hours(24);
let claims = Claims {
exp: (now + expire).timestamp() as usize,
iat: now.timestamp() as usize,
email,
id,
};
let secret = (*constants::app_config::SECRET_KEY).clone();
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_ref()),
)
}
pub fn decode_jwt(jwt: String) -> Result<TokenData<Claims>, jsonwebtoken::errors::Error> {
let secret = (*constants::app_config::SECRET_KEY).clone();
let claim_data: Result<TokenData<Claims>, jsonwebtoken::errors::Error> = decode(
&jwt,
&DecodingKey::from_secret(secret.as_ref()),
&Validation::default(),
);
claim_data
}
Api Response
Here is the API response again I may revisit this. For me seeing usage of associated types is good to understand where one would use them. Because what we are doing is well known it demonstrates how rust should be applied to solution. I think it probably should be a generic but we shall see.
#[derive(Debug)]
pub struct ApiResponse {
pub status_code: u16,
pub body: String,
response_code: StatusCode,
}
impl ApiResponse {
pub fn new(status_code: u16, body: String) -> Self {
ApiResponse {
status_code,
body,
response_code: StatusCode::from_u16(status_code).unwrap(),
}
}
}
impl Responder for ApiResponse {
type Body = BoxBody;
fn respond_to(self, req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> {
let body = BoxBody::new(web::BytesMut::from(self.body.as_bytes()));
HttpResponse::new(self.response_code).set_body(body)
}
}
impl Display for ApiResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Error: {} \n Status Code: {}",
self.body, self.status_code
)
}
}
impl ResponseError for ApiResponse {
fn status_code(&self) -> StatusCode {
self.response_code
}
fn error_response(&self) -> HttpResponse<BoxBody> {
let body = BoxBody::new(web::BytesMut::from(self.body.as_bytes()));
HttpResponse::new(self.status_code()).set_body(body)
}
}
Middleware=
They used a helper crate to do this actix-web-lab. The setup was similar to spring and node. You create a function which either goes left to right. and use it in the route.
Implementation
use actix_web::{
body::MessageBody,
dev::{ServiceRequest, ServiceResponse},
http::header::AUTHORIZATION,
Error, HttpMessage,
};
use actix_web_lab::middleware::Next;
use crate::utils::{
api_response::{self, ApiResponse},
jwt::decode_jwt,
};
pub async fn check_auth_middleware(
req: ServiceRequest,
next: Next<impl MessageBody>,
) -> Result<ServiceResponse<impl MessageBody>, Error> {
let auth = req.headers().get(AUTHORIZATION);
if auth.is_none() {
return Err(Error::from(api_response::ApiResponse::new(
401,
"Unauthorised".to_string(),
)));
}
let token = auth
.unwrap()
.to_str()
.unwrap()
.replace("Bearer ", "")
.to_owned();
let claim = decode_jwt(token).unwrap();
req.extensions_mut().insert(claim.claims);
next.call(req)
.await
.map_err(|err| Error::from(ApiResponse::new(500, err.to_string())))
}
Usage
And here is the usage a protected route
pub fn config(config: &mut web::ServiceConfig){
config
.service(
web::scope("/user")
.wrap(from_fn(middlewares::auth_middleware::check_auth_middleware))
.service(handlers::user_handlers::user)
.service(handlers::user_handlers::update_user)
);
}