home tools

A Rust/Axum Web App CRUD Boilerplate

July 2026

Local Tools

I'm writing little web tools that run on my local machine. They do things like make it easier for me to collect quotes, make blog posts, and the like. I'm using Rust's axumaxum crate for the web server. It's bare-bones. Making the tools involves setting up routes and associated functions for every action. This page is my basic template for Creating, Reading, Updating, and Deleting (CRUD) content.

NOTE: This is a work in progress. While the initial functionality is in place, there are still user experience improvements to make and security issues to be addressed.

The Files

I put all the Rust code in a single file for ease of editing. It's a couple hundred lines that run the server and provide the interactions.

Four HTML template files round out the code. One's a wrapper. The other three use the wrapper around their display content.

main.rs - Sections

Here's the Rust file split out into sections for easier reading and descriptions (the complete file is below as well).

Include Calls

The server relies on anyhow, minijinja, serde, the std library, tower_sessions, and uuid in addition to axum. The complete set of calls is:

use anyhow::Result;
use axum::extract::Form;
use axum::extract::Path;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::response::Redirect;
use axum::{Router, response::Html, routing::get, routing::post};
use minijinja::syntax::SyntaxConfig;
use minijinja::{Environment, Value, context, path_loader};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tower_sessions::Session;
use tower_sessions::{MemoryStore, SessionManagerLayer};
use uuid::Uuid;

struct AppState {
  env: Environment<'static>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct Item {
  id: Option<String>,
  text: String,
}

#[tokio::main]
async fn main() -> Result<()> {
  let session_store = MemoryStore::default();
  let session_layer =
    SessionManagerLayer::new(session_store).with_secure(false);
  let env = get_env()?;
  let app_state = Arc::new(AppState { env });
  let app = Router::new()
    .route("/", get(handle_index))
    .route("/add", post(handle_add))
    .route("/edit/{id}", get(handle_edit))
    .route("/update", post(handle_update))
    .route("/delete/{id}", get(handle_delete_request))
    .route("/confirm-delete", post(handle_confirm_delete))
    .with_state(app_state)
    .layer(session_layer);
  let listener = tokio::net::TcpListener::bind("127.0.0.1:3939")
    .await
    .unwrap();
  println!("listening on {}", listener.local_addr().unwrap());
  axum::serve(listener, app).await?;
  Ok(())
}

fn data_dir() -> PathBuf {
  PathBuf::from("data-storage")
}

fn get_env() -> Result<Environment<'static>> {
  let mut env = Environment::new();
  env.set_loader(path_loader("templates"));
  env.set_syntax(
    SyntaxConfig::builder()
      .line_statement_prefix("==")
      .block_delimiters("[!", "!]")
      .variable_delimiters("[@", "@]")
      .comment_delimiters("[#", "#]")
      .build()
      .unwrap(),
  );
  Ok(env)
}

pub fn get_files_in_dir(dir: &PathBuf) -> Result<Vec<PathBuf>> {
  let files = fs::read_dir(dir)?
    .filter(|p| p.as_ref().unwrap().path().is_file())
    .map(|p| p.as_ref().unwrap().path())
    .filter(|p| {
      !p.file_name().unwrap().to_str().unwrap().starts_with(".")
    })
    .collect();
  Ok(files)
}

async fn handle_add(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(mut request): Form<Item>,
) -> impl IntoResponse {
  let id = Uuid::now_v7();
  request.id = Some(id.to_string());
  if let Ok(contents) = serde_json::to_string(&request) {
    let file_name = format!("{}.json", id);
    let path = data_dir().join(file_name);
    match fs::write(path, contents) {
      Ok(()) => {
        // TODO: Set flash message that the
        // quote was added.
        dbg!("quote added");
        session
          .insert("flash_message", "quote added".to_string())
          .await
          .unwrap();
      }
      Err(e) => {
        // TODO: Set flash message with quote
        // error.
        dbg!(e);
      }
    }
  };
  Redirect::to("/")
}

async fn handle_confirm_delete(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path =
    data_dir().join(format!("{}.json", request.id.unwrap()));
  fs::remove_file(path).unwrap();
  Redirect::to("/")
}

async fn handle_delete_request(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("delete_request.html", context, State(state))
}

async fn handle_edit(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("edit.html", context, State(state))
}

async fn handle_index(
  session: Session,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  // TODO: Add flash messages.
  match get_files_in_dir(&data_dir()) {
    Ok(paths) => {
      let items: Vec<Item> = paths
        .iter()
        .map(|path| {
          serde_json::from_str::<Item>(
            &fs::read_to_string(path).unwrap(),
          )
          .unwrap()
        })
        .collect();
      let context = context!(items => items);
      render("index.html", context, State(state))
    }
    Err(_) => {
      let context = context!();
      render("index.html", context, State(state))
    }
  }
}

async fn handle_update(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path = data_dir()
    .join(format!("{}.json", &request.id.clone().unwrap()));
  let content = serde_json::to_string(&request).unwrap();
  fs::write(path, content).unwrap();
  Redirect::to("/")
}

fn render(
  template: &str,
  context: Value,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let template = match state.env.get_template(template) {
    Ok(t) => t,
    Err(e) => {
      println!("ERROR: {}", e);
      return Err(StatusCode::INTERNAL_SERVER_ERROR);
    }
  };
  match template.render(context) {
    Ok(r) => Ok(Html(r)),
    Err(e) => {
      println!("ERROR: {}", e);
      Err(StatusCode::INTERNAL_SERVER_ERROR)
    }
  }
}

AppState Struct

The MiniJinja template engine requires an initial environment to be set up before rendering pages. This environment gets stored in a persistent AppState struct. Without that, it would have to be initialized for each request.

use anyhow::Result;
use axum::extract::Form;
use axum::extract::Path;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::response::Redirect;
use axum::{Router, response::Html, routing::get, routing::post};
use minijinja::syntax::SyntaxConfig;
use minijinja::{Environment, Value, context, path_loader};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tower_sessions::Session;
use tower_sessions::{MemoryStore, SessionManagerLayer};
use uuid::Uuid;

struct AppState {
  env: Environment<'static>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct Item {
  id: Option<String>,
  text: String,
}

#[tokio::main]
async fn main() -> Result<()> {
  let session_store = MemoryStore::default();
  let session_layer =
    SessionManagerLayer::new(session_store).with_secure(false);
  let env = get_env()?;
  let app_state = Arc::new(AppState { env });
  let app = Router::new()
    .route("/", get(handle_index))
    .route("/add", post(handle_add))
    .route("/edit/{id}", get(handle_edit))
    .route("/update", post(handle_update))
    .route("/delete/{id}", get(handle_delete_request))
    .route("/confirm-delete", post(handle_confirm_delete))
    .with_state(app_state)
    .layer(session_layer);
  let listener = tokio::net::TcpListener::bind("127.0.0.1:3939")
    .await
    .unwrap();
  println!("listening on {}", listener.local_addr().unwrap());
  axum::serve(listener, app).await?;
  Ok(())
}

fn data_dir() -> PathBuf {
  PathBuf::from("data-storage")
}

fn get_env() -> Result<Environment<'static>> {
  let mut env = Environment::new();
  env.set_loader(path_loader("templates"));
  env.set_syntax(
    SyntaxConfig::builder()
      .line_statement_prefix("==")
      .block_delimiters("[!", "!]")
      .variable_delimiters("[@", "@]")
      .comment_delimiters("[#", "#]")
      .build()
      .unwrap(),
  );
  Ok(env)
}

pub fn get_files_in_dir(dir: &PathBuf) -> Result<Vec<PathBuf>> {
  let files = fs::read_dir(dir)?
    .filter(|p| p.as_ref().unwrap().path().is_file())
    .map(|p| p.as_ref().unwrap().path())
    .filter(|p| {
      !p.file_name().unwrap().to_str().unwrap().starts_with(".")
    })
    .collect();
  Ok(files)
}

async fn handle_add(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(mut request): Form<Item>,
) -> impl IntoResponse {
  let id = Uuid::now_v7();
  request.id = Some(id.to_string());
  if let Ok(contents) = serde_json::to_string(&request) {
    let file_name = format!("{}.json", id);
    let path = data_dir().join(file_name);
    match fs::write(path, contents) {
      Ok(()) => {
        // TODO: Set flash message that the
        // quote was added.
        dbg!("quote added");
        session
          .insert("flash_message", "quote added".to_string())
          .await
          .unwrap();
      }
      Err(e) => {
        // TODO: Set flash message with quote
        // error.
        dbg!(e);
      }
    }
  };
  Redirect::to("/")
}

async fn handle_confirm_delete(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path =
    data_dir().join(format!("{}.json", request.id.unwrap()));
  fs::remove_file(path).unwrap();
  Redirect::to("/")
}

async fn handle_delete_request(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("delete_request.html", context, State(state))
}

async fn handle_edit(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("edit.html", context, State(state))
}

