//! Host-agnostic routing: a request in, a renderer-agnostic description out. //! //! //! //! The keystone of the stack. A route answers with what a screen *is*, composed //! from `makeover-layout`'s vocabulary, and the host decides how that becomes //! pixels. Returning markup instead would pin every consumer to a webview and //! hand the terminal and egui renderers an adapter, which is the failure the //! description layer exists to prevent. //! //! Nothing here may import a host crate. `tauri`, `axum` and `wry` are all thin //! adapters written on top of this, never dependencies of it. That rule is what //! keeps a host repriceable per app instead of welded into the view layer, and //! it is what lets the same route serve a desktop protocol handler and an HTTP //! endpoint with no second implementation. //! //! # The shape //! //! ```text //! request (path + params) //! -> router this crate, imports no host crate //! -> description makeover-layout, composed by this crate's Screen //! -> renderer webview | tui | egui //! -> host adapter axum route | Tauri protocol | wry | direct call //! ``` //! //! The renderers share no trait, and looking for one is the wrong move: what //! they have in common is the description above them. `quasi_http::Serves` is //! the nearest thing and is not it — that is an HTTP host's contract, which a //! terminal cannot implement and does not need. //! //! ``` //! use quasi_router::{Action, Node, Outcome, Request, Response, RouteError, Router, Screen, Slot}; //! use quasi_router::layout::{Arrangement, Region}; //! //! struct App { //! tasks: Vec<(u32, String, bool)>, //! } //! //! fn show_task(app: &App, request: Request) -> Result { //! let id: u32 = request //! .captures //! .require("id")? //! .parse() //! .map_err(|_| RouteError::not_found("no such task"))?; //! let (_, title, done) = app //! .tasks //! .iter() //! .find(|(t, _, _)| *t == id) //! .ok_or_else(|| RouteError::not_found("no such task"))?; //! //! let mut detail = Slot::new("detail", quasi_router::RegionKind::Pane) //! .with(Node::page(title.clone())); //! if !done { //! detail = detail.with(Node::act( //! "Complete", //! Action::post(format!("/task/{id}/complete")), //! )); //! } //! //! Ok(Screen::list_detail(title.clone(), false).with(detail).into()) //! } //! //! fn complete_task(_app: &App, request: Request) -> Result { //! let id = request.captures.require("id")?; //! Ok(Response::fragment( //! "detail", //! Node::text(format!("task {id} is done")), //! )) //! } //! //! let router = Router::::new() //! .get("/task/{id}", show_task) //! .post("/task/{id}/complete", complete_task); //! //! let app = App { tasks: vec![(7, "Write the router".into(), false)] }; //! let answer = router.handle(&app, Request::get("/task/7")).unwrap(); //! assert!(matches!(answer.outcome, Outcome::Screen(_))); //! ``` //! //! # What is settled, and where it is written down //! //! The design lives on the wiki note `quasi-overview`, and the decisions this //! crate implements are numbered there. In short: //! //! - **An action is a route** (2). One address space for reads and writes, and //! the verb separates them. See [`Method`]. //! - **The screen tree lives here, not in `makeover-layout`** (3). It will churn //! while the router is proven against a second host, and a route is an address, //! which is the one thing the description layer never names. See [`Screen`]. //! - **A bespoke region is filled per host** (4). See [`RegionKind::Bespoke`]. //! - **The router is sync** (6). See [`Handler`]. //! - **A response carries a target, and may carry a notice or a redirect** (7). //! See [`Response`] and [`Outcome`]. //! - **`Router`, generic over app state** (8). See [`Router`]. //! - **Failure is classified** (9). See [`RouteError`]. pub mod chrome; pub mod containment; pub mod error; mod path; pub mod request; pub mod response; pub mod router; pub mod screen; /// The description layer, re-exported. /// /// Every intent a [`Screen`] composes is `makeover-layout`'s, and a consumer /// needs them to build one. Re-exported so that an app and a renderer are /// provably reading the same version of the vocabulary rather than two /// semver-compatible ones that happen to resolve together. pub use makeover_layout as layout; pub use crate::chrome::{Binding, Chrome}; pub use crate::containment::{Containment, Element, Level, Of}; pub use crate::error::{Class, RouteError}; pub use crate::request::{Method, Params, Request}; pub use crate::response::{Address, Invalidated, Message, Outcome, Response}; pub use crate::router::{Handler, Router}; pub use crate::screen::{ Act, Action, Cell, Cells, Choice, Column, Destination, Discovery, Field, Figure, Meter, Node, Part, Prose, RegionKind, Rest, Row, Screen, Slot, SocialKind, Tag, }; #[cfg(test)] mod tests { use super::*; use crate::layout::{Arrangement, Tone}; /// The smallest app state a route can be written against. struct State { greeting: &'static str, } fn home(state: &State, _request: Request) -> Result { Ok(Screen::sidebar_content("Home") .with(Slot::new("content", RegionKind::Pane).with(Node::text(state.greeting))) .into()) } fn new_task(_state: &State, _request: Request) -> Result { Ok(Response::fragment("detail", Node::text("a new task"))) } // Taken by value because [`Handler`] says so, and a handler that only reads // its parameters is the common case rather than an oversight. #[allow(clippy::needless_pass_by_value)] fn show_task(_state: &State, request: Request) -> Result { let id = request.captures.require("id")?.to_owned(); Ok(Response::fragment("detail", Node::text(id))) } fn forbidden(_state: &State, _request: Request) -> Result { Err(RouteError::denied("not yours")) } fn router() -> Router { // Deliberately registered least-specific-first, so the ordering being // tested is the table's own and not the order of these lines. Router::new() .get("/task/{id}", show_task) .get("/task/new", new_task) .get("/", home) .post("/task/{id}/delete", forbidden) } fn state() -> State { State { greeting: "hello" } } fn text_of(response: &Response) -> Option<&str> { match &response.outcome { Outcome::Fragment { node: Node::Text { text, .. }, .. } => Some(text), _ => None, } } #[test] fn a_static_route_beats_a_capture_whatever_the_order() { let answer = router() .handle(&state(), Request::get("/task/new")) .unwrap(); assert_eq!(text_of(&answer), Some("a new task")); } #[test] fn a_capture_reaches_the_handler() { let answer = router().handle(&state(), Request::get("/task/7")).unwrap(); assert_eq!(text_of(&answer), Some("7")); } #[test] fn the_path_capture_wins_over_a_supplied_value() { // The path is the address; the body is only what was sent to it. let sent = Params::new().with("id", "9"); let answer = router() .handle(&state(), Request::get("/task/7").carrying(sent)) .unwrap(); assert_eq!(text_of(&answer), Some("7")); } #[test] fn a_read_and_a_write_are_different_routes_at_one_address() { let router = router(); let missing = router .handle(&state(), Request::post("/task/7")) .unwrap_err(); assert_eq!(missing.class, Class::NotFound); assert!(missing.message.contains("another method")); } #[test] fn an_unknown_path_says_so_without_mentioning_a_method() { let missing = router() .handle(&state(), Request::get("/nowhere")) .unwrap_err(); assert_eq!(missing.class, Class::NotFound); assert!(!missing.message.contains("another method")); } #[test] fn a_denial_carries_a_banner_and_a_status() { let denied = router() .handle(&state(), Request::post("/task/7/delete")) .unwrap_err(); assert_eq!(denied.class, Class::Denied); assert_eq!(denied.class.http_status(), 403); assert_eq!(denied.notice, layout::Notice::Banner); assert_eq!(denied.tone(), Tone::Warning); assert!(!denied.class.is_ours()); } #[test] fn a_missing_parameter_is_our_bug_not_the_users() { // Reached only by calling the handler outside the router, which is what // a renderer emitting an unfilled route amounts to. let missing = show_task(&state(), Request::get("/task/7")).unwrap_err(); assert_eq!(missing.class, Class::Internal); assert!(missing.class.is_ours()); } #[test] fn a_screen_answers_with_no_target_and_a_fragment_with_one() { let router = router(); let screen = router.handle(&state(), Request::get("/")).unwrap(); assert_eq!(screen.target(), None); let fragment = router.handle(&state(), Request::get("/task/7")).unwrap(); assert_eq!(fragment.target(), Some("detail")); } #[test] fn the_route_table_reads_back_most_specific_first() { let router = router(); let table: Vec<_> = router.routes().collect(); let new_at = table.iter().position(|(_, p)| *p == "/task/new").unwrap(); let id_at = table.iter().position(|(_, p)| *p == "/task/{id}").unwrap(); assert!(new_at < id_at); assert_eq!(router.len(), 4); } #[test] #[should_panic(expected = "registered twice")] fn registering_one_route_twice_is_a_bug() { let _ = Router::::new() .get("/task/{id}", show_task) .get("/task/{id}", show_task); } #[test] fn a_slot_is_found_at_any_depth() { let screen = Screen::new("Tabs", Arrangement::list_detail(true)).with( Slot::new("tabs", RegionKind::TabGroup) .with(Node::Region(Slot::new("pane-a", RegionKind::Pane))), ); assert!(screen.slot("tabs").is_some()); assert!(screen.slot("pane-a").is_some()); assert!(screen.slot("pane-b").is_none()); } #[test] fn a_fragment_replaces_a_top_level_regions_contents() { let mut screen = Screen::sidebar_content("Home").with( Slot::new("content", RegionKind::Pane) .with(Node::text("first")) .with(Node::text("second")), ); assert!(screen.replace("content", Node::text("after"))); let slot = screen.slot("content").expect("the region is still there"); // Replaced, not appended: a fragment is one region's new contents, // which is the whole reason it can be smaller than a screen. assert_eq!(slot.body, vec![Node::text("after")]); } #[test] fn a_fragment_reaches_a_region_nested_inside_another() { let mut screen = Screen::new("Tabs", Arrangement::list_detail(true)).with( Slot::new("tabs", RegionKind::TabGroup) .with(Node::Region(Slot::new("pane-a", RegionKind::Pane))), ); assert!(screen.replace("pane-a", Node::text("loaded"))); let pane = screen.slot("pane-a").expect("the nested region"); assert_eq!(pane.body, vec![Node::text("loaded")]); // The region it is inside keeps its own body, which still holds the // nested region rather than having been replaced by it. let tabs = screen.slot("tabs").expect("the outer region"); assert_eq!(tabs.body.len(), 1); } #[test] fn a_pending_region_stops_being_pending_when_its_content_arrives() { let mut screen = Screen::sidebar_content("Home").with(Slot::new("content", RegionKind::Pane).pending()); assert!(screen.replace("content", Node::text("here"))); let slot = screen.slot("content").expect("the region"); assert_eq!(slot.readiness, layout::Readiness::Ready); } #[test] fn a_fragment_for_a_region_that_is_not_there_answers_false() { let mut screen = Screen::sidebar_content("Home").with(Slot::new("content", RegionKind::Pane)); // Not a panic and not a silent no-op: a miss means a route naming a // slot that no longer exists, so the caller is the one that can act on // it -- redraw, or fail a test. assert!(!screen.replace("detail", Node::text("nowhere"))); assert!(screen.slot("content").expect("untouched").body.is_empty()); } #[test] fn an_owned_field_borrows_back_as_the_description_layers_own() { let field = Field::select( "priority", "Priority", vec![Choice::plain("high"), Choice::new("low", "Low")], ) .required() .error("pick one"); field.with_layout(|borrowed| { assert_eq!(borrowed.name, "priority"); assert_eq!(borrowed.options.len(), 2); assert_eq!(borrowed.options[1].label, "Low"); assert!(borrowed.required); // The description layer's own reading of the same state. assert!(borrowed.invalid()); assert!(borrowed.kind.offers_options()); }); } #[test] fn a_bespoke_region_is_the_only_undescribed_one() { assert!(RegionKind::Pane.described()); assert!( !RegionKind::Bespoke { name: "day-plan".into() } .described() ); } #[test] fn a_screen_nobody_described_is_indexable() { // `bool::default()` is false, so a derived Default here would deindex // every screen that never mentioned the subject. The impl is written // out for exactly this, and this is the assertion that keeps it. assert!(Discovery::default().indexable); assert!(Screen::sidebar_content("Home").discovery.indexable); assert!( !Screen::sidebar_content("Home") .indexed(false) .discovery .indexable ); } #[test] fn every_social_kind_has_a_distinct_spelling() { // A kind added upstream without a spelling fails here rather than // reaching a crawler as an og:type nothing recognises. let kinds = [ SocialKind::Website, SocialKind::Article, SocialKind::Profile, SocialKind::Product, SocialKind::Video, SocialKind::Song, ]; let mut seen = Vec::new(); for kind in kinds { let spelling = kind.as_str(); assert!(!spelling.is_empty(), "{kind:?} spells nothing"); assert!(!seen.contains(&spelling), "{spelling} is spelled twice"); seen.push(spelling); } assert_eq!(SocialKind::default(), SocialKind::Website); } #[test] fn an_overlay_targets_no_region_and_sends_the_user_nowhere() { // The two questions a host asks before it looks for a body. An overlay // answers no to both: it is not aimed at a region, and dismissing it // reveals the screen the user never left. let answer = Response::over(Screen::sidebar_content("Palette")); assert!(answer.target().is_none()); assert!(answer.destination().is_none()); assert!(matches!(answer.outcome, Outcome::Over(_))); } #[test] fn chrome_is_held_beside_the_router_rather_than_arriving_with_an_answer() { // The claim `Chrome` makes, as a type: it is built once and no // `Response` carries one, so an affordance available everywhere cannot // be a fact about one answer. let chrome = Chrome::new().bind("ctrl+k", "Search", Action::get("/palette")); assert_eq!( chrome.bound("ctrl+k").map(|b| b.label.as_str()), Some("Search") ); assert_eq!( chrome.bound("ctrl+k").map(|b| &b.action), Some(&Action::get("/palette")) ); } }