Menu
Public repository / master
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)),
}
}