async fn handle_index(
  session: Session,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  // TODO: Add flash messages.
  match get_files_in_dir(&data_dir()) {
    Ok(paths) => {
      let items: Vec<Item> = paths
        .iter()
        .map(|path| {
          serde_json::from_str::<Item>(
            &fs::read_to_string(path).unwrap(),
          )
          .unwrap()
        })
        .collect();
      let context = context!(items => items);
      render("index.html", context, State(state))
    }
    Err(_) => {
      let context = context!();
      render("index.html", context, State(state))
    }
  }
}

async fn handle_update(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path = data_dir()
    .join(format!("{}.json", &request.id.clone().unwrap()));
  let content = serde_json::to_string(&request).unwrap();
  fs::write(path, content).unwrap();
  Redirect::to("/")
}

fn render(
  template: &str,
  context: Value,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let template = match state.env.get_template(template) {
    Ok(t) => t,
    Err(e) => {
      println!("ERROR: {}", e);
      return Err(StatusCode::INTERNAL_SERVER_ERROR);
    }
  };
  match template.render(context) {
    Ok(r) => Ok(Html(r)),
    Err(e) => {
      println!("ERROR: {}", e);
      Err(StatusCode::INTERNAL_SERVER_ERROR)
    }
  }
}

Item Struct

The example uses a simple Item for the demo data. It consists solely of an id and a text string. The struct itself is set up for serialization so items can be stored directly as JSON objects.

use anyhow::Result;
use axum::extract::Form;
use axum::extract::Path;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::response::Redirect;
use axum::{Router, response::Html, routing::get, routing::post};
use minijinja::syntax::SyntaxConfig;
use minijinja::{Environment, Value, context, path_loader};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tower_sessions::Session;
use tower_sessions::{MemoryStore, SessionManagerLayer};
use uuid::Uuid;

struct AppState {
  env: Environment<'static>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct Item {
  id: Option<String>,
  text: String,
}

#[tokio::main]
async fn main() -> Result<()> {
  let session_store = MemoryStore::default();
  let session_layer =
    SessionManagerLayer::new(session_store).with_secure(false);
  let env = get_env()?;
  let app_state = Arc::new(AppState { env });
  let app = Router::new()
    .route("/", get(handle_index))
    .route("/add", post(handle_add))
    .route("/edit/{id}", get(handle_edit))
    .route("/update", post(handle_update))
    .route("/delete/{id}", get(handle_delete_request))
    .route("/confirm-delete", post(handle_confirm_delete))
    .with_state(app_state)
    .layer(session_layer);
  let listener = tokio::net::TcpListener::bind("127.0.0.1:3939")
    .await
    .unwrap();
  println!("listening on {}", listener.local_addr().unwrap());
  axum::serve(listener, app).await?;
  Ok(())
}

fn data_dir() -> PathBuf {
  PathBuf::from("data-storage")
}

fn get_env() -> Result<Environment<'static>> {
  let mut env = Environment::new();
  env.set_loader(path_loader("templates"));
  env.set_syntax(
    SyntaxConfig::builder()
      .line_statement_prefix("==")
      .block_delimiters("[!", "!]")
      .variable_delimiters("[@", "@]")
      .comment_delimiters("[#", "#]")
      .build()
      .unwrap(),
  );
  Ok(env)
}

pub fn get_files_in_dir(dir: &PathBuf) -> Result<Vec<PathBuf>> {
  let files = fs::read_dir(dir)?
    .filter(|p| p.as_ref().unwrap().path().is_file())
    .map(|p| p.as_ref().unwrap().path())
    .filter(|p| {
      !p.file_name().unwrap().to_str().unwrap().starts_with(".")
    })
    .collect();
  Ok(files)
}

async fn handle_add(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(mut request): Form<Item>,
) -> impl IntoResponse {
  let id = Uuid::now_v7();
  request.id = Some(id.to_string());
  if let Ok(contents) = serde_json::to_string(&request) {
    let file_name = format!("{}.json", id);
    let path = data_dir().join(file_name);
    match fs::write(path, contents) {
      Ok(()) => {
        // TODO: Set flash message that the
        // quote was added.
        dbg!("quote added");
        session
          .insert("flash_message", "quote added".to_string())
          .await
          .unwrap();
      }
      Err(e) => {
        // TODO: Set flash message with quote
        // error.
        dbg!(e);
      }
    }
  };
  Redirect::to("/")
}

async fn handle_confirm_delete(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path =
    data_dir().join(format!("{}.json", request.id.unwrap()));
  fs::remove_file(path).unwrap();
  Redirect::to("/")
}

async fn handle_delete_request(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("delete_request.html", context, State(state))
}

async fn handle_edit(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("edit.html", context, State(state))
}

async fn handle_index(
  session: Session,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  // TODO: Add flash messages.
  match get_files_in_dir(&data_dir()) {
    Ok(paths) => {
      let items: Vec<Item> = paths
        .iter()
        .map(|path| {
          serde_json::from_str::<Item>(
            &fs::read_to_string(path).unwrap(),
          )
          .unwrap()
        })
        .collect();
      let context = context!(items => items);
      render("index.html", context, State(state))
    }
    Err(_) => {
      let context = context!();
      render("index.html", context, State(state))
    }
  }
}

async fn handle_update(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path = data_dir()
    .join(format!("{}.json", &request.id.clone().unwrap()));
  let content = serde_json::to_string(&request).unwrap();
  fs::write(path, content).unwrap();
  Redirect::to("/")
}

fn render(
  template: &str,
  context: Value,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let template = match state.env.get_template(template) {
    Ok(t) => t,
    Err(e) => {
      println!("ERROR: {}", e);
      return Err(StatusCode::INTERNAL_SERVER_ERROR);
    }
  };
  match template.render(context) {
    Ok(r) => Ok(Html(r)),
    Err(e) => {
      println!("ERROR: {}", e);
      Err(StatusCode::INTERNAL_SERVER_ERROR)
    }
  }
}

main()

The main() function sets up session management, the MiniJinja environment, and handlers for each of the routes. The routes each call out to a function that handles them when a corresponding request comes in.

A listener is the last item set up before firing up the server with axum::server() and awaiting it.

use anyhow::Result;
use axum::extract::Form;
use axum::extract::Path;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::response::Redirect;
use axum::{Router, response::Html, routing::get, routing::post};
use minijinja::syntax::SyntaxConfig;
use minijinja::{Environment, Value, context, path_loader};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tower_sessions::Session;
use tower_sessions::{MemoryStore, SessionManagerLayer};
use uuid::Uuid;

struct AppState {
  env: Environment<'static>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct Item {
  id: Option<String>,
  text: String,
}

#[tokio::main]
async fn main() -> Result<()> {
  let session_store = MemoryStore::default();
  let session_layer =
    SessionManagerLayer::new(session_store).with_secure(false);
  let env = get_env()?;
  let app_state = Arc::new(AppState { env });
  let app = Router::new()
    .route("/", get(handle_index))
    .route("/add", post(handle_add))
    .route("/edit/{id}", get(handle_edit))
    .route("/update", post(handle_update))
    .route("/delete/{id}", get(handle_delete_request))
    .route("/confirm-delete", post(handle_confirm_delete))
    .with_state(app_state)
    .layer(session_layer);
  let listener = tokio::net::TcpListener::bind("127.0.0.1:3939")
    .await
    .unwrap();
  println!("listening on {}", listener.local_addr().unwrap());
  axum::serve(listener, app).await?;
  Ok(())
}

fn data_dir() -> PathBuf {
  PathBuf::from("data-storage")
}

fn get_env() -> Result<Environment<'static>> {
  let mut env = Environment::new();
  env.set_loader(path_loader("templates"));
  env.set_syntax(
    SyntaxConfig::builder()
      .line_statement_prefix("==")
      .block_delimiters("[!", "!]")
      .variable_delimiters("[@", "@]")
      .comment_delimiters("[#", "#]")
      .build()
      .unwrap(),
  );
  Ok(env)
}

pub fn get_files_in_dir(dir: &PathBuf) -> Result<Vec<PathBuf>> {
  let files = fs::read_dir(dir)?
    .filter(|p| p.as_ref().unwrap().path().is_file())
    .map(|p| p.as_ref().unwrap().path())
    .filter(|p| {
      !p.file_name().unwrap().to_str().unwrap().starts_with(".")
    })
    .collect();
  Ok(files)
}

async fn handle_add(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(mut request): Form<Item>,
) -> impl IntoResponse {
  let id = Uuid::now_v7();
  request.id = Some(id.to_string());
  if let Ok(contents) = serde_json::to_string(&request) {
    let file_name = format!("{}.json", id);
    let path = data_dir().join(file_name);
    match fs::write(path, contents) {
      Ok(()) => {
        // TODO: Set flash message that the
        // quote was added.
        dbg!("quote added");
        session
          .insert("flash_message", "quote added".to_string())
          .await
          .unwrap();
      }
      Err(e) => {
        // TODO: Set flash message with quote
        // error.
        dbg!(e);
      }
    }
  };
  Redirect::to("/")
}

