Template Rendering in Rust using Askama [Part-2]

on 2023-06-06

Introduction


This is a continuation of Part-1 of the tutorial where we created an HTTP server and added a welcome page. In this part, we will include functionality to add data to our music store and list them.

Data Storage


We can store data in any external database but for simplicity, we will store data in memory. We will use a hashmap to store data and use a read-write lock for concurrency. Let's define the required data structs in the db.rs file.

use lazy_static::lazy_static;
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::*;

type DB = HashMap<u64, Music>;

lazy_static! {
    pub static ref MUSIC_DB: RwLock<DB> = Default::default();
}

#[derive(Default, Deserialize, Clone)]
pub struct Music {
    #[serde(skip_deserializing)]
    pub id: u64,
    pub song: String,
    pub artist: String,
    pub genre: String,
}

We used lazy_static to initialize our DB at runtime. Let's add a few helper functions to access the DB and get and insert data. We will implement our custom error type MusicError in the next section.

fn music_db_read() -> Result<RwLockReadGuard<'static, DB>, MusicError> {
    MUSIC_DB.read().map_err(|e| HttpError::from(format!("{e}")))
}

fn music_db_write() -> Result<RwLockWriteGuard<'static, DB>, MusicError> {
    MUSIC_DB.write().map_err(|e| HttpError::from(format!("{e}")))
}

pub fn music_db_data() -> Result<Vec<Music>, MusicError> {
    let data = music_db_read()?.clone().into_values().collect();
    Ok(data)
}

pub fn insert_data_to_db(mut music: Music) -> Result<(), MusicError> {
    let mut db = music_db_write()?;
    music.id = db.len() as u64;
    db.insert(music.id, music);
    Ok(())
}

music_db_read function acquires a read lock on the DB and returns DB with a read guard while music_db_write acquires a write lock and returns DB with a write guard. music_db_data function gets values from our DB and collects them into a vector. insert_data_to_db function writes data to DB.

Custom Error

Defining a custom error type requires Debug and Display traits to be implemented. And returning our custom error as an API response from actix-web server requires ResponseError trait to be implemented.

Let's define MusicError in music_error.rs file.

use actix_web::ResponseError;
use std::fmt::{Display, Formatter, Result};

#[derive(Debug)]
pub struct MusicError {
    msg: String,
}

impl From<String> for MusicError {
    fn from(msg: String) -> Self { Self { msg } }
}

impl Display for MusicError {
    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
        write!(f, "Error while processing Request: {}", self.msg)
    }
}

impl ResponseError for MusicError {}

We have also implemented conversion from String to MusicError using From trait.

Create Templates


Now let's include templates to add and list music records from our service.

Adding HTML files

We will extend index.html file to include add_music and music_list blocks. Add below add_music.html and list_music.html files to the templates directory.

{% extends "index.html" %}

{% block add_music %}
<form action="/add_music_to_db" method="post">
  <div class="mb-3">
    <label for="songName" class="form-label">Song Name</label>
    <input type="text" class="form-control" id="songName" name="song" placeholder="Enter song name">
  </div>
  <div class="mb-3">
    <label for="artistName" class="form-label">Artist Name</label>
    <input type="text" class="form-control" id="artistName" name="artist" placeholder="Enter artist name">
  </div>
  <div class="mb-3">
    <label for="genre" class="form-label">Genre</label>
    <input type="text" class="form-control" id="genre" name="genre" placeholder="Enter genre">
  </div>
  <button type="submit" class="btn btn-primary">Add Song</button>
</form>
{% endblock %}

The above form reads user input and triggers add_music_to_db function on submit. In add_music_to_db function, we will use Form method from actix_web to extract the payload from above request. The Form method maps name attribute in the form to a field in the rust struct.

We will list music records in a table format.

{% extends "index.html" %}

{% block music_list %}
<table class="table table-striped">
  <thead>
    <tr>
      <th scope="col">#</th>
      <th scope="col">Song Name</th>
      <th scope="col">Artist Name</th>
      <th scope="col">Genre</th>
      <th scope="col">Actions</th>
    </tr>
  </thead>
  <tbody>
    {% for music in data %}
    <tr>
      <th scope="row">{{ music.id }}</th>
      <td>{{ music.song }}</td>
      <td>{{ music.artist }}</td>
      <td>{{ music.genre }}</td>
      <td>
        <a href="{{ "/edit_music/{}"|format(music.id) }}" class="btn btn-sm btn-primary" role="button">Edit</a>
        <a href="{{ "/delete_music/{}"|format(music.id) }}" class="btn btn-sm btn-danger" role="button">Delete</a>
      </td>
    </tr>
    {% endfor %}
  </tbody>
</table>
<a href="/add_music" class="btn btn-success" role="button">Add Music</a>
{% endblock %}

We will define a ListMusicPage struct with a field data which will hold a list of our music records. In the above HTML file, we iterate over the data and add each music record as a row into the table. The Actions column in the table will allow us to edit or delete a record from our DB. We will add edit and delete functionalities in the next part of this tutorial.

Http handlers

Let's add functions to handle the addition and listing of our music records in http_handlers.rs.

#[derive(Template)]
#[template(path = "list_music.html")]
pub struct ListMusicPage {
    data: Vec<Music>,
}

pub async fn list_music() -> Result<ListMusicPage, MusicError> {
    Ok(ListMusicPage { data: music_db_data()? })
}

#[derive(Template)]
#[template(path = "add_music.html")]
pub struct AddMusicPage;

pub async fn add_music() -> AddMusicPage { AddMusicPage }

pub async fn add_music_to_db(data: web::Form<Music>) -> Result<web::Redirect, MusicError> {
    insert_data_to_db(data.into_inner())?;
    Ok(web::Redirect::to("/list_music").see_other())
}

The list_music function gets all music records from our DB and renders list_music.html.
The add_music function will render add_music.html and display the HTML form. On submit, add_music_to_db function will be called which deserializes the form data to Music and inserts it into DB. After adding data to DB we will redirect user to the list music page.

Let's configure routes in main.rs

fn routes(service_config: &mut ServiceConfig) {
    service_config
        .route("/", web::get().to(welcome))
        .route("/list_music", web::get().to(list_music))
        .route("/add_music", web::get().to(add_music))
        .route("/add_music_to_db", web::post().to(add_music_to_db));
}

Build and run the project to start the server.

cargo run

You can add music at http://127.0.0.1:8000/add_music

add_music web page

If data is added successfully, you will be redirected to http://127.0.0.1:8000/list_music

list_music web page

Check out the full code of this tutorial on GitHub.

See Part-3 of the tutorial where we will edit and delete data in the DB. 😀