AkurAI Build
Menu

Public repository / fee5b6c2cde89fa6bf8dc51068af7336b56a88b8

AkurAI-Framework / crates/cli/src/guestbook.rs

Latest change 7769e5d23de597c3affc4c883c124d964512279e - AKURAI-FRAMEWORK-11 migrate guestbook to collections store by Ólafur Búi Ólafsson

//! A guestbook — the form-actions demo, now persisted to the embedded B+tree.
//!
//! `GET /guestbook` lists entries; `POST /guestbook` validates a plain HTML
//! form, stores the entry durably, and redirects back (Post/Redirect/Get, so a
//! refresh never resubmits). It works with JavaScript disabled — the
//! progressive-enhancement baseline the framework is built for. Entries survive
//! restarts: they live in `data/store.db`, not in memory.
//!
//! It also stands in for the "loader" pattern: the handler builds a context
//! (the entry list) for the template, by hand for now — exactly the boilerplate
//! a declarative per-route `load` will later remove.

use crate::cmd_serve::{build_engine, error_page, load_context};
use akurai_collections::{CollError, Collection, Field, FieldKind, Store};
use akurai_http::{form, Method, Reply, Request, Response};
use akurai_json::Value;
use std::path::Path;
use std::sync::Mutex;

/// Guestbook handler state: the canonical collection store and its schema.
pub struct State {
    collection: Collection,
    store: Mutex<Store>,
}

impl State {
    pub fn open(path: impl AsRef<Path>) -> Result<State, CollError> {
        Ok(State {
            collection: Collection::new(
                "guestbook",
                vec![
                    Field::new("name", FieldKind::Text).required(),
                    Field::new("message", FieldKind::Text).required(),
                ],
            ),
            store: Mutex::new(Store::open(path)?),
        })
    }
}

/// Route `GET`/`POST /guestbook`.
pub fn handle(state: &State, root: &Path, req: &Request) -> Reply {
    let response = match req.method {
        Method::Post => submit(state, root, req),
        _ => render_form(state, root, "", "", ""),
    };
    Reply::Response(response)
}

/// Handle a form submission: validate, store durably, redirect.
fn submit(state: &State, root: &Path, req: &Request) -> Response {
    let pairs = form::parse_urlencoded(&req.body_str());
    let name = form::field(&pairs, "name").unwrap_or("").trim().to_string();
    let message = form::field(&pairs, "message")
        .unwrap_or("")
        .trim()
        .to_string();

    if name.is_empty() || message.is_empty() {
        // Re-render with the error and the values the user already typed.
        return render_form(
            state,
            root,
            "Both a name and a message are required.",
            &name,
            &message,
        );
    }

    let record = Value::Object(vec![
        ("name".into(), Value::Str(name)),
        ("message".into(), Value::Str(message)),
    ]);
    if let Err(e) = state
        .store
        .lock()
        .expect("guestbook store mutex poisoned")
        .create(&state.collection, record)
    {
        return Response::new(500).with_html(&error_page(&format!("could not save entry: {e}")));
    }
    // Post/Redirect/Get: send the browser back to the listing with a GET.
    Response::new(303).with_header("Location", "/guestbook")
}

/// Render the guestbook template with the entry list and optional form state.
fn render_form(state: &State, root: &Path, error: &str, name: &str, message: &str) -> Response {
    let engine = match build_engine(root) {
        Ok(engine) => engine,
        Err(msg) => return Response::new(500).with_html(&error_page(&msg)),
    };

    // The canonical store lists records newest-first.
    let records = match state
        .store
        .lock()
        .expect("guestbook store mutex poisoned")
        .list(&state.collection, None)
    {
        Ok(records) => records,
        Err(e) => {
            return Response::new(500)
                .with_html(&error_page(&format!("could not read entries: {e}")))
        }
    };
    let count = records.len() as i64;

    let mut context = load_context(root);
    if let Value::Object(pairs) = &mut context {
        pairs.push(("entries".into(), Value::Array(records)));
        pairs.push(("count".into(), Value::Int(count)));
        pairs.push(("error".into(), Value::Str(error.to_string())));
        pairs.push(("form_name".into(), Value::Str(name.to_string())));
        pairs.push(("form_message".into(), Value::Str(message.to_string())));
    }

    match engine.render("guestbook", &context) {
        Ok(html) => Response::ok().with_html(&html),
        Err(e) => Response::new(500).with_html(&error_page(&e.message)),
    }
}