async fn handle_confirm_delete(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path =
    data_dir().join(format!("{}.json", request.id.unwrap()));
  fs::remove_file(path).unwrap();
  Redirect::to("/")
}

async fn handle_delete_request(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("delete_request.html", context, State(state))
}

async fn handle_edit(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("edit.html", context, State(state))
}

async fn handle_index(
  session: Session,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  // TODO: Add flash messages.
  match get_files_in_dir(&data_dir()) {
    Ok(paths) => {
      let items: Vec<Item> = paths
        .iter()
        .map(|path| {
          serde_json::from_str::<Item>(
            &fs::read_to_string(path).unwrap(),
          )
          .unwrap()
        })
        .collect();
      let context = context!(items => items);
      render("index.html", context, State(state))
    }
    Err(_) => {
      let context = context!();
      render("index.html", context, State(state))
    }
  }
}

async fn handle_update(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path = data_dir()
    .join(format!("{}.json", &request.id.clone().unwrap()));
  let content = serde_json::to_string(&request).unwrap();
  fs::write(path, content).unwrap();
  Redirect::to("/")
}

fn render(
  template: &str,
  context: Value,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let template = match state.env.get_template(template) {
    Ok(t) => t,
    Err(e) => {
      println!("ERROR: {}", e);
      return Err(StatusCode::INTERNAL_SERVER_ERROR);
    }
  };
  match template.render(context) {
    Ok(r) => Ok(Html(r)),
    Err(e) => {
      println!("ERROR: {}", e);
      Err(StatusCode::INTERNAL_SERVER_ERROR)
    }
  }
}

data_dir()

Each item is stored in an independent JSON file that's kept in a data storage directory. The path for this directory is stored in a function to make it easier to change if necessary.

use anyhow::Result;
use axum::extract::Form;
use axum::extract::Path;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::response::Redirect;
use axum::{Router, response::Html, routing::get, routing::post};
use minijinja::syntax::SyntaxConfig;
use minijinja::{Environment, Value, context, path_loader};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tower_sessions::Session;
use tower_sessions::{MemoryStore, SessionManagerLayer};
use uuid::Uuid;

struct AppState {
  env: Environment<'static>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct Item {
  id: Option<String>,
  text: String,
}

#[tokio::main]
async fn main() -> Result<()> {
  let session_store = MemoryStore::default();
  let session_layer =
    SessionManagerLayer::new(session_store).with_secure(false);
  let env = get_env()?;
  let app_state = Arc::new(AppState { env });
  let app = Router::new()
    .route("/", get(handle_index))
    .route("/add", post(handle_add))
    .route("/edit/{id}", get(handle_edit))
    .route("/update", post(handle_update))
    .route("/delete/{id}", get(handle_delete_request))
    .route("/confirm-delete", post(handle_confirm_delete))
    .with_state(app_state)
    .layer(session_layer);
  let listener = tokio::net::TcpListener::bind("127.0.0.1:3939")
    .await
    .unwrap();
  println!("listening on {}", listener.local_addr().unwrap());
  axum::serve(listener, app).await?;
  Ok(())
}

fn data_dir() -> PathBuf {
  PathBuf::from("data-storage")
}

fn get_env() -> Result<Environment<'static>> {
  let mut env = Environment::new();
  env.set_loader(path_loader("templates"));
  env.set_syntax(
    SyntaxConfig::builder()
      .line_statement_prefix("==")
      .block_delimiters("[!", "!]")
      .variable_delimiters("[@", "@]")
      .comment_delimiters("[#", "#]")
      .build()
      .unwrap(),
  );
  Ok(env)
}

pub fn get_files_in_dir(dir: &PathBuf) -> Result<Vec<PathBuf>> {
  let files = fs::read_dir(dir)?
    .filter(|p| p.as_ref().unwrap().path().is_file())
    .map(|p| p.as_ref().unwrap().path())
    .filter(|p| {
      !p.file_name().unwrap().to_str().unwrap().starts_with(".")
    })
    .collect();
  Ok(files)
}

async fn handle_add(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(mut request): Form<Item>,
) -> impl IntoResponse {
  let id = Uuid::now_v7();
  request.id = Some(id.to_string());
  if let Ok(contents) = serde_json::to_string(&request) {
    let file_name = format!("{}.json", id);
    let path = data_dir().join(file_name);
    match fs::write(path, contents) {
      Ok(()) => {
        // TODO: Set flash message that the
        // quote was added.
        dbg!("quote added");
        session
          .insert("flash_message", "quote added".to_string())
          .await
          .unwrap();
      }
      Err(e) => {
        // TODO: Set flash message with quote
        // error.
        dbg!(e);
      }
    }
  };
  Redirect::to("/")
}

async fn handle_confirm_delete(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path =
    data_dir().join(format!("{}.json", request.id.unwrap()));
  fs::remove_file(path).unwrap();
  Redirect::to("/")
}

async fn handle_delete_request(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("delete_request.html", context, State(state))
}

async fn handle_edit(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("edit.html", context, State(state))
}

async fn handle_index(
  session: Session,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  // TODO: Add flash messages.
  match get_files_in_dir(&data_dir()) {
    Ok(paths) => {
      let items: Vec<Item> = paths
        .iter()
        .map(|path| {
          serde_json::from_str::<Item>(
            &fs::read_to_string(path).unwrap(),
          )
          .unwrap()
        })
        .collect();
      let context = context!(items => items);
      render("index.html", context, State(state))
    }
    Err(_) => {
      let context = context!();
      render("index.html", context, State(state))
    }
  }
}

async fn handle_update(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path = data_dir()
    .join(format!("{}.json", &request.id.clone().unwrap()));
  let content = serde_json::to_string(&request).unwrap();
  fs::write(path, content).unwrap();
  Redirect::to("/")
}

fn render(
  template: &str,
  context: Value,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let template = match state.env.get_template(template) {
    Ok(t) => t,
    Err(e) => {
      println!("ERROR: {}", e);
      return Err(StatusCode::INTERNAL_SERVER_ERROR);
    }
  };
  match template.render(context) {
    Ok(r) => Ok(Html(r)),
    Err(e) => {
      println!("ERROR: {}", e);
      Err(StatusCode::INTERNAL_SERVER_ERROR)
    }
  }
}

get_env()

The get_env() function initializes the MiniJinja environment. It starts by defining the directory that holds the templates. The .set_syntax() method changes some defaults based on my preferences (e.g. using [@ @] instead of {{ }} tokens as I find them easier to read).

use anyhow::Result;
use axum::extract::Form;
use axum::extract::Path;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::response::Redirect;
use axum::{Router, response::Html, routing::get, routing::post};
use minijinja::syntax::SyntaxConfig;
use minijinja::{Environment, Value, context, path_loader};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tower_sessions::Session;
use tower_sessions::{MemoryStore, SessionManagerLayer};
use uuid::Uuid;

struct AppState {
  env: Environment<'static>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct Item {
  id: Option<String>,
  text: String,
}

#[tokio::main]
async fn main() -> Result<()> {
  let session_store = MemoryStore::default();
  let session_layer =
    SessionManagerLayer::new(session_store).with_secure(false);
  let env = get_env()?;
  let app_state = Arc::new(AppState { env });
  let app = Router::new()
    .route("/", get(handle_index))
    .route("/add", post(handle_add))
    .route("/edit/{id}", get(handle_edit))
    .route("/update", post(handle_update))
    .route("/delete/{id}", get(handle_delete_request))
    .route("/confirm-delete", post(handle_confirm_delete))
    .with_state(app_state)
    .layer(session_layer);
  let listener = tokio::net::TcpListener::bind("127.0.0.1:3939")
    .await
    .unwrap();
  println!("listening on {}", listener.local_addr().unwrap());
  axum::serve(listener, app).await?;
  Ok(())
}

fn data_dir() -> PathBuf {
  PathBuf::from("data-storage")
}

fn get_env() -> Result<Environment<'static>> {
  let mut env = Environment::new();
  env.set_loader(path_loader("templates"));
  env.set_syntax(
    SyntaxConfig::builder()
      .line_statement_prefix("==")
      .block_delimiters("[!", "!]")
      .variable_delimiters("[@", "@]")
      .comment_delimiters("[#", "#]")
      .build()
      .unwrap(),
  );
  Ok(env)
}

pub fn get_files_in_dir(dir: &PathBuf) -> Result<Vec<PathBuf>> {
  let files = fs::read_dir(dir)?
    .filter(|p| p.as_ref().unwrap().path().is_file())
    .map(|p| p.as_ref().unwrap().path())
    .filter(|p| {
      !p.file_name().unwrap().to_str().unwrap().starts_with(".")
    })
    .collect();
  Ok(files)
}

