Skip to main content

max / quasi

A request arrives in three bags, not one The sixth finding, closed. A screen that carries its view in the address sends its filters on every control, and a write sends its own values; both arrived in one Params with nothing separating them. A screen that filtered on `status` and wrote a `status` had two meanings for one name, and the handler read whichever landed first. goingson's mail screen met it twice in an afternoon and its problems inbox met it again the same day, each time working around it by renaming the write's parameter. The split is not invented for this. HTTP already draws it and the adapters had both halves in their hands before merging them: the query is where you are, the body is what you are telling it. So `captures` from the path, `payload` from what the control sent, `carried` from the address it was sent from. A read has no payload — its values are its address — which keeps the reading rule to one sentence: a filter is in `carried`, a write's target in `payload`. Handler is now fn(&S, Request). There is deliberately no `get` on it: an accessor that searched all three would be the old bag again, and the compiler forcing every read site to name its bag is most of what this buys. Two bugs fall out with it, both in the webview transport. `hx-vals` is a JSON object literal, so two entries under one name emitted a duplicate key and every parser kept the last, inverting Params::get's first-wins rule the moment a value crossed the wire; and every repeat that Params::get_all exists to carry was silently dropped. The view now travels on the address, where a query string repeats a name happily and where a view belonged anyway: a filtered list is a place, so its controls are middle-clickable and a chosen tab survives a reload.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-10 19:55 UTC
Signed with PGP, not checked
Commit: f5cf080e43b5e37424d9ee635f6142b59d4ed44f
Parent: 9d9f465
16 files changed, +417 insertions, -192 deletions
M Cargo.lock +8 -8
@@ -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 + }