//! CRUD example: a tiny user database with JSON persistence. //! //! Demonstrates urus and the actor model together, in the shape a real //! application should use (v0.3): //! - The APP owns the smarm runtime and the root supervisor. urus is one //! child in that tree — `urus::endpoint(...)` — and the store actor is //! an ordered sibling started BEFORE it, so the supervisor's //! reverse-order shutdown drains HTTP first and only then stops the //! store. No handler can be mid-request against a store that is gone. //! - Handlers do NOT take a lock or share mutable state directly. A //! single "store" actor owns the data; handlers address it by //! registered name and block on a reply channel. Serialization is //! structural — one request at a time, no Mutex. //! - Both actors are supervised: kill the store (or let it panic) and it //! restarts from the JSON file, with the endpoint left alone. //! - On every mutating request the store writes the JSON file. Read //! requests don't touch disk. //! //! Endpoints: //! GET /users — list //! POST /users — create //! GET /users/:id — fetch //! PUT /users/:id — replace //! DELETE /users/:id — delete //! //! Try it: //! cargo run --example crud //! curl -s http://localhost:8080/users //! curl -s -X POST -d '{"name":"alice","email":"a@x"}' http://localhost:8080/users //! curl -s http://localhost:8080/users/1 use serde::{Deserialize, Serialize}; use smarm::{channel, ChildSpec, Name, OneForOne, Restart, Sender}; use urus::{Config, Conn, Next, Pipeline, Router}; // --------------------------------------------------------------------------- // Domain // --------------------------------------------------------------------------- #[derive(Clone, Debug, Serialize, Deserialize)] struct User { id: u64, name: String, email: String, } #[derive(Deserialize)] struct NewUser { name: String, email: String, } // --------------------------------------------------------------------------- // Store actor protocol // --------------------------------------------------------------------------- // // One enum per request kind. Each carries a `reply` Sender for the response. // Reply types are kept simple: most are JSON byte vectors + HTTP status. The // store does the serialisation; the handler just writes the bytes. enum Request { List { reply: Sender<(u16, Vec)> }, Get { id: u64, reply: Sender<(u16, Vec)> }, Create { body: Vec, reply: Sender<(u16, Vec)> }, Update { id: u64, body: Vec, reply: Sender<(u16, Vec)> }, Delete { id: u64, reply: Sender<(u16, Vec)> }, } const DB_PATH: &str = "/tmp/urus-crud.json"; // --------------------------------------------------------------------------- // Store actor body // --------------------------------------------------------------------------- fn store_loop() { let (tx, rx) = channel::(); // Self-registration: the name is bound before the first recv, and it // is re-bound automatically on every restart. smarm::register(STORE, tx).expect("crud.store name already taken"); // Load on start. Missing file = empty store. Corrupt file = panic; we // don't auto-rebuild because silently losing data is worse than failing // loud. let mut users: Vec = match std::fs::read(DB_PATH) { Ok(bytes) if !bytes.is_empty() => serde_json::from_slice(&bytes).expect("urus-crud: db file is not valid JSON"), _ => Vec::new(), }; let mut next_id: u64 = users.iter().map(|u| u.id).max().unwrap_or(0) + 1; loop { // A plain park. The supervisor stops this actor at shutdown (after // the endpoint has drained), so there is nothing to poll for. let req = match rx.recv() { Ok(r) => r, Err(_) => return, // all senders dropped }; match req { Request::List { reply } => { let body = serde_json::to_vec(&users).unwrap(); let _ = reply.send((200, body)); } Request::Get { id, reply } => { match users.iter().find(|u| u.id == id) { Some(u) => { let body = serde_json::to_vec(u).unwrap(); let _ = reply.send((200, body)); } None => { let _ = reply.send((404, b"{\"error\":\"not found\"}".to_vec())); } } } Request::Create { body, reply } => { match serde_json::from_slice::(&body) { Ok(nu) => { let u = User { id: next_id, name: nu.name, email: nu.email }; next_id += 1; users.push(u.clone()); persist(&users); let _ = reply.send((201, serde_json::to_vec(&u).unwrap())); } Err(_) => { let _ = reply.send((400, b"{\"error\":\"invalid body\"}".to_vec())); } } } Request::Update { id, body, reply } => { match serde_json::from_slice::(&body) { Ok(nu) => match users.iter_mut().find(|u| u.id == id) { Some(u) => { u.name = nu.name; u.email = nu.email; let snapshot = u.clone(); persist(&users); let _ = reply.send((200, serde_json::to_vec(&snapshot).unwrap())); } None => { let _ = reply.send((404, b"{\"error\":\"not found\"}".to_vec())); } }, Err(_) => { let _ = reply.send((400, b"{\"error\":\"invalid body\"}".to_vec())); } } } Request::Delete { id, reply } => { let before = users.len(); users.retain(|u| u.id != id); if users.len() < before { persist(&users); let _ = reply.send((204, Vec::new())); } else { let _ = reply.send((404, b"{\"error\":\"not found\"}".to_vec())); } } } } } fn persist(users: &[User]) { let json = serde_json::to_vec_pretty(users).unwrap(); // Atomic-ish: write to temp then rename. Avoids half-written files on // crash. /tmp is on the same filesystem so rename is atomic. let tmp = format!("{DB_PATH}.tmp"); std::fs::write(&tmp, &json).expect("urus-crud: write tmp failed"); std::fs::rename(&tmp, DB_PATH).expect("urus-crud: rename failed"); } // --------------------------------------------------------------------------- // Handler helpers // --------------------------------------------------------------------------- // // The store is a supervised child that registers its own inbox under a // typed name; handlers resolve it per send. That replaces the old // `OnceLock` spawn-on-first-use trick — which had no supervisor, // no restart, and no defined shutdown point — with a plain actor whose // lifecycle the tree owns. A restart re-registers the same name, so // in-flight handlers heal on their next send. const STORE: Name = Name::new("crud.store"); /// Send to the store and wait for its reply. `Err` only if the store is /// between incarnations (restarting); the handler turns that into a 503 /// rather than pretending. fn ask(make: impl FnOnce(Sender<(u16, Vec)>) -> Request) -> Option<(u16, Vec)> { let (tx, rx) = channel::<(u16, Vec)>(); smarm::send(STORE, make(tx)).ok()?; rx.recv().ok() } fn reply(conn: Conn, r: Option<(u16, Vec)>) -> Conn { match r { Some((status, body)) => json(conn, status, body), None => json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec()), } } fn json(conn: Conn, status: u16, body: Vec) -> Conn { conn.put_status(status) .put_header("content-type", "application/json") .put_body(body) } fn parse_id(s: &str) -> Option { s.parse().ok() } // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- /// SSE demo: `curl -N localhost:8080/ticker` streams a tick every second /// (with `: keep-alive` comments if it ever goes quiet). The producer /// exits on SseClosed — client gone, write timeout, or the drain stopping /// its connection actor. Nothing to flag: the tree's shutdown reaches it. fn ticker(conn: Conn, _next: Next) -> Conn { let (conn, events) = conn.sse(); smarm::spawn(move || { let mut n: u64 = 0; loop { if events.send("tick", &n.to_string()).is_err() { return; // SseClosed } n += 1; smarm::sleep(std::time::Duration::from_secs(1)); } }); conn } fn list(conn: Conn, _next: Next) -> Conn { let r = ask(|reply| Request::List { reply }); reply(conn, r) } fn create(conn: Conn, _next: Next) -> Conn { let body = conn.body.as_bytes().to_vec(); let r = ask(|reply| Request::Create { body, reply }); reply(conn, r) } fn get_one(conn: Conn, _next: Next) -> Conn { let id = match conn.params.get("id").and_then(parse_id) { Some(id) => id, None => return json(conn, 400, b"{\"error\":\"bad id\"}".to_vec()), }; let r = ask(|reply| Request::Get { id, reply }); reply(conn, r) } fn update(conn: Conn, _next: Next) -> Conn { let id = match conn.params.get("id").and_then(parse_id) { Some(id) => id, None => return json(conn, 400, b"{\"error\":\"bad id\"}".to_vec()), }; let body = conn.body.as_bytes().to_vec(); let r = ask(|reply| Request::Update { id, body, reply }); reply(conn, r) } fn delete(conn: Conn, _next: Next) -> Conn { let id = match conn.params.get("id").and_then(parse_id) { Some(id) => id, None => return json(conn, 400, b"{\"error\":\"bad id\"}".to_vec()), }; let r = ask(|reply| Request::Delete { id, reply }); reply(conn, r) } // --------------------------------------------------------------------------- // Minimal request logger // --------------------------------------------------------------------------- fn logger(conn: Conn, next: Next) -> Conn { let method = conn.method.as_str().to_string(); let path = conn.path.clone(); let conn = next.run(conn); println!("{method} {path} -> {}", conn.status.unwrap_or(0)); conn } // --------------------------------------------------------------------------- // main // --------------------------------------------------------------------------- fn main() { let pipeline = Pipeline::new() .plug(logger) .plug( Router::new() .get( "/users", list) .post( "/users", create) .get( "/users/:id", get_one) .put( "/users/:id", update) .delete("/users/:id", delete) .get( "/ticker", ticker), ); let cfg = Config::new("127.0.0.1:8080".parse().unwrap()); // Bind here, on this thread: an address-in-use error is a startup // failure, not an actor crash. The fds outlive any restart of the // endpoint child. let endpoint = urus::endpoint(cfg, pipeline).expect("bind 127.0.0.1:8080"); println!("urus-crud: DB at {DB_PATH}"); println!("urus-crud: listening on 127.0.0.1:8080 — press Enter to shut down"); let rt = smarm::init(smarm::Config::default()); let handle = rt.handle(); let (sup_tx, sup_rx) = std::sync::mpsc::channel(); // Graceful shutdown on stdin-Enter: a plain OS thread blocks on // read_line and shuts the ROOT SUPERVISOR down. No signal-handling // crate needed, and no urus-specific shutdown plumbing — this is // exactly what a SIGTERM handler would do. std::thread::spawn(move || { let sup: smarm::Pid = sup_rx.recv().expect("supervisor pid"); let mut line = String::new(); let _ = std::io::stdin().read_line(&mut line); println!("urus-crud: shutting down (draining in-flight requests)…"); handle.request_shutdown(sup); }); rt.run(move || { let sup = smarm::spawn(move || { OneForOne::new() // Store FIRST: reverse-order shutdown therefore stops it // LAST, after the endpoint has finished draining. .child(ChildSpec::new(Restart::Permanent, store_loop)) .child( ChildSpec::new(Restart::Permanent, endpoint) // The endpoint bounds its own drain with // Config::drain_timeout; a shorter supervisor // deadline would cut that drain in half. .shutdown(smarm::supervisor::Shutdown::Infinity), ) .run() }); let _ = sup_tx.send(sup.pid()); let _ = sup.join(); }); println!("urus-crud: bye"); }