async fn handle_add(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(mut request): Form<Item>,
) -> impl IntoResponse {
  let id = Uuid::now_v7();
  request.id = Some(id.to_string());
  if let Ok(contents) = serde_json::to_string(&request) {
    let file_name = format!("{}.json", id);
    let path = data_dir().join(file_name);
    match fs::write(path, contents) {
      Ok(()) => {
        // TODO: Set flash message that the
        // quote was added.
        dbg!("quote added");
        session
          .insert("flash_message", "quote added".to_string())
          .await
          .unwrap();
      }
      Err(e) => {
        // TODO: Set flash message with quote
        // error.
        dbg!(e);
      }
    }
  };
  Redirect::to("/")
}

async fn handle_confirm_delete(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path =
    data_dir().join(format!("{}.json", request.id.unwrap()));
  fs::remove_file(path).unwrap();
  Redirect::to("/")
}

async fn handle_delete_request(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("delete_request.html", context, State(state))
}

async fn handle_edit(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("edit.html", context, State(state))
}

async fn handle_index(
  session: Session,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  // TODO: Add flash messages.
  match get_files_in_dir(&data_dir()) {
    Ok(paths) => {
      let items: Vec<Item> = paths
        .iter()
        .map(|path| {
          serde_json::from_str::<Item>(
            &fs::read_to_string(path).unwrap(),
          )
          .unwrap()
        })
        .collect();
      let context = context!(items => items);
      render("index.html", context, State(state))
    }
    Err(_) => {
      let context = context!();
      render("index.html", context, State(state))
    }
  }
}

async fn handle_update(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path = data_dir()
    .join(format!("{}.json", &request.id.clone().unwrap()));
  let content = serde_json::to_string(&request).unwrap();
  fs::write(path, content).unwrap();
  Redirect::to("/")
}

fn render(
  template: &str,
  context: Value,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let template = match state.env.get_template(template) {
    Ok(t) => t,
    Err(e) => {
      println!("ERROR: {}", e);
      return Err(StatusCode::INTERNAL_SERVER_ERROR);
    }
  };
  match template.render(context) {
    Ok(r) => Ok(Html(r)),
    Err(e) => {
      println!("ERROR: {}", e);
      Err(StatusCode::INTERNAL_SERVER_ERROR)
    }
  }
}

get_files_in_dir()

This is a helper function that grabs the paths for the JSON data files.

use anyhow::Result;
use axum::extract::Form;
use axum::extract::Path;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::response::Redirect;
use axum::{Router, response::Html, routing::get, routing::post};
use minijinja::syntax::SyntaxConfig;
use minijinja::{Environment, Value, context, path_loader};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tower_sessions::Session;
use tower_sessions::{MemoryStore, SessionManagerLayer};
use uuid::Uuid;

struct AppState {
  env: Environment<'static>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct Item {
  id: Option<String>,
  text: String,
}

#[tokio::main]
async fn main() -> Result<()> {
  let session_store = MemoryStore::default();
  let session_layer =
    SessionManagerLayer::new(session_store).with_secure(false);
  let env = get_env()?;
  let app_state = Arc::new(AppState { env });
  let app = Router::new()
    .route("/", get(handle_index))
    .route("/add", post(handle_add))
    .route("/edit/{id}", get(handle_edit))
    .route("/update", post(handle_update))
    .route("/delete/{id}", get(handle_delete_request))
    .route("/confirm-delete", post(handle_confirm_delete))
    .with_state(app_state)
    .layer(session_layer);
  let listener = tokio::net::TcpListener::bind("127.0.0.1:3939")
    .await
    .unwrap();
  println!("listening on {}", listener.local_addr().unwrap());
  axum::serve(listener, app).await?;
  Ok(())
}

fn data_dir() -> PathBuf {
  PathBuf::from("data-storage")
}

fn get_env() -> Result<Environment<'static>> {
  let mut env = Environment::new();
  env.set_loader(path_loader("templates"));
  env.set_syntax(
    SyntaxConfig::builder()
      .line_statement_prefix("==")
      .block_delimiters("[!", "!]")
      .variable_delimiters("[@", "@]")
      .comment_delimiters("[#", "#]")
      .build()
      .unwrap(),
  );
  Ok(env)
}

pub fn get_files_in_dir(dir: &PathBuf) -> Result<Vec<PathBuf>> {
  let files = fs::read_dir(dir)?
    .filter(|p| p.as_ref().unwrap().path().is_file())
    .map(|p| p.as_ref().unwrap().path())
    .filter(|p| {
      !p.file_name().unwrap().to_str().unwrap().starts_with(".")
    })
    .collect();
  Ok(files)
}

async fn handle_add(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(mut request): Form<Item>,
) -> impl IntoResponse {
  let id = Uuid::now_v7();
  request.id = Some(id.to_string());
  if let Ok(contents) = serde_json::to_string(&request) {
    let file_name = format!("{}.json", id);
    let path = data_dir().join(file_name);
    match fs::write(path, contents) {
      Ok(()) => {
        // TODO: Set flash message that the
        // quote was added.
        dbg!("quote added");
        session
          .insert("flash_message", "quote added".to_string())
          .await
          .unwrap();
      }
      Err(e) => {
        // TODO: Set flash message with quote
        // error.
        dbg!(e);
      }
    }
  };
  Redirect::to("/")
}

async fn handle_confirm_delete(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path =
    data_dir().join(format!("{}.json", request.id.unwrap()));
  fs::remove_file(path).unwrap();
  Redirect::to("/")
}

async fn handle_delete_request(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("delete_request.html", context, State(state))
}

async fn handle_edit(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("edit.html", context, State(state))
}

async fn handle_index(
  session: Session,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  // TODO: Add flash messages.
  match get_files_in_dir(&data_dir()) {
    Ok(paths) => {
      let items: Vec<Item> = paths
        .iter()
        .map(|path| {
          serde_json::from_str::<Item>(
            &fs::read_to_string(path).unwrap(),
          )
          .unwrap()
        })
        .collect();
      let context = context!(items => items);
      render("index.html", context, State(state))
    }
    Err(_) => {
      let context = context!();
      render("index.html", context, State(state))
    }
  }
}

async fn handle_update(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path = data_dir()
    .join(format!("{}.json", &request.id.clone().unwrap()));
  let content = serde_json::to_string(&request).unwrap();
  fs::write(path, content).unwrap();
  Redirect::to("/")
}

fn render(
  template: &str,
  context: Value,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let template = match state.env.get_template(template) {
    Ok(t) => t,
    Err(e) => {
      println!("ERROR: {}", e);
      return Err(StatusCode::INTERNAL_SERVER_ERROR);
    }
  };
  match template.render(context) {
    Ok(r) => Ok(Html(r)),
    Err(e) => {
      println!("ERROR: {}", e);
      Err(StatusCode::INTERNAL_SERVER_ERROR)
    }
  }
}

handle_add()

The handler for adding new items. I've started the work to add flash messages. There's still more to do, though.

Each new item is given a UUID v7 (which makes them sortable based on timestamp). The id is stored in the data as well as being used for the file name.

use anyhow::Result;
use axum::extract::Form;
use axum::extract::Path;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::response::Redirect;
use axum::{Router, response::Html, routing::get, routing::post};
use minijinja::syntax::SyntaxConfig;
use minijinja::{Environment, Value, context, path_loader};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tower_sessions::Session;
use tower_sessions::{MemoryStore, SessionManagerLayer};
use uuid::Uuid;

struct AppState {
  env: Environment<'static>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct Item {
  id: Option<String>,
  text: String,
}

#[tokio::main]
async fn main() -> Result<()> {
  let session_store = MemoryStore::default();
  let session_layer =
    SessionManagerLayer::new(session_store).with_secure(false);
  let env = get_env()?;
  let app_state = Arc::new(AppState { env });
  let app = Router::new()
    .route("/", get(handle_index))
    .route("/add", post(handle_add))
    .route("/edit/{id}", get(handle_edit))
    .route("/update", post(handle_update))
    .route("/delete/{id}", get(handle_delete_request))
    .route("/confirm-delete", post(handle_confirm_delete))
    .with_state(app_state)
    .layer(session_layer);
  let listener = tokio::net::TcpListener::bind("127.0.0.1:3939")
    .await
    .unwrap();
  println!("listening on {}", listener.local_addr().unwrap());
  axum::serve(listener, app).await?;
  Ok(())
}

fn data_dir() -> PathBuf {
  PathBuf::from("data-storage")
}

fn get_env() -> Result<Environment<'static>> {
  let mut env = Environment::new();
  env.set_loader(path_loader("templates"));
  env.set_syntax(
    SyntaxConfig::builder()
      .line_statement_prefix("==")
      .block_delimiters("[!", "!]")
      .variable_delimiters("[@", "@]")
      .comment_delimiters("[#", "#]")
      .build()
      .unwrap(),
  );
  Ok(env)
}

pub fn get_files_in_dir(dir: &PathBuf) -> Result<Vec<PathBuf>> {
  let files = fs::read_dir(dir)?
    .filter(|p| p.as_ref().unwrap().path().is_file())
    .map(|p| p.as_ref().unwrap().path())
    .filter(|p| {
      !p.file_name().unwrap().to_str().unwrap().starts_with(".")
    })
    .collect();
  Ok(files)
}

