Skip to main content

max / quasi

10.0 KB · 247 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 //! two response headers and one client configuration.
7 //!
8 //! This belonged to the webview transport rather than to axum specifically,
9 //! and lived in `quasi-axum` while axum was the only caller. The Tauri
10 //! custom-protocol adapter is the second, so it moved here as its own doc
11 //! comment said it would.
12
13 use quasi_router::layout;
14
15 /// The header naming the element a response replaces.
16 ///
17 /// Set from [`Response::Fragment`](quasi_router::Response::Fragment), because
18 /// the router is the only party that knows what it just changed. htmx wants a
19 /// CSS selector, so a [`Slot::id`](quasi_router::Slot::id) of `detail` is sent
20 /// as `#detail`, and the webview renderer owes every slot a matching `id`.
21 pub const RETARGET: &str = "HX-Retarget";
22
23 /// The header sending the user to another route in this app.
24 ///
25 /// Set from [`Outcome::Goto`](quasi_router::Outcome::Goto) with a
26 /// [`Destination::Route`](quasi_router::Destination::Route). htmx issues the
27 /// request itself and swaps the body, so history and the back button work and
28 /// the page is not torn down.
29 ///
30 /// Deliberately not a 303. A `fetch` follows a redirect before htmx sees the
31 /// headers, so the client would swap the destination's body into whatever
32 /// element the control targeted, which is a fragment-shaped swap of a whole
33 /// screen. The status stays 200 with an empty body, and this header is the
34 /// whole answer.
35 pub const LOCATION: &str = "HX-Location";
36
37 /// The header handing the user to something that is not this app.
38 ///
39 /// Set from [`Outcome::Goto`](quasi_router::Outcome::Goto) with a
40 /// [`Destination::External`](quasi_router::Destination::External). htmx assigns
41 /// `window.location`, which is a real navigation and the only thing that can
42 /// reach a `file://` or a `mailto:`.
43 ///
44 /// The split from [`LOCATION`] is the difference between going somewhere this
45 /// router answers and leaving. Only the first can be a swap.
46 pub const REDIRECT: &str = "HX-Redirect";
47
48 /// The header carrying a client-side event, used here for a notice.
49 ///
50 /// Set from [`Response::notice`](quasi_router::Response::notice). The value is
51 /// JSON naming one event, `quasi:notice`, whose detail is the kind, tone and
52 /// text. A client listens once and shows the message however that host shows
53 /// messages.
54 ///
55 /// A header rather than markup in the body, because a notice is orthogonal to
56 /// the outcome: it has to survive a redirect, which has no body at all, and it
57 /// must not be mistaken for the content of the region being replaced.
58 pub const TRIGGER: &str = "HX-Trigger";
59
60 /// The event name [`TRIGGER`] carries.
61 pub const NOTICE_EVENT: &str = "quasi:notice";
62
63 /// The header saying this answer is a new place in history.
64 ///
65 /// Derived rather than described. A `GET` of a
66 /// [`Destination::Route`](quasi_router::Destination::Route) answering with a
67 /// whole screen is a place, and that is the whole of the common case: the
68 /// address is the request's own, so nothing has to be computed and no control
69 /// has to predict what its answer will be.
70 ///
71 /// [`Address`](quasi_router::Address) is the override, for the answers the
72 /// derivation cannot reach. A fragment that is a place says
73 /// `.at(url)`; a screen that is not says `.in_place()`.
74 ///
75 /// Never emitted as markup. `hx-push-url` on a control is the same fact decided
76 /// a step too early, by the party that does not know it yet.
77 pub const PUSH_URL: &str = "HX-Push-Url";
78
79 /// The header saying this answer is a place that takes the current slot.
80 ///
81 /// [`PUSH_URL`]'s sibling, from
82 /// [`Address::Replaces`](quasi_router::Address::Replaces). The address moves
83 /// and history does not grow, which is what a filter over a list wants: the
84 /// back button should leave the list, not walk back through the filters.
85 pub const REPLACE_URL: &str = "HX-Replace-Url";
86
87 /// The header naming how a response is put in place.
88 ///
89 /// Not set by this adapter. How a swap happens is the renderer's business and
90 /// travels with the element, and a host overriding it per response is how two
91 /// parties end up deciding one thing.
92 pub const RESWAP: &str = "HX-Reswap";
93
94 /// The client configuration a classified error needs, as JSON.
95 ///
96 /// htmx 2's default `responseHandling` does not swap a 4xx:
97 ///
98 /// ```json
99 /// [{"code":"204","swap":false},
100 /// {"code":"[23]..","swap":true},
101 /// {"code":"[45]..","swap":false,"error":true}]
102 /// ```
103 ///
104 /// Decision 9 has an adapter answer 403 for a denial and 404 for a missing
105 /// thing, so under the default the user sees **nothing at all** where a banner
106 /// was meant to be. That is not an optional refinement of the webview adapter,
107 /// it is a required piece of it.
108 ///
109 /// This value keeps `error: true`, so failures stay failures for
110 /// `htmx:responseError` handlers and for anything counting them, and turns the
111 /// swap back on so the notice reaches the screen. A 204 still swaps nothing,
112 /// which is what "no content" means.
113 pub const RESPONSE_HANDLING: &str = r#"[{"code":"204","swap":false},{"code":"[23]..","swap":true},{"code":"[45]..","swap":true,"error":true}]"#;
114
115 /// The same configuration as the meta tag htmx reads at load.
116 ///
117 /// Emit this in the document head. A tag rather than a script keeps it working
118 /// under a `script-src` with no `unsafe-inline`, which MNW enforces.
119 pub const CONFIG_META: &str = concat!(
120 r#"<meta name="htmx-config" content='{"responseHandling":"#,
121 r#"[{"code":"204","swap":false},{"code":"[23]..","swap":true},{"code":"[45]..","swap":true,"error":true}]"#,
122 r#"}'>"#
123 );
124
125 /// The [`TRIGGER`] value for one notice, as JSON.
126 ///
127 /// Hand-built rather than through a serialiser, because every part but the text
128 /// is a fixed literal chosen here and the text is the only thing an app
129 /// supplies. That makes [`escape`] the whole of the correctness argument, and it
130 /// is tested directly.
131 #[must_use]
132 pub fn notice_trigger(kind: layout::Notice, tone: layout::Tone, text: &str) -> String {
133 let kind = match kind {
134 layout::Notice::Toast => "toast",
135 layout::Notice::Banner => "banner",
136 };
137 let tone = match tone {
138 layout::Tone::Neutral => "neutral",
139 layout::Tone::Info => "info",
140 layout::Tone::Success => "success",
141 layout::Tone::Warning => "warning",
142 layout::Tone::Danger => "danger",
143 };
144 format!(
145 r#"{{"{NOTICE_EVENT}":{{"kind":"{kind}","tone":"{tone}","text":"{}"}}}}"#,
146 escape(text)
147 )
148 }
149
150 /// A string as a JSON string body, without the quotes.
151 ///
152 /// A header value cannot hold a control character, so the escapes that exist to
153 /// keep JSON parseable are also what keep the header legal. Anything below
154 /// space goes to `\u00XX` rather than being dropped, because dropping it would
155 /// silently change the message.
156 fn escape(text: &str) -> String {
157 let mut out = String::with_capacity(text.len());
158 for ch in text.chars() {
159 match ch {
160 '"' => out.push_str("\\\""),
161 '\\' => out.push_str("\\\\"),
162 '\n' => out.push_str("\\n"),
163 '\r' => out.push_str("\\r"),
164 '\t' => out.push_str("\\t"),
165 c if (c as u32) < 0x20 => {
166 use std::fmt::Write as _;
167 let _ = write!(out, "\\u{:04x}", c as u32);
168 }
169 c => out.push(c),
170 }
171 }
172 out
173 }
174
175 #[cfg(test)]
176 mod tests {
177 use super::*;
178
179 #[test]
180 fn a_quote_in_a_message_cannot_end_the_json_string() {
181 // The one thing an app supplies, and the reason this is not a format!
182 // with the text dropped straight in.
183 let json = notice_trigger(
184 layout::Notice::Toast,
185 layout::Tone::Success,
186 r#"Deleted "Q3 plan""#,
187 );
188 assert!(json.contains(r#"Deleted \"Q3 plan\""#));
189 assert_eq!(json.matches(r#"","#).count(), 2);
190 }
191
192 #[test]
193 fn a_backslash_does_not_escape_the_quote_after_it() {
194 // `C:\` followed by the closing quote is the case a naive quote-only
195 // escaper turns into `C:\"`, which ends the string one character early.
196 let json = notice_trigger(layout::Notice::Toast, layout::Tone::Info, r"C:\");
197 assert!(json.contains(r#""text":"C:\\""#));
198 }
199
200 #[test]
201 fn a_newline_cannot_reach_the_header_value() {
202 // A raw newline is both invalid JSON and an illegal header value, which
203 // is header injection if it survives.
204 let json = notice_trigger(layout::Notice::Banner, layout::Tone::Danger, "one\r\ntwo");
205 assert!(!json.contains('\n'));
206 assert!(!json.contains('\r'));
207 assert!(json.contains(r"one\r\ntwo"));
208 }
209
210 #[test]
211 fn a_control_character_is_kept_rather_than_dropped() {
212 let json = notice_trigger(layout::Notice::Toast, layout::Tone::Neutral, "a\u{1}b");
213 assert!(json.contains(r"a\u0001b"));
214 }
215
216 #[test]
217 fn every_tone_and_kind_has_a_spelling() {
218 // A new Tone member added upstream fails to compile here rather than
219 // reaching a client as a tone nothing styles.
220 for tone in [
221 layout::Tone::Neutral,
222 layout::Tone::Info,
223 layout::Tone::Success,
224 layout::Tone::Warning,
225 layout::Tone::Danger,
226 ] {
227 for kind in [layout::Notice::Toast, layout::Notice::Banner] {
228 let json = notice_trigger(kind, tone, "x");
229 assert!(json.starts_with(&format!(r#"{{"{NOTICE_EVENT}":"#)));
230 assert!(!json.contains(r#""""#), "{json} has an empty spelling");
231 }
232 }
233 }
234
235 #[test]
236 fn the_meta_tag_carries_the_same_policy_as_the_json() {
237 // Two spellings of one fact, so they are checked against each other
238 // rather than kept in step by hand.
239 assert!(CONFIG_META.contains(RESPONSE_HANDLING));
240 }
241
242 #[test]
243 fn a_4xx_swaps_and_stays_an_error() {
244 assert!(RESPONSE_HANDLING.contains(r#"{"code":"[45]..","swap":true,"error":true}"#));
245 }
246 }
247