Developing a web app with rust: part2, Actix Web

Francis Stephan
6 min readJun 3, 2024

--

This story is a continuation of my series on developing a web application with rust (see part 1 here). We speak about Actix Web when we refer to the framework, and actix_web for the rust crate implementing the framework.

1. Introduction: Actix as a MVC implementation

I understood this after reading Using HTMX with Rust — A Quickstart Guide. This tutorial shows how to implement a counter, using Actix Web with tera templates (and htmx, which not relevant at this stage).

The Model part (the counter) is represented by the application state through:

struct AppStateCounter { counter: Mutex<i32> }

The View part is implemented in the tera template as:

<p id="counter">Counter: {{ counter_value }}</p>

And the Controller part is implemented through the actix_web App component:

HttpServer::new( move || {
App::new()
.app_data(tera.clone())
.app_data(counter.clone())
...

In this excerpt, you see that the actix_web App takes control of both the tera templating system (the view) and the counter (the model).

At this stage, you should have a brief look at the Actix Web documentation and get a general understanding of the main concepts:

  • the application (App) gets wrapped within the HTTP server (HttpServer), which takes charge of incoming HTTP requests and delivers HTTP responses through handlers;
  • the handlers are async functions that take a variable number of parameters (up to 12) that are called data extractors: you do not need the formal definition of extractors (you can find it here), all you need to know is that they provide type-safe access to the data contained in HTTP requests, using deserialization with `serde`. We shall see 5 types of extractors in the chinese character dictionary app.

2. The actix main function

The following is a shortened version of the main function of the dictionary app (see the full version here):

pub struct AppState {
db: SqlitePool,
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
...
env_logger::init_from_env(env_logger::Env::new().default_filter_or("debug"));
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let pool = SqlitePool::connect(&database_url).await.unwrap();
let tera = Data::new(Tera::new("./vol/templates/**/*.html").unwrap());

HttpServer::new(move || {
App::new()
.app_data(tera.clone())
.app_data(Data::new(AppState { db: pool.clone() }))
.wrap(Logger::new("%U %r %s"))
.service(handlers::index)
.service(handlers::size)
.service(handlers::getziform)
.service(handlers::zilist)
...
.service(handlers::getaddziform)
.service(handlers::addzi)
...
.service(handlers::dodelete)
.service(handlers::showlast)
.service(handlers::cancel)
.service(actix_files::Files::new("/assets", "./vol/assets").show_files_listing())
})
.bind(("0.0.0.0", 8090))?
.run()
.await
}

Two types of data are created outside of HttpServer:

  • pool: a connection pool to the sqlite database;
  • tera: an entry point to the tera templating engine, containing prsed templates.

These data are cloned and included within the actix_web App via the app_data function.

The App wraps a Logger, which needs the env_logger crate for display, with the selected options (%U = the request URL, %r shows the first line of request, %s shows the response status code).

Then follows a list of all the handlers to be serviced. The handlers are listed by their name, with no indication of the HTTP verb (get, post, put or delete) nor of the handler parameters (the extractors, see above). We shall explain more about the handlers in the next section.

The App’s last line allows for the availability of the files contained in the vol/assets directory: css files, logos, javascript files.

The server gets bound to port 8090 and starts waiting for incoming requests.

3. Handlers and HTTP verbs

Our 19 handlers will be presented according to the HTTP verbs they serve (this section) and to the extractors (nex section).

The handlers get their own module, handlers.rs, and you can see the whole code here.

HTTP get verb:

#[get("/getziform")]
pub async fn getziform(tera: Data<Tera>) -> impl Responder {
let mut ctx = Context::new();
let insert = forms::ziform();
ctx.insert("content", &insert);
HttpResponse::Ok().body(tera.render("components/content.html", &ctx).unwrap())
}

All handlers get an attribute such as `#[get(“/getziform”)]` showing the HTTP verb (get in this instance) and the path (“/getziform”) which may also include a variable part (such as in “/dodelete/{id}”). In this instance the path is identical with the handler’s name (getziform), but this need not be so. This handler only needs a reference to the templating engine, through which it inserts a form (provided as a String from the forms module) within the “content” div, resulting in the following screen:

HTTP post verb:

If we click to submit in the previous form, this is handed over to the following handler:

#[post("/zilist")]
pub async fn zilist(
formdata: web::Form<dbase::CharData>,
tera: Data<Tera>,
data: web::Data<AppState>,
) -> impl Responder {
let chain = &formdata.carac;
let first: char = chain.chars().next().unwrap();
let mut ctx = Context::new();
ctx.insert("query", &chain);
let disp = dbase::list_for_zi(data, format!("{:X}", first as u32)).await;

ctx.insert("dico", &disp);
ctx.insert("diclen", &disp.len());
HttpResponse::Ok().body(tera.render("components/zilist.html", &ctx).unwrap())
}

Here, the form data will be deserialized according to the CharData struct (defined in our dbase module, see here), yielding a (hopefully) chinese character which will be looked up in the database through the dbase::list_for_zi function. The result will be fed into the tera context and rendered in the components/zilist.html template.

HTTP put and delete verbs:

Similarly, we have one handler for the `put` verb (doupdate) and one for the `delete` verb, which will not be discussed here.

4. Handlers and extractors

We shall briefly present the 5 types of extractors we use:

  • tera extractor
  • application state extractor
  • path data extractor
  • form data extractor
  • HttpRequest extractor.

The programmer has to decide which type of extractors he needs based on the handler’s objectives and actions. The order in which these extractors are declared within the parameter list does not matter.

4.1 tera extractor:

Let us again show our getziform handler:

#[get("/getziform")]
pub async fn getziform(tera: Data<Tera>) -> impl Responder {
let mut ctx = Context::new();
let insert:String = forms::ziform();
ctx.insert("content", &insert);
HttpResponse::Ok().body(tera.render("components/content.html", &ctx).unwrap())
}

I need the tera extractor (of type Data<Tera>) each time I want to render anything in the client browser, meaning I need the tera extractor in each of my handlers.

This done through the tera render function, which takes 2 parameters:

  • the html template file to be rendered, in this case:
<div id="content">{{content | safe}}</div> <!-- safe prevents HTML escaping -->
  • the context: the context ctx contains all the variables to be supplied to the template: in this case there is just one, named “content” (marked with the ‘safe’ filter), and the value to be attributed to this variable, which is the “insert” String, containing the form we want to show:
ctx.insert("content", &insert);

4.2 Application state extractor:

The following handler, called in response to the /size path, has two extractors:

#[get("/size")]
pub async fn size(tera: Data<Tera>, data: web::Data<AppState>) -> impl Responder {
// https://stackoverflow.com/questions/669092/sqlite-getting-number-of-rows-in-a-database
let mut ctx = Context::new();

let size = dbase::getsize(data).await;
let metadata = fs::metadata("vol/zidian.db").expect("Failed to read file metadata");
let time = metadata.modified().unwrap();
use chrono::prelude::{DateTime, Utc};
let dt: DateTime<Utc> = time.clone().into();
ctx.insert( "content",
format!(
"The dictionary presently contains {} entries. Last updated on {}",
&size, &dt.format("%Y-%m-%d")
).as_str(),
);
HttpResponse::Ok().body(tera.render("components/content.html", &ctx).unwrap())
}

These extractors are the tera extractor, which we just saw, and the application state extractor (of type web::Data<AppState>), since it needs data from the database. Remember that we defined AppState as a Struct in main.rs:

pub struct AppState {
db: SqlitePool,
}

This size is obtained from the getsize function in our dbase module:

pub async fn getsize(data: Data<AppState>) -> i64 {
let result: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM pyhz")
.fetch_one(&data.db)
.await
.unwrap();
result.0
}

4.3 Path data extractor

We have only one path extractor in this program:

#[delete("/dodelete/{id}")]
pub async fn dodelete(
iddata: web::Path<dbase::IdData>, // deserialization by serde of path info
tera: Data<Tera>,
data: web::Data<AppState>,
) -> impl Responder {
let id = iddata.id;
let chaine = dbase::delete_db(id, data).await;
let mut ctx = Context::new();
ctx.insert("content", &chaine);
HttpResponse::Ok().body(tera.render("components/content.html", &ctx).unwrap())
}

This handler has 3 extractors, including one path extractor, of type web::Path<dbase::IdData>. The IdData struct is defined in the dbase module as:

#[derive(Deserialize)]
pub struct IdData {
pub id: i64,
}

This means that the {id} component in the path will be deserialized, providing IdData.id of type i64.

4.4 Form data extractor

As an example, we shall consider the addzi handler:

#[post("/addzi")]
pub async fn addzi(
zidata: web::Form<dbase::Idzi>,
tera: Data<Tera>,
data: web::Data<AppState>,
) -> impl Responder {
let chaine = dbase::addzi_db(zidata, data).await;
let mut ctx = Context::new();
ctx.insert("content", &chaine);
HttpResponse::Ok().body(tera.render("components/content.html", &ctx).unwrap())
}

Our form data extractor, in this instance, is of type web::Form<dbase::Idzi>, with the Idzi struct being defined in the dbase module as:

#[derive(Deserialize)]
pub struct Idzi {
pub id: i64,
pub pinyin_ton: String,
pub unicode: String,
pub sens: String,
}

The data from the form will be deserialized, ready for inclusion in the databas of a new record.

4.5. HttpRequest extractor:

This extractor gives you access to some low level info about the request:

#[get("/")]
pub async fn index(req: HttpRequest, tera: Data<Tera>) -> impl Responder {
let ipaddr = actix_remote_ip::get_remote_ip(&req);
let mut ctx = Context::new();
if ipaddr.is_loopback() {
ctx.insert("contenu", "Connected to local server");
} else {
ctx.insert("contenu", "Caution: all modifications to the database will be discarded at the end of the fly.io session");
}
HttpResponse::Ok().body(tera.render("index.html", &ctx).unwrap())
}

In our case, we wanted to know wether the server was called from a distant client or not, because for a distant client the database is in fact read only, since it gets reinitialized after a short period.

Our next instalment will deal with the tera templating system and htmx.

--

--