async fn handle_add(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(mut request): Form<Item>,
) -> impl IntoResponse {
  let id = Uuid::now_v7();
  request.id = Some(id.to_string());
  if let Ok(contents) = serde_json::to_string(&request) {
    let file_name = format!("{}.json", id);
    let path = data_dir().join(file_name);
    match fs::write(path, contents) {
      Ok(()) => {
        // TODO: Set flash message that the
        // quote was added.
        dbg!("quote added");
        session
          .insert("flash_message", "quote added".to_string())
          .await
          .unwrap();
      }
      Err(e) => {
        // TODO: Set flash message with quote
        // error.
        dbg!(e);
      }
    }
  };
  Redirect::to("/")
}

async fn handle_confirm_delete(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path =
    data_dir().join(format!("{}.json", request.id.unwrap()));
  fs::remove_file(path).unwrap();
  Redirect::to("/")
}

async fn handle_delete_request(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("delete_request.html", context, State(state))
}

async fn handle_edit(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("edit.html", context, State(state))
}

async fn handle_index(
  session: Session,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  // TODO: Add flash messages.
  match get_files_in_dir(&data_dir()) {
    Ok(paths) => {
      let items: Vec<Item> = paths
        .iter()
        .map(|path| {
          serde_json::from_str::<Item>(
            &fs::read_to_string(path).unwrap(),
          )
          .unwrap()
        })
        .collect();
      let context = context!(items => items);
      render("index.html", context, State(state))
    }
    Err(_) => {
      let context = context!();
      render("index.html", context, State(state))
    }
  }
}

async fn handle_update(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path = data_dir()
    .join(format!("{}.json", &request.id.clone().unwrap()));
  let content = serde_json::to_string(&request).unwrap();
  fs::write(path, content).unwrap();
  Redirect::to("/")
}

fn render(
  template: &str,
  context: Value,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let template = match state.env.get_template(template) {
    Ok(t) => t,
    Err(e) => {
      println!("ERROR: {}", e);
      return Err(StatusCode::INTERNAL_SERVER_ERROR);
    }
  };
  match template.render(context) {
    Ok(r) => Ok(Html(r)),
    Err(e) => {
      println!("ERROR: {}", e);
      Err(StatusCode::INTERNAL_SERVER_ERROR)
    }
  }
}

handle_confirm_delete()

Deleting an item is a two step process. The first is clicking the delete link which brings up a confirmation page. Clicking the confirmation button on that page makes a call to the route handled by this function which does the actual deletion.

use anyhow::Result;
use axum::extract::Form;
use axum::extract::Path;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::response::Redirect;
use axum::{Router, response::Html, routing::get, routing::post};
use minijinja::syntax::SyntaxConfig;
use minijinja::{Environment, Value, context, path_loader};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tower_sessions::Session;
use tower_sessions::{MemoryStore, SessionManagerLayer};
use uuid::Uuid;

struct AppState {
  env: Environment<'static>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct Item {
  id: Option<String>,
  text: String,
}

#[tokio::main]
async fn main() -> Result<()> {
  let session_store = MemoryStore::default();
  let session_layer =
    SessionManagerLayer::new(session_store).with_secure(false);
  let env = get_env()?;
  let app_state = Arc::new(AppState { env });
  let app = Router::new()
    .route("/", get(handle_index))
    .route("/add", post(handle_add))
    .route("/edit/{id}", get(handle_edit))
    .route("/update", post(handle_update))
    .route("/delete/{id}", get(handle_delete_request))
    .route("/confirm-delete", post(handle_confirm_delete))
    .with_state(app_state)
    .layer(session_layer);
  let listener = tokio::net::TcpListener::bind("127.0.0.1:3939")
    .await
    .unwrap();
  println!("listening on {}", listener.local_addr().unwrap());
  axum::serve(listener, app).await?;
  Ok(())
}

fn data_dir() -> PathBuf {
  PathBuf::from("data-storage")
}

fn get_env() -> Result<Environment<'static>> {
  let mut env = Environment::new();
  env.set_loader(path_loader("templates"));
  env.set_syntax(
    SyntaxConfig::builder()
      .line_statement_prefix("==")
      .block_delimiters("[!", "!]")
      .variable_delimiters("[@", "@]")
      .comment_delimiters("[#", "#]")
      .build()
      .unwrap(),
  );
  Ok(env)
}

pub fn get_files_in_dir(dir: &PathBuf) -> Result<Vec<PathBuf>> {
  let files = fs::read_dir(dir)?
    .filter(|p| p.as_ref().unwrap().path().is_file())
    .map(|p| p.as_ref().unwrap().path())
    .filter(|p| {
      !p.file_name().unwrap().to_str().unwrap().starts_with(".")
    })
    .collect();
  Ok(files)
}

async fn handle_add(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(mut request): Form<Item>,
) -> impl IntoResponse {
  let id = Uuid::now_v7();
  request.id = Some(id.to_string());
  if let Ok(contents) = serde_json::to_string(&request) {
    let file_name = format!("{}.json", id);
    let path = data_dir().join(file_name);
    match fs::write(path, contents) {
      Ok(()) => {
        // TODO: Set flash message that the
        // quote was added.
        dbg!("quote added");
        session
          .insert("flash_message", "quote added".to_string())
          .await
          .unwrap();
      }
      Err(e) => {
        // TODO: Set flash message with quote
        // error.
        dbg!(e);
      }
    }
  };
  Redirect::to("/")
}

async fn handle_confirm_delete(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path =
    data_dir().join(format!("{}.json", request.id.unwrap()));
  fs::remove_file(path).unwrap();
  Redirect::to("/")
}

async fn handle_delete_request(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("delete_request.html", context, State(state))
}

async fn handle_edit(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("edit.html", context, State(state))
}

async fn handle_index(
  session: Session,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  // TODO: Add flash messages.
  match get_files_in_dir(&data_dir()) {
    Ok(paths) => {
      let items: Vec<Item> = paths
        .iter()
        .map(|path| {
          serde_json::from_str::<Item>(
            &fs::read_to_string(path).unwrap(),
          )
          .unwrap()
        })
        .collect();
      let context = context!(items => items);
      render("index.html", context, State(state))
    }
    Err(_) => {
      let context = context!();
      render("index.html", context, State(state))
    }
  }
}

async fn handle_update(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path = data_dir()
    .join(format!("{}.json", &request.id.clone().unwrap()));
  let content = serde_json::to_string(&request).unwrap();
  fs::write(path, content).unwrap();
  Redirect::to("/")
}

fn render(
  template: &str,
  context: Value,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let template = match state.env.get_template(template) {
    Ok(t) => t,
    Err(e) => {
      println!("ERROR: {}", e);
      return Err(StatusCode::INTERNAL_SERVER_ERROR);
    }
  };
  match template.render(context) {
    Ok(r) => Ok(Html(r)),
    Err(e) => {
      println!("ERROR: {}", e);
      Err(StatusCode::INTERNAL_SERVER_ERROR)
    }
  }
}

handle_delete_request()

This function presents the page for the first part of the delete request the provides the confirmation button.

use anyhow::Result;
use axum::extract::Form;
use axum::extract::Path;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::response::Redirect;
use axum::{Router, response::Html, routing::get, routing::post};
use minijinja::syntax::SyntaxConfig;
use minijinja::{Environment, Value, context, path_loader};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tower_sessions::Session;
use tower_sessions::{MemoryStore, SessionManagerLayer};
use uuid::Uuid;

struct AppState {
  env: Environment<'static>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct Item {
  id: Option<String>,
  text: String,
}

#[tokio::main]
async fn main() -> Result<()> {
  let session_store = MemoryStore::default();
  let session_layer =
    SessionManagerLayer::new(session_store).with_secure(false);
  let env = get_env()?;
  let app_state = Arc::new(AppState { env });
  let app = Router::new()
    .route("/", get(handle_index))
    .route("/add", post(handle_add))
    .route("/edit/{id}", get(handle_edit))
    .route("/update", post(handle_update))
    .route("/delete/{id}", get(handle_delete_request))
    .route("/confirm-delete", post(handle_confirm_delete))
    .with_state(app_state)
    .layer(session_layer);
  let listener = tokio::net::TcpListener::bind("127.0.0.1:3939")
    .await
    .unwrap();
  println!("listening on {}", listener.local_addr().unwrap());
  axum::serve(listener, app).await?;
  Ok(())
}

fn data_dir() -> PathBuf {
  PathBuf::from("data-storage")
}

fn get_env() -> Result<Environment<'static>> {
  let mut env = Environment::new();
  env.set_loader(path_loader("templates"));
  env.set_syntax(
    SyntaxConfig::builder()
      .line_statement_prefix("==")
      .block_delimiters("[!", "!]")
      .variable_delimiters("[@", "@]")
      .comment_delimiters("[#", "#]")
      .build()
      .unwrap(),
  );
  Ok(env)
}

