max / quasi
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
16 files changed,
+417 insertions,
-192 deletions
| @@ -4888,6 +4888,14 @@ | |||
| 4888 | 4888 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4889 | 4889 | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" | |
| 4890 | 4890 | ||
| 4891 | + | [[patch.unused]] | |
| 4892 | + | name = "synckit-client" | |
| 4893 | + | version = "0.8.0" | |
| 4894 | + | ||
| 4895 | + | [[patch.unused]] | |
| 4896 | + | name = "synckit-config" | |
| 4897 | + | version = "0.2.0" | |
| 4898 | + | ||
| 4891 | 4899 | [[patch.unused]] | |
| 4892 | 4900 | name = "kberg" | |
| 4893 | 4901 | version = "0.1.0" | |
| @@ -4899,11 +4907,3 @@ | |||
| 4899 | 4907 | [[patch.unused]] | |
| 4900 | 4908 | name = "tagtree" | |
| 4901 | 4909 | version = "0.4.0" | |
| 4902 | - | ||
| 4903 | - | [[patch.unused]] | |
| 4904 | - | name = "synckit-client" | |
| 4905 | - | version = "0.8.0" | |
| 4906 | - | ||
| 4907 | - | [[patch.unused]] | |
| 4908 | - | name = "synckit-config" | |
| 4909 | - | version = "0.2.0" |
| @@ -40,7 +40,7 @@ | |||
| 40 | 40 | //! ```no_run | |
| 41 | 41 | //! use std::sync::Arc; | |
| 42 | 42 | //! use quasi_axum::{Adapter, Render}; | |
| 43 | - | //! use quasi_router::{Node, Params, Response, RouteError, Router, Screen, Slot, RegionKind}; | |
| 43 | + | //! use quasi_router::{Node, Request, Response, RouteError, Router, Screen, Slot, RegionKind}; | |
| 44 | 44 | //! | |
| 45 | 45 | //! struct App; | |
| 46 | 46 | //! struct Html; | |
| @@ -50,7 +50,7 @@ | |||
| 50 | 50 | //! fn fragment(&self, _node: &Node) -> String { String::new() } | |
| 51 | 51 | //! } | |
| 52 | 52 | //! | |
| 53 | - | //! fn home(_app: &App, _params: Params) -> Result<Response, RouteError> { | |
| 53 | + | //! fn home(_app: &App, _request: Request) -> Result<Response, RouteError> { | |
| 54 | 54 | //! Ok(Screen::sidebar_content("Home") | |
| 55 | 55 | //! .with(Slot::new("content", RegionKind::Pane)) | |
| 56 | 56 | //! .into()) | |
| @@ -124,7 +124,7 @@ | |||
| 124 | 124 | ||
| 125 | 125 | /// Where the state for a request comes from. | |
| 126 | 126 | /// | |
| 127 | - | /// `Handler<S> = fn(&S, Params)` builds `S` once and reads it for the life of | |
| 127 | + | /// `Handler<S> = fn(&S, Request)` builds `S` once and reads it for the life of | |
| 128 | 128 | /// the program, which is correct for the hosts quasi was designed against: a | |
| 129 | 129 | /// Tauri window, an egui frame and a terminal loop each have one user, so one | |
| 130 | 130 | /// of everything is the truth. A hosted server is the one host that breaks | |
| @@ -184,7 +184,7 @@ | |||
| 184 | 184 | /// what the answer contains. | |
| 185 | 185 | /// | |
| 186 | 186 | /// This is the only channel for it, and deliberately. A handler is | |
| 187 | - | /// `fn(&S, Params) -> Result<Response, RouteError>` with no request object | |
| 187 | + | /// `fn(&S, Request) -> Result<Response, RouteError>` with no request object | |
| 188 | 188 | /// and no side channel, so anything it computed could only ride in the | |
| 189 | 189 | /// [`Response`], and host markup in the router's response type would make | |
| 190 | 190 | /// the router host-aware. So the factory does the work and the handler | |
| @@ -352,8 +352,10 @@ | |||
| 352 | 352 | ||
| 353 | 353 | // Kept because the router consumes them and a per-request renderer is | |
| 354 | 354 | // built after dispatch, when what the factory needs to see is both what | |
| 355 | - | // was asked and what was answered. | |
| 356 | - | let params = incoming.params.clone(); | |
| 355 | + | // was asked and what was answered. The view is what a renderer factory | |
| 356 | + | // reads — which theme, which viewer, which list you are on — so it is the | |
| 357 | + | // carried half that is kept rather than the write's payload. | |
| 358 | + | let params = incoming.carried.clone(); | |
| 357 | 359 | ||
| 358 | 360 | // After the envelope is checked and before the router is called. An | |
| 359 | 361 | // oversized body should not cost a session lookup, and a handler must not | |
| @@ -371,12 +373,7 @@ | |||
| 371 | 373 | let dispatch = { | |
| 372 | 374 | let context = Arc::clone(&context); | |
| 373 | 375 | let state = Arc::clone(&state); | |
| 374 | - | tokio::task::spawn_blocking(move || { | |
| 375 | - | context | |
| 376 | - | .router | |
| 377 | - | .handle(&state, incoming.method, &incoming.path, incoming.params) | |
| 378 | - | }) | |
| 379 | - | .await | |
| 376 | + | tokio::task::spawn_blocking(move || context.router.handle(&state, incoming.into())).await | |
| 380 | 377 | }; | |
| 381 | 378 | ||
| 382 | 379 | let outcome = dispatch.unwrap_or_else(|_| { |
| @@ -14,7 +14,7 @@ | |||
| 14 | 14 | use axum::body::Body; | |
| 15 | 15 | use axum::http::{Request, StatusCode, header}; | |
| 16 | 16 | use http_body_util::BodyExt; | |
| 17 | - | use quasi_router::{Node, Params, RegionKind, Response, RouteError, Router, Screen, Slot}; | |
| 17 | + | use quasi_router::{Node, RegionKind, Response, RouteError, Router, Screen, Slot}; | |
| 18 | 18 | use tower::ServiceExt; | |
| 19 | 19 | ||
| 20 | 20 | /// An app with nothing in it. The router is what is under test. | |
| @@ -37,31 +37,36 @@ | |||
| 37 | 37 | } | |
| 38 | 38 | } | |
| 39 | 39 | ||
| 40 | - | fn home(_app: &App, _params: Params) -> Result<Response, RouteError> { | |
| 40 | + | fn home(_app: &App, _request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 41 | 41 | Ok(Screen::sidebar_content("Home") | |
| 42 | 42 | .with(Slot::new("content", RegionKind::Pane)) | |
| 43 | 43 | .into()) | |
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | - | fn echo(_app: &App, params: Params) -> Result<Response, RouteError> { | |
| 47 | - | let joined = params | |
| 46 | + | fn echo(_app: &App, request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 47 | + | // Captures, then what was sent, then the view it was sent from: the same | |
| 48 | + | // order the one merged bag used to hold them in. | |
| 49 | + | let joined = request | |
| 50 | + | .captures | |
| 48 | 51 | .iter() | |
| 52 | + | .chain(request.payload.iter()) | |
| 53 | + | .chain(request.carried.iter()) | |
| 49 | 54 | .map(|(k, v)| format!("{k}={v}")) | |
| 50 | 55 | .collect::<Vec<_>>() | |
| 51 | 56 | .join(","); | |
| 52 | 57 | Ok(Response::fragment("detail", Node::text(joined))) | |
| 53 | 58 | } | |
| 54 | 59 | ||
| 55 | - | fn tags(_app: &App, params: Params) -> Result<Response, RouteError> { | |
| 56 | - | let all = params.get_all("tag").collect::<Vec<_>>().join("+"); | |
| 60 | + | fn tags(_app: &App, request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 61 | + | let all = request.payload.get_all("tag").collect::<Vec<_>>().join("+"); | |
| 57 | 62 | Ok(Response::fragment("detail", Node::text(all))) | |
| 58 | 63 | } | |
| 59 | 64 | ||
| 60 | - | fn denied(_app: &App, _params: Params) -> Result<Response, RouteError> { | |
| 65 | + | fn denied(_app: &App, _request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 61 | 66 | Err(RouteError::denied("not yours")) | |
| 62 | 67 | } | |
| 63 | 68 | ||
| 64 | - | fn boom(_app: &App, _params: Params) -> Result<Response, RouteError> { | |
| 69 | + | fn boom(_app: &App, _request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 65 | 70 | panic!("a handler that panics"); | |
| 66 | 71 | } | |
| 67 | 72 | ||
| @@ -346,7 +351,7 @@ | |||
| 346 | 351 | .with(Slot::new("detail", RegionKind::Pane).with(Node::text("Nothing selected"))) | |
| 347 | 352 | } | |
| 348 | 353 | ||
| 349 | - | fn real_home(_app: &App, _params: Params) -> Result<Response, RouteError> { | |
| 354 | + | fn real_home(_app: &App, _request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 350 | 355 | Ok(real_screen().into()) | |
| 351 | 356 | } | |
| 352 | 357 | ||
| @@ -409,7 +414,7 @@ | |||
| 409 | 414 | } | |
| 410 | 415 | } | |
| 411 | 416 | ||
| 412 | - | fn whoami(viewer: &Viewer, _params: Params) -> Result<Response, RouteError> { | |
| 417 | + | fn whoami(viewer: &Viewer, _request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 413 | 418 | Ok(Response::fragment("detail", Node::text(viewer.who.clone()))) | |
| 414 | 419 | } | |
| 415 | 420 | ||
| @@ -462,7 +467,7 @@ | |||
| 462 | 467 | async fn a_state_that_cannot_be_resolved_never_reaches_the_router() { | |
| 463 | 468 | // A store that will not answer is not a signed-out reader. The handler | |
| 464 | 469 | // panics, so reading the factory's own status back proves it never ran. | |
| 465 | - | fn never(_viewer: &Viewer, _params: Params) -> Result<Response, RouteError> { | |
| 470 | + | fn never(_viewer: &Viewer, _request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 466 | 471 | panic!("the router was called without a state"); | |
| 467 | 472 | } | |
| 468 | 473 |
| @@ -37,7 +37,9 @@ | |||
| 37 | 37 | //! | |
| 38 | 38 | //! [`quasi_axum`]: https://makenot.work/git/max/quasi | |
| 39 | 39 | ||
| 40 | - | use quasi_router::{Action, Destination, Method, Node, Outcome, Params, Response, RouteError}; | |
| 40 | + | use quasi_router::{ | |
| 41 | + | Action, Destination, Method, Node, Outcome, Params, Request, Response, RouteError, | |
| 42 | + | }; | |
| 41 | 43 | ||
| 42 | 44 | pub mod htmx; | |
| 43 | 45 | pub mod render; | |
| @@ -62,8 +64,22 @@ | |||
| 62 | 64 | pub method: Method, | |
| 63 | 65 | /// The path, with no scheme, host or query on it. | |
| 64 | 66 | pub path: String, | |
| 65 | - | /// Everything named the request carried, form ahead of query. | |
| 66 | - | pub params: Params, | |
| 67 | + | /// The form body: what the control sent. Empty on a read. | |
| 68 | + | pub payload: Params, | |
| 69 | + | /// The query string: the view the control was offered under. | |
| 70 | + | pub carried: Params, | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | impl From<Incoming> for Request { | |
| 74 | + | fn from(incoming: Incoming) -> Self { | |
| 75 | + | Self { | |
| 76 | + | method: incoming.method, | |
| 77 | + | path: incoming.path, | |
| 78 | + | captures: Params::new(), | |
| 79 | + | payload: incoming.payload, | |
| 80 | + | carried: incoming.carried, | |
| 81 | + | } | |
| 82 | + | } | |
| 67 | 83 | } | |
| 68 | 84 | ||
| 69 | 85 | /// A request the adapter turns away without troubling the router. | |
| @@ -99,13 +115,19 @@ | |||
| 99 | 115 | /// because the two hosts read them differently, and by the time either calls | |
| 100 | 116 | /// this the read has happened. | |
| 101 | 117 | /// | |
| 102 | - | /// # Ordering | |
| 118 | + | /// # The two halves stay apart | |
| 103 | 119 | /// | |
| 104 | - | /// The form is absorbed before the query, and [`Params::get`] answers with the | |
| 105 | - | /// first match, so a form field beats a query argument of the same name. A form | |
| 106 | - | /// is the answer to the question the screen asked; a query argument on a POST is | |
| 107 | - | /// context that came along with it. The path capture beats both, and the router | |
| 108 | - | /// is what applies that. | |
| 120 | + | /// The form body and the query string used to be absorbed into one bag here, | |
| 121 | + | /// form first, so that a form field beat a query argument of the same name. | |
| 122 | + | /// That merge was the bug: a screen carrying its view in the address sends its | |
| 123 | + | /// filters on every control, so a write about the same noun the filter filters | |
| 124 | + | /// on ended up with two meanings for one name and the handler read whichever | |
| 125 | + | /// landed first. goingson's mail screen met it twice in one afternoon. | |
| 126 | + | /// | |
| 127 | + | /// They are two things and they arrive separately, which is what HTTP already | |
| 128 | + | /// says: the query is where you are, the body is what you are telling it. The | |
| 129 | + | /// router adds the path captures as a third bag, since a capture is the route's | |
| 130 | + | /// own and was sent by nobody. | |
| 109 | 131 | pub fn decode( | |
| 110 | 132 | method: &http::Method, | |
| 111 | 133 | uri: &http::Uri, | |
| @@ -115,20 +137,20 @@ | |||
| 115 | 137 | ) -> Result<Incoming, Refusal> { | |
| 116 | 138 | let method = translate(method).ok_or(Refusal::Method)?; | |
| 117 | 139 | ||
| 118 | - | let mut params = Params::new(); | |
| 140 | + | let mut payload = Params::new(); | |
| 119 | 141 | if method.mutates() && is_form(headers) { | |
| 120 | 142 | if body.len() > body_limit { | |
| 121 | 143 | return Err(Refusal::TooLarge); | |
| 122 | 144 | } | |
| 123 | 145 | let text = std::str::from_utf8(body).map_err(|_| Refusal::Malformed)?; | |
| 124 | - | params.absorb(decode_pairs(text)); | |
| 146 | + | payload.absorb(decode_pairs(text)); | |
| 125 | 147 | } | |
| 126 | - | params.absorb(decode_pairs(uri.query().unwrap_or_default())); | |
| 127 | 148 | ||
| 128 | 149 | Ok(Incoming { | |
| 129 | 150 | method, | |
| 130 | 151 | path: uri.path().to_owned(), | |
| 131 | - | params, | |
| 152 | + | payload, | |
| 153 | + | carried: decode_pairs(uri.query().unwrap_or_default()), | |
| 132 | 154 | }) | |
| 133 | 155 | } | |
| 134 | 156 | ||
| @@ -203,7 +225,10 @@ | |||
| 203 | 225 | /// written: a `mailto:` or a `file://` has no query string this router built. | |
| 204 | 226 | fn redirect(action: &Action) -> http::Response<Vec<u8>> { | |
| 205 | 227 | let (header, address) = match &action.destination { | |
| 206 | - | Destination::Route(path) => (htmx::LOCATION, route_url(path, &action.params)), | |
| 228 | + | // The view, not the payload. Going somewhere is an address, and an | |
| 229 | + | // address is what `carried` holds; a redirect that dropped the filter | |
| 230 | + | // would land on an unfiltered list. | |
| 231 | + | Destination::Route(path) => (htmx::LOCATION, route_url(path, &action.carried)), | |
| 207 | 232 | Destination::External(address) => (htmx::REDIRECT, address.clone()), | |
| 208 | 233 | }; | |
| 209 | 234 | let mut builder = http::Response::builder().status(200); |
| @@ -52,11 +52,12 @@ | |||
| 52 | 52 | read("POST", uri, Some("application/x-www-form-urlencoded"), body) | |
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | - | /// The params, flattened, so an assertion reads like the wire did. | |
| 55 | + | /// Everything that arrived, flattened, so an assertion reads like the wire did. | |
| 56 | 56 | fn joined(incoming: &super::Incoming) -> String { | |
| 57 | 57 | incoming | |
| 58 | - | .params | |
| 58 | + | .payload | |
| 59 | 59 | .iter() | |
| 60 | + | .chain(incoming.carried.iter()) | |
| 60 | 61 | .map(|(k, v)| format!("{k}={v}")) | |
| 61 | 62 | .collect::<Vec<_>>() | |
| 62 | 63 | .join(",") | |
| @@ -93,16 +94,22 @@ | |||
| 93 | 94 | } | |
| 94 | 95 | ||
| 95 | 96 | #[test] | |
| 96 | - | fn a_form_field_beats_a_query_argument_of_the_same_name() { | |
| 97 | + | fn a_form_field_and_a_query_argument_of_the_same_name_stay_apart() { | |
| 98 | + | // They used to be merged, form first, so this read `from-form` and the | |
| 99 | + | // other value was unreachable. That merge is what let a screen's filter and | |
| 100 | + | // a write about the same noun mean one name between them. Now the body is | |
| 101 | + | // what the control sent and the query is the view it was sent from, and a | |
| 102 | + | // handler asks for the one it means. | |
| 97 | 103 | let incoming = form("/task/7/edit?title=from-query", "title=from-form").unwrap(); | |
| 98 | - | assert_eq!(incoming.params.get("title"), Some("from-form")); | |
| 104 | + | assert_eq!(incoming.payload.get("title"), Some("from-form")); | |
| 105 | + | assert_eq!(incoming.carried.get("title"), Some("from-query")); | |
| 99 | 106 | } | |
| 100 | 107 | ||
| 101 | 108 | #[test] | |
| 102 | 109 | fn repeated_names_all_survive() { | |
| 103 | 110 | let incoming = form("/tags", "tag=rust&tag=router&tag=quasi").unwrap(); | |
| 104 | 111 | assert_eq!( | |
| 105 | - | incoming.params.get_all("tag").collect::<Vec<_>>(), | |
| 112 | + | incoming.payload.get_all("tag").collect::<Vec<_>>(), | |
| 106 | 113 | ["rust", "router", "quasi"] | |
| 107 | 114 | ); | |
| 108 | 115 | } | |
| @@ -118,7 +125,7 @@ | |||
| 118 | 125 | "title=ignored", | |
| 119 | 126 | ) | |
| 120 | 127 | .unwrap(); | |
| 121 | - | assert!(incoming.params.is_empty()); | |
| 128 | + | assert!(incoming.payload.is_empty()); | |
| 122 | 129 | } | |
| 123 | 130 | ||
| 124 | 131 | #[test] | |
| @@ -130,7 +137,7 @@ | |||
| 130 | 137 | "--xyz--", | |
| 131 | 138 | ) | |
| 132 | 139 | .unwrap(); | |
| 133 | - | assert!(incoming.params.is_empty()); | |
| 140 | + | assert!(incoming.payload.is_empty()); | |
| 134 | 141 | } | |
| 135 | 142 | ||
| 136 | 143 | #[test] | |
| @@ -142,7 +149,7 @@ | |||
| 142 | 149 | "title=ok", | |
| 143 | 150 | ) | |
| 144 | 151 | .unwrap(); | |
| 145 | - | assert_eq!(incoming.params.get("title"), Some("ok")); | |
| 152 | + | assert_eq!(incoming.payload.get("title"), Some("ok")); | |
| 146 | 153 | } | |
| 147 | 154 | ||
| 148 | 155 | #[test] |
| @@ -25,15 +25,16 @@ | |||
| 25 | 25 | //! ``` | |
| 26 | 26 | //! | |
| 27 | 27 | //! ``` | |
| 28 | - | //! use quasi_router::{Action, Method, Node, Params, Outcome, Response, RouteError, Router, Screen, Slot}; | |
| 28 | + | //! use quasi_router::{Action, Node, Outcome, Request, Response, RouteError, Router, Screen, Slot}; | |
| 29 | 29 | //! use quasi_router::layout::{Arrangement, Region}; | |
| 30 | 30 | //! | |
| 31 | 31 | //! struct App { | |
| 32 | 32 | //! tasks: Vec<(u32, String, bool)>, | |
| 33 | 33 | //! } | |
| 34 | 34 | //! | |
| 35 | - | //! fn show_task(app: &App, params: Params) -> Result<Response, RouteError> { | |
| 36 | - | //! let id: u32 = params | |
| 35 | + | //! fn show_task(app: &App, request: Request) -> Result<Response, RouteError> { | |
| 36 | + | //! let id: u32 = request | |
| 37 | + | //! .captures | |
| 37 | 38 | //! .require("id")? | |
| 38 | 39 | //! .parse() | |
| 39 | 40 | //! .map_err(|_| RouteError::not_found("no such task"))?; | |
| @@ -55,8 +56,8 @@ | |||
| 55 | 56 | //! Ok(Screen::list_detail(title.clone(), false).with(detail).into()) | |
| 56 | 57 | //! } | |
| 57 | 58 | //! | |
| 58 | - | //! fn complete_task(_app: &App, params: Params) -> Result<Response, RouteError> { | |
| 59 | - | //! let id = params.require("id")?; | |
| 59 | + | //! fn complete_task(_app: &App, request: Request) -> Result<Response, RouteError> { | |
| 60 | + | //! let id = request.captures.require("id")?; | |
| 60 | 61 | //! Ok(Response::fragment( | |
| 61 | 62 | //! "detail", | |
| 62 | 63 | //! Node::text(format!("task {id} is done")), | |
| @@ -68,7 +69,7 @@ | |||
| 68 | 69 | //! .post("/task/:id/complete", complete_task); | |
| 69 | 70 | //! | |
| 70 | 71 | //! let app = App { tasks: vec![(7, "Write the router".into(), false)] }; | |
| 71 | - | //! let answer = router.handle(&app, Method::Get, "/task/7", Params::new()).unwrap(); | |
| 72 | + | //! let answer = router.handle(&app, Request::get("/task/7")).unwrap(); | |
| 72 | 73 | //! assert!(matches!(answer.outcome, Outcome::Screen(_))); | |
| 73 | 74 | //! ``` | |
| 74 | 75 | //! | |
| @@ -105,7 +106,7 @@ | |||
| 105 | 106 | pub use makeover_layout as layout; | |
| 106 | 107 | ||
| 107 | 108 | pub use crate::error::{Class, RouteError}; | |
| 108 | - | pub use crate::request::{Method, Params}; | |
| 109 | + | pub use crate::request::{Method, Params, Request}; | |
| 109 | 110 | pub use crate::response::{Message, Outcome, Response}; | |
| 110 | 111 | pub use crate::router::{Handler, Router}; | |
| 111 | 112 | pub use crate::screen::{ | |
| @@ -123,25 +124,25 @@ | |||
| 123 | 124 | greeting: &'static str, | |
| 124 | 125 | } | |
| 125 | 126 | ||
| 126 | - | fn home(state: &State, _params: Params) -> Result<Response, RouteError> { | |
| 127 | + | fn home(state: &State, _request: Request) -> Result<Response, RouteError> { | |
| 127 | 128 | Ok(Screen::sidebar_content("Home") | |
| 128 | 129 | .with(Slot::new("content", RegionKind::Pane).with(Node::text(state.greeting))) | |
| 129 | 130 | .into()) | |
| 130 | 131 | } | |
| 131 | 132 | ||
| 132 | - | fn new_task(_state: &State, _params: Params) -> Result<Response, RouteError> { | |
| 133 | + | fn new_task(_state: &State, _request: Request) -> Result<Response, RouteError> { | |
| 133 | 134 | Ok(Response::fragment("detail", Node::text("a new task"))) | |
| 134 | 135 | } | |
| 135 | 136 | ||
| 136 | 137 | // Taken by value because [`Handler`] says so, and a handler that only reads | |
| 137 | 138 | // its parameters is the common case rather than an oversight. | |
| 138 | 139 | #[allow(clippy::needless_pass_by_value)] | |
| 139 | - | fn show_task(_state: &State, params: Params) -> Result<Response, RouteError> { | |
| 140 | - | let id = params.require("id")?.to_owned(); | |
| 140 | + | fn show_task(_state: &State, request: Request) -> Result<Response, RouteError> { | |
| 141 | + | let id = request.captures.require("id")?.to_owned(); | |
| 141 | 142 | Ok(Response::fragment("detail", Node::text(id))) | |
| 142 | 143 | } | |
| 143 | 144 | ||
| 144 | - | fn forbidden(_state: &State, _params: Params) -> Result<Response, RouteError> { | |
| 145 | + | fn forbidden(_state: &State, _request: Request) -> Result<Response, RouteError> { | |
| 145 | 146 | Err(RouteError::denied("not yours")) | |
| 146 | 147 | } | |
| 147 | 148 | ||
| @@ -172,16 +173,14 @@ | |||
| 172 | 173 | #[test] | |
| 173 | 174 | fn a_static_route_beats_a_capture_whatever_the_order() { | |
| 174 | 175 | let answer = router() | |
| 175 | - | .handle(&state(), Method::Get, "/task/new", Params::new()) | |
| 176 | + | .handle(&state(), Request::get("/task/new")) | |
| 176 | 177 | .unwrap(); | |
| 177 | 178 | assert_eq!(text_of(&answer), Some("a new task")); | |
| 178 | 179 | } | |
| 179 | 180 | ||
| 180 | 181 | #[test] | |
| 181 | 182 | fn a_capture_reaches_the_handler() { | |
| 182 | - | let answer = router() | |
| 183 | - | .handle(&state(), Method::Get, "/task/7", Params::new()) | |
| 184 | - | .unwrap(); | |
| 183 | + | let answer = router().handle(&state(), Request::get("/task/7")).unwrap(); | |
| 185 | 184 | assert_eq!(text_of(&answer), Some("7")); | |
| 186 | 185 | } | |
| 187 | 186 | ||
| @@ -190,7 +189,7 @@ | |||
| 190 | 189 | // The path is the address; the body is only what was sent to it. | |
| 191 | 190 | let sent = Params::new().with("id", "9"); | |
| 192 | 191 | let answer = router() | |
| 193 | - | .handle(&state(), Method::Get, "/task/7", sent) | |
| 192 | + | .handle(&state(), Request::get("/task/7").carrying(sent)) | |
| 194 | 193 | .unwrap(); | |
| 195 | 194 | assert_eq!(text_of(&answer), Some("7")); | |
| 196 | 195 | } | |
| @@ -199,7 +198,7 @@ | |||
| 199 | 198 | fn a_read_and_a_write_are_different_routes_at_one_address() { | |
| 200 | 199 | let router = router(); | |
| 201 | 200 | let missing = router | |
| 202 | - | .handle(&state(), Method::Post, "/task/7", Params::new()) | |
| 201 | + | .handle(&state(), Request::post("/task/7")) | |
| 203 | 202 | .unwrap_err(); | |
| 204 | 203 | assert_eq!(missing.class, Class::NotFound); | |
| 205 | 204 | assert!(missing.message.contains("another method")); | |
| @@ -208,7 +207,7 @@ | |||
| 208 | 207 | #[test] | |
| 209 | 208 | fn an_unknown_path_says_so_without_mentioning_a_method() { | |
| 210 | 209 | let missing = router() | |
| 211 | - | .handle(&state(), Method::Get, "/nowhere", Params::new()) | |
| 210 | + | .handle(&state(), Request::get("/nowhere")) | |
| 212 | 211 | .unwrap_err(); | |
| 213 | 212 | assert_eq!(missing.class, Class::NotFound); | |
| 214 | 213 | assert!(!missing.message.contains("another method")); | |
| @@ -217,7 +216,7 @@ | |||
| 217 | 216 | #[test] | |
| 218 | 217 | fn a_denial_carries_a_banner_and_a_status() { | |
| 219 | 218 | let denied = router() | |
| 220 | - | .handle(&state(), Method::Post, "/task/7/delete", Params::new()) | |
| 219 | + | .handle(&state(), Request::post("/task/7/delete")) | |
| 221 | 220 | .unwrap_err(); | |
| 222 | 221 | assert_eq!(denied.class, Class::Denied); | |
| 223 | 222 | assert_eq!(denied.class.http_status(), 403); | |
| @@ -230,7 +229,7 @@ | |||
| 230 | 229 | fn a_missing_parameter_is_our_bug_not_the_users() { | |
| 231 | 230 | // Reached only by calling the handler outside the router, which is what | |
| 232 | 231 | // a renderer emitting an unfilled route amounts to. | |
| 233 | - | let missing = show_task(&state(), Params::new()).unwrap_err(); | |
| 232 | + | let missing = show_task(&state(), Request::get("/task/7")).unwrap_err(); | |
| 234 | 233 | assert_eq!(missing.class, Class::Internal); | |
| 235 | 234 | assert!(missing.class.is_ours()); | |
| 236 | 235 | } | |
| @@ -238,14 +237,10 @@ | |||
| 238 | 237 | #[test] | |
| 239 | 238 | fn a_screen_answers_with_no_target_and_a_fragment_with_one() { | |
| 240 | 239 | let router = router(); | |
| 241 | - | let screen = router | |
| 242 | - | .handle(&state(), Method::Get, "/", Params::new()) | |
| 243 | - | .unwrap(); | |
| 240 | + | let screen = router.handle(&state(), Request::get("/")).unwrap(); | |
| 244 | 241 | assert_eq!(screen.target(), None); | |
| 245 | 242 | ||
| 246 | - | let fragment = router | |
| 247 | - | .handle(&state(), Method::Get, "/task/7", Params::new()) | |
| 248 | - | .unwrap(); | |
| 243 | + | let fragment = router.handle(&state(), Request::get("/task/7")).unwrap(); | |
| 249 | 244 | assert_eq!(fragment.target(), Some("detail")); | |
| 250 | 245 | } | |
| 251 | 246 |
| @@ -163,3 +163,84 @@ | |||
| 163 | 163 | } | |
| 164 | 164 | } | |
| 165 | 165 | } | |
| 166 | + | ||
| 167 | + | /// Everything a handler is given: a verb, a path, and three bags of values. | |
| 168 | + | /// | |
| 169 | + | /// # Why three bags and not one | |
| 170 | + | /// | |
| 171 | + | /// One bag was the shape until 2026-08-10, and it could not answer the question | |
| 172 | + | /// every screen that carries its view in the address ends up asking. Such a | |
| 173 | + | /// screen sends its filters on every control, and a write sends its own values; | |
| 174 | + | /// both arrived here under one namespace with nothing separating them. A screen | |
| 175 | + | /// filtering on `status` that also writes a `status` then had two meanings for | |
| 176 | + | /// one name, and the handler read whichever landed first. goingson's mail screen | |
| 177 | + | /// met it twice in an afternoon and its problems inbox met it again the same | |
| 178 | + | /// day, each time working around it by renaming the write's parameter — a | |
| 179 | + | /// convention held by hand, in one app, by whoever remembered. | |
| 180 | + | /// | |
| 181 | + | /// The split is not invented for this. HTTP already draws it and the adapters | |
| 182 | + | /// already had both halves in their hands before merging them: the address is | |
| 183 | + | /// where you are, the body is what you are telling it. | |
| 184 | + | /// | |
| 185 | + | /// - [`Self::captures`] — named pieces of the path pattern. The route's own, and | |
| 186 | + | /// not something anyone sent. | |
| 187 | + | /// - [`Self::payload`] — what this control sent. A write's values. Empty on a | |
| 188 | + | /// read, because a read has nothing to say: its values *are* its address. | |
| 189 | + | /// - [`Self::carried`] — the view the control was offered under. The query | |
| 190 | + | /// string. | |
| 191 | + | /// | |
| 192 | + | /// So the rule a screen needs is one sentence: **a filter is read from | |
| 193 | + | /// `carried`, a write's target from `payload`.** A name in both is now | |
| 194 | + | /// well-defined rather than a collision, which is the property that retires the | |
| 195 | + | /// naming convention. | |
| 196 | + | /// | |
| 197 | + | /// There is no `get` on this type on purpose. A single accessor that searched | |
| 198 | + | /// all three would be the old bag again with more steps, and the compiler | |
| 199 | + | /// forcing every read site to name its bag is most of what this change buys. | |
| 200 | + | #[derive(Debug, Clone, Default, PartialEq, Eq)] | |
| 201 | + | pub struct Request { | |
| 202 | + | /// Asking or telling. | |
| 203 | + | pub method: Method, | |
| 204 | + | /// The path, with no scheme, host or query on it. | |
| 205 | + | pub path: String, | |
| 206 | + | /// Values captured out of the path pattern, filled in by the router. | |
| 207 | + | pub captures: Params, | |
| 208 | + | /// What the control sent. Empty on a read. | |
| 209 | + | pub payload: Params, | |
| 210 | + | /// The view the control was offered under. | |
| 211 | + | pub carried: Params, | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | impl Request { | |
| 215 | + | /// A read of a path, carrying nothing. | |
| 216 | + | pub fn get(path: impl Into<String>) -> Self { | |
| 217 | + | Self { | |
| 218 | + | method: Method::Get, | |
| 219 | + | path: path.into(), | |
| 220 | + | ..Self::default() | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | /// A write to a path, sending nothing. | |
| 225 | + | pub fn post(path: impl Into<String>) -> Self { | |
| 226 | + | Self { | |
| 227 | + | method: Method::Post, | |
| 228 | + | path: path.into(), | |
| 229 | + | ..Self::default() | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | /// The values this control sent, chaining. | |
| 234 | + | #[must_use] | |
| 235 | + | pub fn sending(mut self, payload: Params) -> Self { | |
| 236 | + | self.payload = payload; | |
| 237 | + | self | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | /// The view it was offered under, chaining. | |
| 241 | + | #[must_use] | |
| 242 | + | pub fn carrying(mut self, carried: Params) -> Self { | |
| 243 | + | self.carried = carried; | |
| 244 | + | self | |
| 245 | + | } | |
| 246 | + | } |