Skip to main content

max / quasi

13.0 KB · 321 lines History Blame Raw
1 //! The htmx side of the contract, in one place.
2 //!
3 //! htmx is the webview transport, and it sits *below* the description: nothing
4 //! in [`quasi_router`] names it, and the description never will. What is here
5 //! is the small amount an HTTP host has to know to speak it correctly, which is
6 //! a handful of response headers.
7 //!
8 //! # Which htmx this speaks
9 //!
10 //! htmx 4, and not 2.x. quasi is pre-release, so writing the transport
11 //! against a model that is being replaced means writing it twice; 4.0 is in
12 //! beta with no announced stable date and the bet is deliberate. Every header
13 //! named below is read by 4.0.0-beta6, checked against its source rather than
14 //! against the migration guide.
15 //!
16 //! One 4 behaviour is load-bearing here and is the reason a whole constant
17 //! left this module: **every response swaps except 204 and 304**. Under 2.x
18 //! the default `responseHandling` dropped a 4xx, so decision 9's classified
19 //! errors (403 `Denied`, 404 `NotFound`) rendered nothing at all and a
20 //! `htmx-config` meta tag turning the swap back on was a required piece of the
21 //! webview adapter. Under 4 it is the default, `responseHandling` no longer
22 //! exists as a config key, and a screen wanting per-status behaviour says so
23 //! on the element with `hx-status:XXX`.
24 //!
25 //! This belonged to the webview transport rather than to axum specifically,
26 //! and lived in `quasi-axum` while axum was the only caller. The Tauri
27 //! custom-protocol adapter is the second, so it moved here as its own doc
28 //! comment said it would.
29
30 use quasi_router::layout;
31
32 /// The header naming the element a response replaces.
33 ///
34 /// Set from [`Response::Fragment`](quasi_router::Response::Fragment), because
35 /// the router is the only party that knows what it just changed. htmx wants a
36 /// CSS selector, so a [`Slot::id`](quasi_router::Slot::id) of `detail` is sent
37 /// as `#detail`, and the webview renderer owes every slot a matching `id`.
38 pub const RETARGET: &str = "HX-Retarget";
39
40 /// The header sending the user to another route in this app.
41 ///
42 /// Set from [`Outcome::Goto`](quasi_router::Outcome::Goto) with a
43 /// [`Destination::Route`](quasi_router::Destination::Route). htmx issues the
44 /// request itself and swaps the body, so history and the back button work and
45 /// the page is not torn down.
46 ///
47 /// Deliberately not a 303. A `fetch` follows a redirect before htmx sees the
48 /// headers, so the client would swap the destination's body into whatever
49 /// element the control targeted, which is a fragment-shaped swap of a whole
50 /// screen. The status stays 200 with an empty body, and this header is the
51 /// whole answer.
52 pub const LOCATION: &str = "HX-Location";
53
54 /// The header handing the user to something that is not this app.
55 ///
56 /// Set from [`Outcome::Goto`](quasi_router::Outcome::Goto) with a
57 /// [`Destination::External`](quasi_router::Destination::External). htmx assigns
58 /// `window.location`, which is a real navigation and the only thing that can
59 /// reach a `file://` or a `mailto:`.
60 ///
61 /// The split from [`LOCATION`] is the difference between going somewhere this
62 /// router answers and leaving. Only the first can be a swap.
63 pub const REDIRECT: &str = "HX-Redirect";
64
65 /// The header htmx sets on every request it makes.
66 ///
67 /// Read to tell an XHR from a navigation, which is the one fact about the
68 /// envelope that changes what an answer may be: a navigation can be handed any
69 /// bytes at all, and htmx reads a response as a UTF-8 string. See
70 /// [`is_text`](crate::respond) -- the file arm of `respond` is the only place
71 /// this is consulted.
72 ///
73 /// Presence is the whole test. htmx sets it to `true` and nothing sets it to
74 /// anything else, and a client that lies about it gets a refusal it could have
75 /// had by not lying.
76 pub const REQUEST: &str = "HX-Request";
77
78 /// The header carrying a client-side event, used here for a notice.
79 ///
80 /// Set from [`Response::notice`](quasi_router::Response::notice). The value is
81 /// JSON naming one event, `quasi:notice`, whose detail is the kind, tone and
82 /// text. A client listens once and shows the message however that host shows
83 /// messages.
84 ///
85 /// A header rather than markup in the body, because a notice is orthogonal to
86 /// the outcome: it has to survive a redirect, which has no body at all, and it
87 /// must not be mistaken for the content of the region being replaced.
88 pub const TRIGGER: &str = "HX-Trigger";
89
90 /// The event name [`TRIGGER`] carries.
91 pub const NOTICE_EVENT: &str = "quasi:notice";
92
93 /// The header saying this answer is a new place in history.
94 ///
95 /// Derived rather than described. A `GET` of a
96 /// [`Destination::Route`](quasi_router::Destination::Route) answering with a
97 /// whole screen is a place, and that is the whole of the common case: the
98 /// address is the request's own, so nothing has to be computed and no control
99 /// has to predict what its answer will be.
100 ///
101 /// [`Address`](quasi_router::Address) is the override, for the answers the
102 /// derivation cannot reach. A fragment that is a place says
103 /// `.at(url)`; a screen that is not says `.in_place()`.
104 ///
105 /// Never emitted as markup. `hx-push-url` on a control is the same fact decided
106 /// a step too early, by the party that does not know it yet.
107 ///
108 /// htmx 4 keeps no snapshot in `localStorage`, so going back re-requests the
109 /// address rather than restoring a cached body. Nothing here has to change for
110 /// that -- the address was always the whole of what is pushed -- but a route
111 /// answering one of these is answering it again on every back, which is a
112 /// reason to keep such an answer cheap rather than a reason to push less.
113 pub const PUSH_URL: &str = "HX-Push-Url";
114
115 /// The header saying this answer is a place that takes the current slot.
116 ///
117 /// [`PUSH_URL`]'s sibling, from
118 /// [`Address::Replaces`](quasi_router::Address::Replaces). The address moves
119 /// and history does not grow, which is what a filter over a list wants: the
120 /// back button should leave the list, not walk back through the filters.
121 pub const REPLACE_URL: &str = "HX-Replace-Url";
122
123 /// The header naming how a response is put in place.
124 ///
125 /// Not set by this adapter. How a swap happens is the renderer's business and
126 /// travels with the element, and a host overriding it per response is how two
127 /// parties end up deciding one thing.
128 pub const RESWAP: &str = "HX-Reswap";
129
130 /// The [`TRIGGER`] value for one notice, as JSON.
131 ///
132 /// Hand-built rather than through a serialiser, because every part but the text
133 /// and the undo's address is a fixed literal chosen here. That makes [`escape`]
134 /// the whole of the correctness argument, and it is tested directly.
135 ///
136 /// # The undo, and why it is a route rather than an action
137 ///
138 /// [`Message::undo`] is a way back offered alongside the sentence, and this is
139 /// the one path that carries a message without building a node out of it -- a
140 /// client listens for the event and draws the message however that host draws
141 /// messages, so it is the client that puts a control on it.
142 ///
143 /// `undo` is absent when there is none, so a client written before this reads
144 /// the same object it always read. What it carries is the label and an address
145 /// to `POST`, which is what an undo is: `Action::params` and `Action::carried`
146 /// are not sent, because nothing in the tree raises an undo that has any and a
147 /// half-serialised action is worse than an omitted one. A route that needs
148 /// values in its way back says so with a screen rather than a notice.
149 ///
150 /// An [external destination](quasi_router::Destination::External) is dropped
151 /// for the same reason: an undo is this router's own route, and handing a
152 /// client somewhere else to send a write is not what the member means.
153 ///
154 /// [`Message::undo`]: quasi_router::Message::undo
155 #[must_use]
156 pub fn notice_trigger(
157 kind: layout::Notice,
158 tone: layout::Tone,
159 text: &str,
160 undo: Option<&quasi_router::Action>,
161 ) -> String {
162 let kind = match kind {
163 layout::Notice::Toast => "toast",
164 layout::Notice::Banner => "banner",
165 };
166 let tone = match tone {
167 layout::Tone::Neutral => "neutral",
168 layout::Tone::Info => "info",
169 layout::Tone::Success => "success",
170 layout::Tone::Warning => "warning",
171 layout::Tone::Danger => "danger",
172 };
173 let undo = undo
174 .and_then(|action| action.destination.route())
175 .map(|route| {
176 format!(
177 r#","undo":{{"label":"{}","route":"{}"}}"#,
178 escape(quasi_router::Message::UNDO),
179 escape(route)
180 )
181 })
182 .unwrap_or_default();
183 format!(
184 r#"{{"{NOTICE_EVENT}":{{"kind":"{kind}","tone":"{tone}","text":"{}"{undo}}}}}"#,
185 escape(text)
186 )
187 }
188
189 /// A string as a JSON string body, without the quotes.
190 ///
191 /// A header value cannot hold a control character, so the escapes that exist to
192 /// keep JSON parseable are also what keep the header legal. Anything below
193 /// space goes to `\u00XX` rather than being dropped, because dropping it would
194 /// silently change the message.
195 fn escape(text: &str) -> String {
196 let mut out = String::with_capacity(text.len());
197 for ch in text.chars() {
198 match ch {
199 '"' => out.push_str("\\\""),
200 '\\' => out.push_str("\\\\"),
201 '\n' => out.push_str("\\n"),
202 '\r' => out.push_str("\\r"),
203 '\t' => out.push_str("\\t"),
204 c if (c as u32) < 0x20 => {
205 use std::fmt::Write as _;
206 let _ = write!(out, "\\u{:04x}", c as u32);
207 }
208 c => out.push(c),
209 }
210 }
211 out
212 }
213
214 #[cfg(test)]
215 mod tests {
216 use super::*;
217
218 #[test]
219 fn a_quote_in_a_message_cannot_end_the_json_string() {
220 // The one thing an app supplies, and the reason this is not a format!
221 // with the text dropped straight in.
222 let json = notice_trigger(
223 layout::Notice::Toast,
224 layout::Tone::Success,
225 r#"Deleted "Q3 plan""#,
226 None,
227 );
228 assert!(json.contains(r#"Deleted \"Q3 plan\""#));
229 assert_eq!(json.matches(r#"","#).count(), 2);
230 }
231
232 #[test]
233 fn a_backslash_does_not_escape_the_quote_after_it() {
234 // `C:\` followed by the closing quote is the case a naive quote-only
235 // escaper turns into `C:\"`, which ends the string one character early.
236 let json = notice_trigger(layout::Notice::Toast, layout::Tone::Info, r"C:\", None);
237 assert!(json.contains(r#""text":"C:\\""#));
238 }
239
240 #[test]
241 fn a_notice_with_a_way_back_carries_it_and_one_without_says_nothing() {
242 // `bde35298`. This is the one path that hands a message over without
243 // building a node out of it, so the undo has to ride in the payload or
244 // the client has nothing to draw a control from.
245 let with = notice_trigger(
246 layout::Notice::Toast,
247 layout::Tone::Success,
248 "Deleted",
249 Some(&quasi_router::Action::post("/tasks/7/restore")),
250 );
251 assert!(
252 with.contains(r#""undo":{"label":"Undo","route":"/tasks/7/restore"}"#),
253 "{with}"
254 );
255
256 // Absent rather than null, so a client written before this reads the
257 // object it always read.
258 let without = notice_trigger(
259 layout::Notice::Toast,
260 layout::Tone::Success,
261 "Deleted",
262 None,
263 );
264 assert!(!without.contains("undo"), "{without}");
265
266 // Somewhere else is not a way back: an undo is this router's own route.
267 let outside = notice_trigger(
268 layout::Notice::Toast,
269 layout::Tone::Success,
270 "Deleted",
271 Some(&quasi_router::Action::external("https://example.com/back")),
272 );
273 assert!(!outside.contains("undo"), "{outside}");
274 }
275
276 #[test]
277 fn a_newline_cannot_reach_the_header_value() {
278 // A raw newline is both invalid JSON and an illegal header value, which
279 // is header injection if it survives.
280 let json = notice_trigger(
281 layout::Notice::Banner,
282 layout::Tone::Danger,
283 "one\r\ntwo",
284 None,
285 );
286 assert!(!json.contains('\n'));
287 assert!(!json.contains('\r'));
288 assert!(json.contains(r"one\r\ntwo"));
289 }
290
291 #[test]
292 fn a_control_character_is_kept_rather_than_dropped() {
293 let json = notice_trigger(
294 layout::Notice::Toast,
295 layout::Tone::Neutral,
296 "a\u{1}b",
297 None,
298 );
299 assert!(json.contains(r"a\u0001b"));
300 }
301
302 #[test]
303 fn every_tone_and_kind_has_a_spelling() {
304 // A new Tone member added upstream fails to compile here rather than
305 // reaching a client as a tone nothing styles.
306 for tone in [
307 layout::Tone::Neutral,
308 layout::Tone::Info,
309 layout::Tone::Success,
310 layout::Tone::Warning,
311 layout::Tone::Danger,
312 ] {
313 for kind in [layout::Notice::Toast, layout::Notice::Banner] {
314 let json = notice_trigger(kind, tone, "x", None);
315 assert!(json.starts_with(&format!(r#"{{"{NOTICE_EVENT}":"#)));
316 assert!(!json.contains(r#""""#), "{json} has an empty spelling");
317 }
318 }
319 }
320 }
321