pub fn get_files_in_dir(dir: &PathBuf) -> Result<Vec<PathBuf>> {
  let files = fs::read_dir(dir)?
    .filter(|p| p.as_ref().unwrap().path().is_file())
    .map(|p| p.as_ref().unwrap().path())
    .filter(|p| {
      !p.file_name().unwrap().to_str().unwrap().starts_with(".")
    })
    .collect();
  Ok(files)
}

async fn handle_add(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(mut request): Form<Item>,
) -> impl IntoResponse {
  let id = Uuid::now_v7();
  request.id = Some(id.to_string());
  if let Ok(contents) = serde_json::to_string(&request) {
    let file_name = format!("{}.json", id);
    let path = data_dir().join(file_name);
    match fs::write(path, contents) {
      Ok(()) => {
        // TODO: Set flash message that the
        // quote was added.
        dbg!("quote added");
        session
          .insert("flash_message", "quote added".to_string())
          .await
          .unwrap();
      }
      Err(e) => {
        // TODO: Set flash message with quote
        // error.
        dbg!(e);
      }
    }
  };
  Redirect::to("/")
}

async fn handle_confirm_delete(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path =
    data_dir().join(format!("{}.json", request.id.unwrap()));
  fs::remove_file(path).unwrap();
  Redirect::to("/")
}

async fn handle_delete_request(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("delete_request.html", context, State(state))
}

async fn handle_edit(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("edit.html", context, State(state))
}

async fn handle_index(
  session: Session,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  // TODO: Add flash messages.
  match get_files_in_dir(&data_dir()) {
    Ok(paths) => {
      let items: Vec<Item> = paths
        .iter()
        .map(|path| {
          serde_json::from_str::<Item>(
            &fs::read_to_string(path).unwrap(),
          )
          .unwrap()
        })
        .collect();
      let context = context!(items => items);
      render("index.html", context, State(state))
    }
    Err(_) => {
      let context = context!();
      render("index.html", context, State(state))
    }
  }
}

async fn handle_update(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path = data_dir()
    .join(format!("{}.json", &request.id.clone().unwrap()));
  let content = serde_json::to_string(&request).unwrap();
  fs::write(path, content).unwrap();
  Redirect::to("/")
}

fn render(
  template: &str,
  context: Value,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let template = match state.env.get_template(template) {
    Ok(t) => t,
    Err(e) => {
      println!("ERROR: {}", e);
      return Err(StatusCode::INTERNAL_SERVER_ERROR);
    }
  };
  match template.render(context) {
    Ok(r) => Ok(Html(r)),
    Err(e) => {
      println!("ERROR: {}", e);
      Err(StatusCode::INTERNAL_SERVER_ERROR)
    }
  }
}

handle_edit()

The handler that displays the edit page.

use anyhow::Result;
use axum::extract::Form;
use axum::extract::Path;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::response::Redirect;
use axum::{Router, response::Html, routing::get, routing::post};
use minijinja::syntax::SyntaxConfig;
use minijinja::{Environment, Value, context, path_loader};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tower_sessions::Session;
use tower_sessions::{MemoryStore, SessionManagerLayer};
use uuid::Uuid;

struct AppState {
  env: Environment<'static>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct Item {
  id: Option<String>,
  text: String,
}

#[tokio::main]
async fn main() -> Result<()> {
  let session_store = MemoryStore::default();
  let session_layer =
    SessionManagerLayer::new(session_store).with_secure(false);
  let env = get_env()?;
  let app_state = Arc::new(AppState { env });
  let app = Router::new()
    .route("/", get(handle_index))
    .route("/add", post(handle_add))
    .route("/edit/{id}", get(handle_edit))
    .route("/update", post(handle_update))
    .route("/delete/{id}", get(handle_delete_request))
    .route("/confirm-delete", post(handle_confirm_delete))
    .with_state(app_state)
    .layer(session_layer);
  let listener = tokio::net::TcpListener::bind("127.0.0.1:3939")
    .await
    .unwrap();
  println!("listening on {}", listener.local_addr().unwrap());
  axum::serve(listener, app).await?;
  Ok(())
}

fn data_dir() -> PathBuf {
  PathBuf::from("data-storage")
}

fn get_env() -> Result<Environment<'static>> {
  let mut env = Environment::new();
  env.set_loader(path_loader("templates"));
  env.set_syntax(
    SyntaxConfig::builder()
      .line_statement_prefix("==")
      .block_delimiters("[!", "!]")
      .variable_delimiters("[@", "@]")
      .comment_delimiters("[#", "#]")
      .build()
      .unwrap(),
  );
  Ok(env)
}

pub fn get_files_in_dir(dir: &PathBuf) -> Result<Vec<PathBuf>> {
  let files = fs::read_dir(dir)?
    .filter(|p| p.as_ref().unwrap().path().is_file())
    .map(|p| p.as_ref().unwrap().path())
    .filter(|p| {
      !p.file_name().unwrap().to_str().unwrap().starts_with(".")
    })
    .collect();
  Ok(files)
}

async fn handle_add(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(mut request): Form<Item>,
) -> impl IntoResponse {
  let id = Uuid::now_v7();
  request.id = Some(id.to_string());
  if let Ok(contents) = serde_json::to_string(&request) {
    let file_name = format!("{}.json", id);
    let path = data_dir().join(file_name);
    match fs::write(path, contents) {
      Ok(()) => {
        // TODO: Set flash message that the
        // quote was added.
        dbg!("quote added");
        session
          .insert("flash_message", "quote added".to_string())
          .await
          .unwrap();
      }
      Err(e) => {
        // TODO: Set flash message with quote
        // error.
        dbg!(e);
      }
    }
  };
  Redirect::to("/")
}

async fn handle_confirm_delete(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path =
    data_dir().join(format!("{}.json", request.id.unwrap()));
  fs::remove_file(path).unwrap();
  Redirect::to("/")
}

async fn handle_delete_request(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("delete_request.html", context, State(state))
}

async fn handle_edit(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("edit.html", context, State(state))
}

async fn handle_index(
  session: Session,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  // TODO: Add flash messages.
  match get_files_in_dir(&data_dir()) {
    Ok(paths) => {
      let items: Vec<Item> = paths
        .iter()
        .map(|path| {
          serde_json::from_str::<Item>(
            &fs::read_to_string(path).unwrap(),
          )
          .unwrap()
        })
        .collect();
      let context = context!(items => items);
      render("index.html", context, State(state))
    }
    Err(_) => {
      let context = context!();
      render("index.html", context, State(state))
    }
  }
}

async fn handle_update(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path = data_dir()
    .join(format!("{}.json", &request.id.clone().unwrap()));
  let content = serde_json::to_string(&request).unwrap();
  fs::write(path, content).unwrap();
  Redirect::to("/")
}

fn render(
  template: &str,
  context: Value,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let template = match state.env.get_template(template) {
    Ok(t) => t,
    Err(e) => {
      println!("ERROR: {}", e);
      return Err(StatusCode::INTERNAL_SERVER_ERROR);
    }
  };
  match template.render(context) {
    Ok(r) => Ok(Html(r)),
    Err(e) => {
      println!("ERROR: {}", e);
      Err(StatusCode::INTERNAL_SERVER_ERROR)
    }
  }
}

handle_index()

The main page that displays all the items and the links to edit and delete them.

use anyhow::Result;
use axum::extract::Form;
use axum::extract::Path;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::response::Redirect;
use axum::{Router, response::Html, routing::get, routing::post};
use minijinja::syntax::SyntaxConfig;
use minijinja::{Environment, Value, context, path_loader};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tower_sessions::Session;
use tower_sessions::{MemoryStore, SessionManagerLayer};
use uuid::Uuid;

struct AppState {
  env: Environment<'static>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct Item {
  id: Option<String>,
  text: String,
}

#[tokio::main]
async fn main() -> Result<()> {
  let session_store = MemoryStore::default();
  let session_layer =
    SessionManagerLayer::new(session_store).with_secure(false);
  let env = get_env()?;
  let app_state = Arc::new(AppState { env });
  let app = Router::new()
    .route("/", get(handle_index))
    .route("/add", post(handle_add))
    .route("/edit/{id}", get(handle_edit))
    .route("/update", post(handle_update))
    .route("/delete/{id}", get(handle_delete_request))
    .route("/confirm-delete", post(handle_confirm_delete))
    .with_state(app_state)
    .layer(session_layer);
  let listener = tokio::net::TcpListener::bind("127.0.0.1:3939")
    .await
    .unwrap();
  println!("listening on {}", listener.local_addr().unwrap());
  axum::serve(listener, app).await?;
  Ok(())
}

fn data_dir() -> PathBuf {
  PathBuf::from("data-storage")
}

fn get_env() -> Result<Environment<'static>> {
  let mut env = Environment::new();
  env.set_loader(path_loader("templates"));
  env.set_syntax(
    SyntaxConfig::builder()
      .line_statement_prefix("==")
      .block_delimiters("[!", "!]")
      .variable_delimiters("[@", "@]")
      .comment_delimiters("[#", "#]")
      .build()
      .unwrap(),
  );
  Ok(env)
}

