Menu
Public repository / 69a2606c7ff3e89d2f50090fa59a15f14171a37a
AkurAI-Framework / crates/cli/src/guestbook.rs
Latest change 69a2606c7ff3e89d2f50090fa59a15f14171a37a - feat: HTTP request bodies + form actions (guestbook demo, progressive enhancement) by Ólafur Búi Ólafsson
//! A guestbook — the form-actions demo.
//!
//! `GET /guestbook` lists entries; `POST /guestbook` validates a plain HTML
//! form, stores the entry, 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 are in-memory and
//! reset on restart; durable storage is the next slice (wire in the B+tree).
//!
//! 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_http::{form, Method, Reply, Request, Response};
use akurai_json::Value;
use std::path::Path;
use std::sync::{Arc, Mutex};
/// Shared, in-memory guestbook entries. Cheap to clone (an `Arc`), so the
/// request handler can hold one copy for the server's life.
#[derive(Clone, Default)]
pub struct State {
entries: Arc<Mutex<Vec<Entry>>>,
}
#[derive(Clone)]
struct Entry {
name: String,
message: String,
}
impl State {
pub fn new() -> State {
State::default()
}
}
/// 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, 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,
);
}
state.entries.lock().unwrap().push(Entry { name, message });
// 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)),
};
// Snapshot entries newest-first for display, releasing the lock promptly.
let (entries, count) = {
let guard = state.entries.lock().unwrap();
let list = guard
.iter()
.rev()
.map(|e| {
Value::Object(vec![
("name".into(), Value::Str(e.name.clone())),
("message".into(), Value::Str(e.message.clone())),
])
})
.collect();
(Value::Array(list), guard.len() as i64)
};
let mut context = load_context(root);
if let Value::Object(pairs) = &mut context {
pairs.push(("entries".into(), entries));
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)),
}
}