Skip to main content

max / quasi

17.8 KB · 442 lines History Blame Raw
1 //! The http-shaped seam every webview host adapter shares.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! A hosted axum route and a Tauri custom-protocol handler are the same
6 //! function wearing two coats. Both are handed a verb, a path, a query string
7 //! and maybe a form body; both owe back a status, some headers and a rendered
8 //! body; and both are talking to htmx on the other side. The coats are the
9 //! parts that differ, and they are thin: how the bytes arrive, and which thread
10 //! is allowed to block.
11 //!
12 //! So the middle lives here, in `http` types that neither host owns, and a host
13 //! adapter is left with only its own coat. [`quasi_axum`] was the first caller
14 //! and carried this code inline; the Tauri adapter is the second, and the
15 //! [`htmx`] module said in its own doc comment that it would move when that
16 //! happened.
17 //!
18 //! # What is here
19 //!
20 //! - [`decode`], which turns a request into the [`Method`], path and [`Params`]
21 //! the router takes. [`quasi_router`] deliberately parses neither a query
22 //! string nor a form body, because every host already has that code.
23 //! - [`respond`] and [`refuse`], which turn the router's answer, or the
24 //! adapter's own refusal, into a response.
25 //! - [`Serves`], the seam to markup, which is a parameter rather than an
26 //! implementation for the reason its own docs give.
27 //! - [`htmx`], the two response headers and one client configuration an http
28 //! host has to know to speak the webview transport correctly.
29 //!
30 //! # What is not here
31 //!
32 //! Anything that knows which host it is in. There is no runtime, no thread and
33 //! no socket in this crate, and nothing in it is `async`: the blocking hop is a
34 //! host's own problem and the two hosts solve it differently. That is what
35 //! keeps this callable from a Tauri protocol handler, which has no executor of
36 //! its own to hand work to.
37 //!
38 //! [`quasi_axum`]: https://makenot.work/git/max/quasi
39
40 use quasi_router::{
41 Action, Address, Destination, Method, Node, Outcome, Params, Request, Response, RouteError,
42 };
43
44 pub mod htmx;
45 pub mod serves;
46
47 pub use crate::serves::Serves;
48
49 /// How much of a form body is read before the request is refused.
50 ///
51 /// A description-layer form is fields and choices, so a request an order of
52 /// magnitude past this is a mistake or an attack rather than a long answer.
53 /// File uploads do not come through here: a byte stream is not something a
54 /// description describes, and each host keeps its own path for them.
55 pub const DEFAULT_BODY_LIMIT: usize = 256 * 1024;
56
57 /// The verbs the description layer has, as an `Allow` header value.
58 ///
59 /// Must list exactly what `translate` accepts, or the header promises a verb
60 /// the decoder refuses. `PATCH` is the one HTTP has that this does not: nothing
61 /// in either app writes one, and a verb with no consumer is a verb whose
62 /// meaning nobody has had to decide.
63 pub const ALLOWED_METHODS: &str = "GET, POST, DELETE, PUT";
64
65 /// A request, in the terms the router takes.
66 #[derive(Debug, Clone, PartialEq, Eq)]
67 pub struct Incoming {
68 /// Asking or telling.
69 pub method: Method,
70 /// The path, with no scheme, host or query on it.
71 pub path: String,
72 /// The form body: what the control sent. Empty on a read.
73 pub payload: Params,
74 /// The query string: the view the control was offered under.
75 pub carried: Params,
76 }
77
78 /// What was asked, kept back so the answer can be placed in history.
79 ///
80 /// [`Incoming`] is consumed by the router, and the two facts history needs — was
81 /// this a read, and of what address — outlive it. Taken here rather than
82 /// re-derived from the http request, so the URL a push carries is exactly the
83 /// one the route was reached at, params and all.
84 #[derive(Debug, Clone, PartialEq, Eq)]
85 pub struct Asked {
86 /// Asking or telling. Only a read can be a place.
87 pub method: Method,
88 /// The address this request was made at, carried params included.
89 pub url: String,
90 }
91
92 impl Asked {
93 /// What a request was, before the router takes it.
94 #[must_use]
95 pub fn new(incoming: &Incoming) -> Self {
96 Self {
97 method: incoming.method,
98 url: route_url(&incoming.path, &incoming.carried),
99 }
100 }
101 }
102
103 impl From<Incoming> for Request {
104 fn from(incoming: Incoming) -> Self {
105 Self {
106 method: incoming.method,
107 path: incoming.path,
108 captures: Params::new(),
109 payload: incoming.payload,
110 carried: incoming.carried,
111 }
112 }
113 }
114
115 /// A request the adapter turns away without troubling the router.
116 ///
117 /// Three, and all three are about the envelope rather than the address. A
118 /// missing route is not here: that is a [`RouteError`] from the router, it is
119 /// classified, and it renders a notice like any other failure.
120 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
121 pub enum Refusal {
122 /// A verb the description layer does not have.
123 Method,
124 /// A form body past the limit.
125 TooLarge,
126 /// A form body that is not UTF-8.
127 Malformed,
128 }
129
130 impl Refusal {
131 /// The status this refusal answers with.
132 #[must_use]
133 pub const fn status(self) -> u16 {
134 match self {
135 Self::Method => 405,
136 Self::TooLarge => 413,
137 Self::Malformed => 400,
138 }
139 }
140 }
141
142 /// Read a request into the router's terms.
143 ///
144 /// `body` is the bytes the host already has. Reading them is the host's job
145 /// because the two hosts read them differently, and by the time either calls
146 /// this the read has happened.
147 ///
148 /// # The two halves stay apart
149 ///
150 /// The form body and the query string used to be absorbed into one bag here,
151 /// form first, so that a form field beat a query argument of the same name.
152 /// That merge was the bug: a screen carrying its view in the address sends its
153 /// filters on every control, so a write about the same noun the filter filters
154 /// on ended up with two meanings for one name and the handler read whichever
155 /// landed first. goingson's mail screen met it twice in one afternoon.
156 ///
157 /// They are two things and they arrive separately, which is what HTTP already
158 /// says: the query is where you are, the body is what you are telling it. The
159 /// router adds the path captures as a third bag, since a capture is the route's
160 /// own and was sent by nobody.
161 pub fn decode(
162 method: &http::Method,
163 uri: &http::Uri,
164 headers: &http::HeaderMap,
165 body: &[u8],
166 body_limit: usize,
167 ) -> Result<Incoming, Refusal> {
168 let method = translate(method).ok_or(Refusal::Method)?;
169
170 let mut payload = Params::new();
171 if method.mutates() && is_form(headers) {
172 if body.len() > body_limit {
173 return Err(Refusal::TooLarge);
174 }
175 let text = std::str::from_utf8(body).map_err(|_| Refusal::Malformed)?;
176 payload.absorb(decode_pairs(text));
177 }
178
179 Ok(Incoming {
180 method,
181 path: uri.path().to_owned(),
182 payload,
183 carried: decode_pairs(uri.query().unwrap_or_default()),
184 })
185 }
186
187 /// Turn the router's answer into a response.
188 ///
189 /// Takes the `Result` whole rather than the two halves separately, because a
190 /// failure is not a special case here: it becomes a [`Node::Notice`] and is
191 /// rendered as a fragment down the same path as everything else. That is
192 /// decision 9 holding at the boundary and not only in the router.
193 ///
194 /// A host with a way of failing the router does not have, such as a panicking
195 /// handler or a worker that died, reports it as
196 /// [`RouteError::internal`] and gets the same treatment.
197 pub fn respond<R: Serves + ?Sized>(
198 render: &R,
199 outcome: Result<Response, RouteError>,
200 asked: &Asked,
201 ) -> http::Response<Vec<u8>> {
202 match outcome {
203 Ok(answer) => {
204 // Whether this answer is a place, decided here because this is the
205 // one point that holds both halves: what was asked, and what the
206 // router did about it. A control cannot know the second, which is
207 // why no `hx-push-url` is ever emitted into markup.
208 let address = placement(&answer, asked);
209 // The notice is orthogonal to the outcome and is applied to all
210 // three, including a redirect, which has no body to carry one.
211 let trigger = answer
212 .notice
213 .as_ref()
214 .map(|notice| htmx::notice_trigger(notice.kind, notice.tone, &notice.text));
215 let mut response = match answer.outcome {
216 // An invalidation is not applied to a whole screen, and that is
217 // not a case being dropped. Every slot is being replaced
218 // already, so an out-of-band copy would be a second element
219 // carrying an id the document now has twice. A handler that
220 // says `.also()` on a screen is stating something the answer
221 // already made true.
222 Outcome::Screen(screen) => body(render, 200, render.screen(&screen), None),
223 Outcome::Fragment { region, node } => {
224 // The router said what it changed, so the client is told
225 // rather than left to infer it from which element was
226 // clicked. The webview renderer owes every slot an `id`
227 // matching its `Slot::id` for this to land.
228 //
229 // The other slots the answer changed ride along behind the
230 // targeted one, each named rather than aimed. Appended in
231 // the order the router gave them, because a renderer
232 // reordering them would be inventing a fact the response
233 // did not state.
234 let mut markup = render.fragment(&node);
235 for stale in &answer.invalidates {
236 markup.push_str(&render.invalidated(&stale.region, &stale.node));
237 }
238 body(render, 200, markup, Some(format!("#{region}")))
239 }
240 // Nor to a redirect, which has no body to carry one. The
241 // destination answers next and answers with everything.
242 Outcome::Goto(action) => redirect(&action),
243 // Over what is already there. The retarget is the whole
244 // difference from a screen: the answer lands in the overlay
245 // container and the document under it is left alone. A
246 // renderer with no such container says so by answering `None`,
247 // and its `overlay` draws the screen instead.
248 Outcome::Over(screen) => {
249 let target = render.overlay_target().map(|id| format!("#{id}"));
250 body(render, 200, render.overlay(&screen), target)
251 }
252 };
253 if let Some(trigger) = trigger
254 && let Ok(value) = http::HeaderValue::from_str(&trigger)
255 {
256 response.headers_mut().insert(htmx::TRIGGER, value);
257 }
258 if let Some((header, url)) = address
259 && let Ok(value) = http::HeaderValue::from_str(&url)
260 {
261 response.headers_mut().insert(header, value);
262 }
263 response
264 }
265 Err(error) => {
266 let node = Node::Notice {
267 kind: error.notice,
268 tone: error.tone(),
269 text: error.message.clone(),
270 };
271 body(
272 render,
273 error.class.http_status(),
274 render.fragment(&node),
275 None,
276 )
277 }
278 }
279 }
280
281 /// Which history header this answer earns, and what address it carries.
282 ///
283 /// The override first, then the derivation, because the whole point of
284 /// [`Address`] is to be able to say something the derivation cannot reach.
285 ///
286 /// The derivation:
287 ///
288 /// - a read answering with a whole screen is a place, at the address it was
289 /// read from
290 /// - a write answering with a screen is not: the address is where the form was,
291 /// and going back to it should not re-offer the write's result as a page
292 /// - a fragment is not a place, unless it says otherwise. This is where the
293 /// addressable tab panel says otherwise
294 /// - a [`Goto`](Outcome::Goto) sets nothing, because
295 /// [`HX-Location`](htmx::LOCATION) issues the request client-side and htmx
296 /// pushes for it, and [`HX-Redirect`](htmx::REDIRECT) is a real navigation
297 fn placement(answer: &Response, asked: &Asked) -> Option<(&'static str, String)> {
298 match &answer.address {
299 Some(Address::Enters(url)) => return Some((htmx::PUSH_URL, url.clone())),
300 Some(Address::Replaces(url)) => return Some((htmx::REPLACE_URL, url.clone())),
301 Some(Address::Unchanged) => return None,
302 None => {}
303 }
304
305 match answer.outcome {
306 Outcome::Screen(_) if asked.method == Method::Get => {
307 Some((htmx::PUSH_URL, asked.url.clone()))
308 }
309 _ => None,
310 }
311 }
312
313 /// Send the user somewhere instead of answering with content.
314 ///
315 /// 200 and an empty body, with the whole answer in the header. See
316 /// [`htmx::LOCATION`] for why this is not a 303.
317 ///
318 /// A route keeps its params, because a redirect back to a filtered list that
319 /// drops the filter is a different place. An external address is taken as
320 /// written: a `mailto:` or a `file://` has no query string this router built.
321 fn redirect(action: &Action) -> http::Response<Vec<u8>> {
322 let (header, address) = match &action.destination {
323 // The view, not the payload. Going somewhere is an address, and an
324 // address is what `carried` holds; a redirect that dropped the filter
325 // would land on an unfiltered list.
326 Destination::Route(path) => (htmx::LOCATION, route_url(path, &action.carried)),
327 Destination::External(address) => (htmx::REDIRECT, address.clone()),
328 };
329 let mut builder = http::Response::builder().status(200);
330 if let Ok(value) = http::HeaderValue::from_str(&address) {
331 builder = builder.header(header, value);
332 }
333 builder
334 .body(Vec::new())
335 .expect("a response with no body and one checked header is always valid")
336 }
337
338 /// A route with its parameters folded into a query string.
339 ///
340 /// Encoded here rather than in the router, for the reason the router does not
341 /// decode: the host has this code already and a second implementation is a
342 /// second place for an escaping bug. Public because the renderers need the same
343 /// answer the redirect does. A read that a renderer writes into an `href` and a
344 /// redirect back to a filtered list are the same address, and two functions
345 /// building it is how they stop being.
346 ///
347 /// A route with no parameters is returned as written, so nothing gains a
348 /// trailing `?` it did not have.
349 #[must_use]
350 pub fn route_url(path: &str, params: &Params) -> String {
351 if params.is_empty() {
352 return path.to_owned();
353 }
354 let query = form_urlencoded::Serializer::new(String::new())
355 .extend_pairs(params.iter())
356 .finish();
357 let joiner = if path.contains('?') { '&' } else { '?' };
358 format!("{path}{joiner}{query}")
359 }
360
361 /// Turn the adapter's own refusal into a response.
362 ///
363 /// No body, because there is nothing to say that the status does not already
364 /// say and no description was ever reached. `Allow` on a 405 is what a client
365 /// needs to correct itself rather than retry the same thing.
366 #[must_use]
367 pub fn refuse(refusal: Refusal) -> http::Response<Vec<u8>> {
368 let mut builder = http::Response::builder().status(refusal.status());
369 if refusal == Refusal::Method {
370 builder = builder.header(http::header::ALLOW, ALLOWED_METHODS);
371 }
372 builder
373 .body(Vec::new())
374 .expect("a response with no body and a static header is always valid")
375 }
376
377 /// A rendered body, with the renderer's own content type.
378 fn body<R: Serves + ?Sized>(
379 render: &R,
380 status: u16,
381 rendered: String,
382 retarget: Option<String>,
383 ) -> http::Response<Vec<u8>> {
384 let mut builder = http::Response::builder()
385 .status(status)
386 .header(http::header::CONTENT_TYPE, render.content_type());
387 if let Some(target) = retarget {
388 builder = builder.header(htmx::RETARGET, target);
389 }
390 builder.body(rendered.into_bytes()).unwrap_or_else(|_| {
391 // Only reachable if a renderer answered with a content type that is not
392 // a legal header value, which is our bug and not the request's.
393 http::Response::builder()
394 .status(500)
395 .body(Vec::new())
396 .expect("a response with no body and no headers is always valid")
397 })
398 }
399
400 /// The two verbs the description layer has, and nothing else.
401 fn translate(method: &http::Method) -> Option<Method> {
402 match *method {
403 http::Method::GET => Some(Method::Get),
404 http::Method::POST => Some(Method::Post),
405 http::Method::DELETE => Some(Method::Delete),
406 http::Method::PUT => Some(Method::Put),
407 _ => None,
408 }
409 }
410
411 /// Whether the body is a form these adapters read.
412 ///
413 /// `multipart/form-data` is deliberately not read. A file is a byte stream, a
414 /// description has no word for one, and buffering an upload into [`Params`]
415 /// would be the wrong shape at any size. Such a request still routes, with no
416 /// parameters from its body.
417 fn is_form(headers: &http::HeaderMap) -> bool {
418 headers
419 .get(http::header::CONTENT_TYPE)
420 .and_then(|value| value.to_str().ok())
421 .is_some_and(|value| {
422 value.split(';').next().is_some_and(|kind| {
423 kind.trim()
424 .eq_ignore_ascii_case("application/x-www-form-urlencoded")
425 })
426 })
427 }
428
429 /// Percent-decoded name and value pairs, repeats kept.
430 ///
431 /// Repeats are the point: a checkbox group submits one name several times, and
432 /// a decoder that keeps the last is a bug that only shows on the screen with
433 /// the multi-select on it.
434 fn decode_pairs(encoded: &str) -> Params {
435 form_urlencoded::parse(encoded.as_bytes())
436 .map(|(name, value)| (name.into_owned(), value.into_owned()))
437 .collect()
438 }
439
440 #[cfg(test)]
441 mod tests;
442