pub fn get_files_in_dir(dir: &PathBuf) -> Result<Vec<PathBuf>> {
  let files = fs::read_dir(dir)?
    .filter(|p| p.as_ref().unwrap().path().is_file())
    .map(|p| p.as_ref().unwrap().path())
    .filter(|p| {
      !p.file_name().unwrap().to_str().unwrap().starts_with(".")
    })
    .collect();
  Ok(files)
}

async fn handle_add(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(mut request): Form<Item>,
) -> impl IntoResponse {
  let id = Uuid::now_v7();
  request.id = Some(id.to_string());
  if let Ok(contents) = serde_json::to_string(&request) {
    let file_name = format!("{}.json", id);
    let path = data_dir().join(file_name);
    match fs::write(path, contents) {
      Ok(()) => {
        // TODO: Set flash message that the
        // quote was added.
        dbg!("quote added");
        session
          .insert("flash_message", "quote added".to_string())
          .await
          .unwrap();
      }
      Err(e) => {
        // TODO: Set flash message with quote
        // error.
        dbg!(e);
      }
    }
  };
  Redirect::to("/")
}

async fn handle_confirm_delete(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path =
    data_dir().join(format!("{}.json", request.id.unwrap()));
  fs::remove_file(path).unwrap();
  Redirect::to("/")
}

async fn handle_delete_request(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("delete_request.html", context, State(state))
}

async fn handle_edit(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("edit.html", context, State(state))
}

async fn handle_index(
  session: Session,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  // TODO: Add flash messages.
  match get_files_in_dir(&data_dir()) {
    Ok(paths) => {
      let items: Vec<Item> = paths
        .iter()
        .map(|path| {
          serde_json::from_str::<Item>(
            &fs::read_to_string(path).unwrap(),
          )
          .unwrap()
        })
        .collect();
      let context = context!(items => items);
      render("index.html", context, State(state))
    }
    Err(_) => {
      let context = context!();
      render("index.html", context, State(state))
    }
  }
}

async fn handle_update(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path = data_dir()
    .join(format!("{}.json", &request.id.clone().unwrap()));
  let content = serde_json::to_string(&request).unwrap();
  fs::write(path, content).unwrap();
  Redirect::to("/")
}

fn render(
  template: &str,
  context: Value,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let template = match state.env.get_template(template) {
    Ok(t) => t,
    Err(e) => {
      println!("ERROR: {}", e);
      return Err(StatusCode::INTERNAL_SERVER_ERROR);
    }
  };
  match template.render(context) {
    Ok(r) => Ok(Html(r)),
    Err(e) => {
      println!("ERROR: {}", e);
      Err(StatusCode::INTERNAL_SERVER_ERROR)
    }
  }
}

handle_update()

This is the handler that updates items when changes are submitted from the edit page.

use anyhow::Result;
use axum::extract::Form;
use axum::extract::Path;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::response::Redirect;
use axum::{Router, response::Html, routing::get, routing::post};
use minijinja::syntax::SyntaxConfig;
use minijinja::{Environment, Value, context, path_loader};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tower_sessions::Session;
use tower_sessions::{MemoryStore, SessionManagerLayer};
use uuid::Uuid;

struct AppState {
  env: Environment<'static>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct Item {
  id: Option<String>,
  text: String,
}

#[tokio::main]
async fn main() -> Result<()> {
  let session_store = MemoryStore::default();
  let session_layer =
    SessionManagerLayer::new(session_store).with_secure(false);
  let env = get_env()?;
  let app_state = Arc::new(AppState { env });
  let app = Router::new()
    .route("/", get(handle_index))
    .route("/add", post(handle_add))
    .route("/edit/{id}", get(handle_edit))
    .route("/update", post(handle_update))
    .route("/delete/{id}", get(handle_delete_request))
    .route("/confirm-delete", post(handle_confirm_delete))
    .with_state(app_state)
    .layer(session_layer);
  let listener = tokio::net::TcpListener::bind("127.0.0.1:3939")
    .await
    .unwrap();
  println!("listening on {}", listener.local_addr().unwrap());
  axum::serve(listener, app).await?;
  Ok(())
}

fn data_dir() -> PathBuf {
  PathBuf::from("data-storage")
}

fn get_env() -> Result<Environment<'static>> {
  let mut env = Environment::new();
  env.set_loader(path_loader("templates"));
  env.set_syntax(
    SyntaxConfig::builder()
      .line_statement_prefix("==")
      .block_delimiters("[!", "!]")
      .variable_delimiters("[@", "@]")
      .comment_delimiters("[#", "#]")
      .build()
      .unwrap(),
  );
  Ok(env)
}

pub fn get_files_in_dir(dir: &PathBuf) -> Result<Vec<PathBuf>> {
  let files = fs::read_dir(dir)?
    .filter(|p| p.as_ref().unwrap().path().is_file())
    .map(|p| p.as_ref().unwrap().path())
    .filter(|p| {
      !p.file_name().unwrap().to_str().unwrap().starts_with(".")
    })
    .collect();
  Ok(files)
}

async fn handle_add(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(mut request): Form<Item>,
) -> impl IntoResponse {
  let id = Uuid::now_v7();
  request.id = Some(id.to_string());
  if let Ok(contents) = serde_json::to_string(&request) {
    let file_name = format!("{}.json", id);
    let path = data_dir().join(file_name);
    match fs::write(path, contents) {
      Ok(()) => {
        // TODO: Set flash message that the
        // quote was added.
        dbg!("quote added");
        session
          .insert("flash_message", "quote added".to_string())
          .await
          .unwrap();
      }
      Err(e) => {
        // TODO: Set flash message with quote
        // error.
        dbg!(e);
      }
    }
  };
  Redirect::to("/")
}

async fn handle_confirm_delete(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path =
    data_dir().join(format!("{}.json", request.id.unwrap()));
  fs::remove_file(path).unwrap();
  Redirect::to("/")
}

async fn handle_delete_request(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("delete_request.html", context, State(state))
}

async fn handle_edit(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("edit.html", context, State(state))
}

async fn handle_index(
  session: Session,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  // TODO: Add flash messages.
  match get_files_in_dir(&data_dir()) {
    Ok(paths) => {
      let items: Vec<Item> = paths
        .iter()
        .map(|path| {
          serde_json::from_str::<Item>(
            &fs::read_to_string(path).unwrap(),
          )
          .unwrap()
        })
        .collect();
      let context = context!(items => items);
      render("index.html", context, State(state))
    }
    Err(_) => {
      let context = context!();
      render("index.html", context, State(state))
    }
  }
}

async fn handle_update(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path = data_dir()
    .join(format!("{}.json", &request.id.clone().unwrap()));
  let content = serde_json::to_string(&request).unwrap();
  fs::write(path, content).unwrap();
  Redirect::to("/")
}

fn render(
  template: &str,
  context: Value,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let template = match state.env.get_template(template) {
    Ok(t) => t,
    Err(e) => {
      println!("ERROR: {}", e);
      return Err(StatusCode::INTERNAL_SERVER_ERROR);
    }
  };
  match template.render(context) {
    Ok(r) => Ok(Html(r)),
    Err(e) => {
      println!("ERROR: {}", e);
      Err(StatusCode::INTERNAL_SERVER_ERROR)
    }
  }
}

render()

A helper function responsible for generating the pages with MiniJinja. Any errors in the process result in a 500 error being returned to the client.

use anyhow::Result;
use axum::extract::Form;
use axum::extract::Path;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::response::Redirect;
use axum::{Router, response::Html, routing::get, routing::post};
use minijinja::syntax::SyntaxConfig;
use minijinja::{Environment, Value, context, path_loader};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tower_sessions::Session;
use tower_sessions::{MemoryStore, SessionManagerLayer};
use uuid::Uuid;

struct AppState {
  env: Environment<'static>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct Item {
  id: Option<String>,
  text: String,
}

#[tokio::main]
async fn main() -> Result<()> {
  let session_store = MemoryStore::default();
  let session_layer =
    SessionManagerLayer::new(session_store).with_secure(false);
  let env = get_env()?;
  let app_state = Arc::new(AppState { env });
  let app = Router::new()
    .route("/", get(handle_index))
    .route("/add", post(handle_add))
    .route("/edit/{id}", get(handle_edit))
    .route("/update", post(handle_update))
    .route("/delete/{id}", get(handle_delete_request))
    .route("/confirm-delete", post(handle_confirm_delete))
    .with_state(app_state)
    .layer(session_layer);
  let listener = tokio::net::TcpListener::bind("127.0.0.1:3939")
    .await
    .unwrap();
  println!("listening on {}", listener.local_addr().unwrap());
  axum::serve(listener, app).await?;
  Ok(())
}

fn data_dir() -> PathBuf {
  PathBuf::from("data-storage")
}

fn get_env() -> Result<Environment<'static>> {
  let mut env = Environment::new();
  env.set_loader(path_loader("templates"));
  env.set_syntax(
    SyntaxConfig::builder()
      .line_statement_prefix("==")
      .block_delimiters("[!", "!]")
      .variable_delimiters("[@", "@]")
      .comment_delimiters("[#", "#]")
      .build()
      .unwrap(),
  );
  Ok(env)
}

pub fn get_files_in_dir(dir: &PathBuf) -> Result<Vec<PathBuf>> {
  let files = fs::read_dir(dir)?
    .filter(|p| p.as_ref().unwrap().path().is_file())
    .map(|p| p.as_ref().unwrap().path())
    .filter(|p| {
      !p.file_name().unwrap().to_str().unwrap().starts_with(".")
    })
    .collect();
  Ok(files)
}

async fn handle_add(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(mut request): Form<Item>,
) -> impl IntoResponse {
  let id = Uuid::now_v7();
  request.id = Some(id.to_string());
  if let Ok(contents) = serde_json::to_string(&request) {
    let file_name = format!("{}.json", id);
    let path = data_dir().join(file_name);
    match fs::write(path, contents) {
      Ok(()) => {
        // TODO: Set flash message that the
        // quote was added.
        dbg!("quote added");
        session
          .insert("flash_message", "quote added".to_string())
          .await
          .unwrap();
      }
      Err(e) => {
        // TODO: Set flash message with quote
        // error.
        dbg!(e);
      }
    }
  };
  Redirect::to("/")
}

async fn handle_confirm_delete(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path =
    data_dir().join(format!("{}.json", request.id.unwrap()));
  fs::remove_file(path).unwrap();
  Redirect::to("/")
}

async fn handle_delete_request(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("delete_request.html", context, State(state))
}

async fn handle_edit(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("edit.html", context, State(state))
}

async fn handle_index(
  session: Session,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  // TODO: Add flash messages.
  match get_files_in_dir(&data_dir()) {
    Ok(paths) => {
      let items: Vec<Item> = paths
        .iter()
        .map(|path| {
          serde_json::from_str::<Item>(
            &fs::read_to_string(path).unwrap(),
          )
          .unwrap()
        })
        .collect();
      let context = context!(items => items);
      render("index.html", context, State(state))
    }
    Err(_) => {
      let context = context!();
      render("index.html", context, State(state))
    }
  }
}

async fn handle_update(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path = data_dir()
    .join(format!("{}.json", &request.id.clone().unwrap()));
  let content = serde_json::to_string(&request).unwrap();
  fs::write(path, content).unwrap();
  Redirect::to("/")
}

fn render(
  template: &str,
  context: Value,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let template = match state.env.get_template(template) {
    Ok(t) => t,
    Err(e) => {
      println!("ERROR: {}", e);
      return Err(StatusCode::INTERNAL_SERVER_ERROR);
    }
  };
  match template.render(context) {
    Ok(r) => Ok(Html(r)),
    Err(e) => {
      println!("ERROR: {}", e);
      Err(StatusCode::INTERNAL_SERVER_ERROR)
    }
  }
}

main.rs - Full File

Here's the set of Rust code assembled into a single block.

View File
use anyhow::Result;
use axum::extract::Form;
use axum::extract::Path;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::response::Redirect;
use axum::{Router, response::Html, routing::get, routing::post};
use minijinja::syntax::SyntaxConfig;
use minijinja::{Environment, Value, context, path_loader};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use tower_sessions::Session;
use tower_sessions::{MemoryStore, SessionManagerLayer};
use uuid::Uuid;

struct AppState {
  env: Environment<'static>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct Item {
  id: Option<String>,
  text: String,
}

#[tokio::main]
async fn main() -> Result<()> {
  let session_store = MemoryStore::default();
  let session_layer =
    SessionManagerLayer::new(session_store).with_secure(false);
  let env = get_env()?;
  let app_state = Arc::new(AppState { env });
  let app = Router::new()
    .route("/", get(handle_index))
    .route("/add", post(handle_add))
    .route("/edit/{id}", get(handle_edit))
    .route("/update", post(handle_update))
    .route("/delete/{id}", get(handle_delete_request))
    .route("/confirm-delete", post(handle_confirm_delete))
    .with_state(app_state)
    .layer(session_layer);
  let listener = tokio::net::TcpListener::bind("127.0.0.1:3939")
    .await
    .unwrap();
  println!("listening on {}", listener.local_addr().unwrap());
  axum::serve(listener, app).await?;
  Ok(())
}

fn data_dir() -> PathBuf {
  PathBuf::from("data-storage")
}

fn get_env() -> Result<Environment<'static>> {
  let mut env = Environment::new();
  env.set_loader(path_loader("templates"));
  env.set_syntax(
    SyntaxConfig::builder()
      .line_statement_prefix("==")
      .block_delimiters("[!", "!]")
      .variable_delimiters("[@", "@]")
      .comment_delimiters("[#", "#]")
      .build()
      .unwrap(),
  );
  Ok(env)
}

pub fn get_files_in_dir(dir: &PathBuf) -> Result<Vec<PathBuf>> {
  let files = fs::read_dir(dir)?
    .filter(|p| p.as_ref().unwrap().path().is_file())
    .map(|p| p.as_ref().unwrap().path())
    .filter(|p| {
      !p.file_name().unwrap().to_str().unwrap().starts_with(".")
    })
    .collect();
  Ok(files)
}

async fn handle_add(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(mut request): Form<Item>,
) -> impl IntoResponse {
  let id = Uuid::now_v7();
  request.id = Some(id.to_string());
  if let Ok(contents) = serde_json::to_string(&request) {
    let file_name = format!("{}.json", id);
    let path = data_dir().join(file_name);
    match fs::write(path, contents) {
      Ok(()) => {
        // TODO: Set flash message that the
        // quote was added.
        dbg!("quote added");
        session
          .insert("flash_message", "quote added".to_string())
          .await
          .unwrap();
      }
      Err(e) => {
        // TODO: Set flash message with quote
        // error.
        dbg!(e);
      }
    }
  };
  Redirect::to("/")
}

async fn handle_confirm_delete(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path =
    data_dir().join(format!("{}.json", request.id.unwrap()));
  fs::remove_file(path).unwrap();
  Redirect::to("/")
}

async fn handle_delete_request(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("delete_request.html", context, State(state))
}

async fn handle_edit(
  session: Session,
  Path(id): Path<String>,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let path = data_dir().join(format!("{}.json", id));
  let item = serde_json::from_str::<Item>(
    &fs::read_to_string(path).unwrap(),
  )
  .unwrap();
  let context = context!(item => item);
  render("edit.html", context, State(state))
}

async fn handle_index(
  session: Session,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  // TODO: Add flash messages.
  match get_files_in_dir(&data_dir()) {
    Ok(paths) => {
      let items: Vec<Item> = paths
        .iter()
        .map(|path| {
          serde_json::from_str::<Item>(
            &fs::read_to_string(path).unwrap(),
          )
          .unwrap()
        })
        .collect();
      let context = context!(items => items);
      render("index.html", context, State(state))
    }
    Err(_) => {
      let context = context!();
      render("index.html", context, State(state))
    }
  }
}

async fn handle_update(
  session: Session,
  State(state): State<Arc<AppState>>,
  Form(request): Form<Item>,
) -> impl IntoResponse {
  let path = data_dir()
    .join(format!("{}.json", &request.id.clone().unwrap()));
  let content = serde_json::to_string(&request).unwrap();
  fs::write(path, content).unwrap();
  Redirect::to("/")
}

fn render(
  template: &str,
  context: Value,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let template = match state.env.get_template(template) {
    Ok(t) => t,
    Err(e) => {
      println!("ERROR: {}", e);
      return Err(StatusCode::INTERNAL_SERVER_ERROR);
    }
  };
  match template.render(context) {
    Ok(r) => Ok(Html(r)),
    Err(e) => {
      println!("ERROR: {}", e);
      Err(StatusCode::INTERNAL_SERVER_ERROR)
    }
  }
}

Templates

These are the templates used by the handlers.

delete_request.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <style>body{ background-color: black; color: goldenrod; }</style>
  </head>
  <body>
    [! block body !][! endblock !]
  </body>
</html>

edit.html

== extends "/templates.inc/wrappers/default.html"
== block body

<h2>Edit Item</h2>


<p>TODO: Show item details again</p>

[#
<form method="post" action="/update">
  <input type="text" name="text" value="[@ item.text @]" />
  <input type="hidden" name="id" value="[@ item.id @]" />
  <input type="submit" value="Update Item" />
</form>
#]

== endblock

index.html

== extends "/templates.inc/wrappers/default.html"
== block body

<h2>Items</h2>

<form method="post" action="/add">
  <input type="text" name="text" />
  <input type="submit" value="Add New Item" />
</form>

<ul>
== for item in items
  <li>
    [@ item.text @] 
    <a href="/edit/[@ item.id @]">edit</a>
    <a href="/delete/[@ item.id @]">delete</a>
  </li>
== endfor
</ul>

== endblock

wrapper.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <style>body{ background-color: black; color: goldenrod; }</style>
  </head>
  <body>
    [! block body !][! endblock !]
  </body>
</html>

Notes