Skip to main content

max / quasi

Add quasi-axum: the first host adapter Turns an HTTP request into a path, a verb and a bag of named values, hands them to the router, and turns the answer back into a response. - Mounts as a fallback, so quasi matches paths and axum does not. One matcher rather than two that have to agree, and real axum routes merged in front keep serving what a description has no word for. - Decodes query strings and form bodies, keeping repeats, because a checkbox group submits one name several times. Body beats query, path capture beats both. - Calls the router on a blocking thread, per decision 6. - A class becomes a status code and a notice becomes the body, from one return value, so an operator's tooling and the user's screen read the same event. A panicking handler is a 500 that reads as ours. - A fragment's region becomes HX-Retarget. It emits no markup. Render is a parameter because the Tauri protocol adapter serves HTML to a webview too, and generating it here would guarantee a second implementation; whole-screen markup is makeover-webview phase B. The htmx module carries the response-handling gap decision 9 opened: htmx 2's default does not swap a 4xx, so a classified error would render nothing. RESPONSE_HANDLING and CONFIG_META are the fix, checked against each other by a test.
Author: Max Johnson <me@maxj.phd> · 2026-08-08 22:08 UTC
Signed with PGP, not checked
Commit: 38fd4c810989e3746ffedaba49004e10bb3079ea
Parent: 4b8d518
8 files changed, +1214 insertions, -14 deletions
M Cargo.lock +470 -9
@@ -2,16 +2,288 @@
2 2 # It is not intended for manual editing.
3 3 version = 4
4 4
5 + [[package]]
6 + name = "atomic-waker"
7 + version = "1.1.2"
8 + source = "registry+https://github.com/rust-lang/crates.io-index"
9 + checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
10 +
11 + [[package]]
12 + name = "axum"
13 + version = "0.8.9"
14 + source = "registry+https://github.com/rust-lang/crates.io-index"
15 + checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
16 + dependencies = [
17 + "axum-core",
18 + "bytes",
19 + "form_urlencoded",
20 + "futures-util",
21 + "http",
22 + "http-body",
23 + "http-body-util",
24 + "hyper",
25 + "hyper-util",
26 + "itoa",
27 + "matchit",
28 + "memchr",
29 + "mime",
30 + "percent-encoding",
31 + "pin-project-lite",
32 + "serde_core",
33 + "serde_json",
34 + "serde_path_to_error",
35 + "serde_urlencoded",
36 + "sync_wrapper",
37 + "tokio",
38 + "tower",
39 + "tower-layer",
40 + "tower-service",
41 + "tracing",
42 + ]
43 +
44 + [[package]]
45 + name = "axum-core"
46 + version = "0.5.6"
47 + source = "registry+https://github.com/rust-lang/crates.io-index"
48 + checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
49 + dependencies = [
50 + "bytes",
51 + "futures-core",
52 + "http",
53 + "http-body",
54 + "http-body-util",
55 + "mime",
56 + "pin-project-lite",
57 + "sync_wrapper",
58 + "tower-layer",
59 + "tower-service",
60 + "tracing",
61 + ]
62 +
63 + [[package]]
64 + name = "bytes"
65 + version = "1.12.1"
66 + source = "registry+https://github.com/rust-lang/crates.io-index"
67 + checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
68 +
69 + [[package]]
70 + name = "form_urlencoded"
71 + version = "1.2.2"
72 + source = "registry+https://github.com/rust-lang/crates.io-index"
73 + checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
74 + dependencies = [
75 + "percent-encoding",
76 + ]
77 +
78 + [[package]]
79 + name = "futures-channel"
80 + version = "0.3.33"
81 + source = "registry+https://github.com/rust-lang/crates.io-index"
82 + checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
83 + dependencies = [
84 + "futures-core",
85 + ]
86 +
87 + [[package]]
88 + name = "futures-core"
89 + version = "0.3.33"
90 + source = "registry+https://github.com/rust-lang/crates.io-index"
91 + checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
92 +
93 + [[package]]
94 + name = "futures-task"
95 + version = "0.3.33"
96 + source = "registry+https://github.com/rust-lang/crates.io-index"
97 + checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
98 +
99 + [[package]]
100 + name = "futures-util"
101 + version = "0.3.33"
102 + source = "registry+https://github.com/rust-lang/crates.io-index"
103 + checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
104 + dependencies = [
105 + "futures-core",
106 + "futures-task",
107 + "pin-project-lite",
108 + "slab",
109 + ]
110 +
111 + [[package]]
112 + name = "http"
113 + version = "1.5.0"
114 + source = "registry+https://github.com/rust-lang/crates.io-index"
115 + checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
116 + dependencies = [
117 + "bytes",
118 + "itoa",
119 + ]
120 +
121 + [[package]]
122 + name = "http-body"
123 + version = "1.1.0"
124 + source = "registry+https://github.com/rust-lang/crates.io-index"
125 + checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
126 + dependencies = [
127 + "bytes",
128 + "http",
129 + ]
130 +
131 + [[package]]
132 + name = "http-body-util"
133 + version = "0.1.4"
134 + source = "registry+https://github.com/rust-lang/crates.io-index"
135 + checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
136 + dependencies = [
137 + "bytes",
138 + "futures-core",
139 + "http",
140 + "http-body",
141 + "pin-project-lite",
142 + ]
143 +
144 + [[package]]
145 + name = "httparse"
146 + version = "1.10.1"
147 + source = "registry+https://github.com/rust-lang/crates.io-index"
148 + checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
149 +
150 + [[package]]
151 + name = "httpdate"
152 + version = "1.0.3"
153 + source = "registry+https://github.com/rust-lang/crates.io-index"
154 + checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
155 +
156 + [[package]]
157 + name = "hyper"
158 + version = "1.11.0"
159 + source = "registry+https://github.com/rust-lang/crates.io-index"
160 + checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
161 + dependencies = [
162 + "atomic-waker",
163 + "bytes",
164 + "futures-channel",
165 + "futures-core",
166 + "http",
167 + "http-body",
168 + "httparse",
169 + "httpdate",
170 + "itoa",
171 + "pin-project-lite",
172 + "smallvec",
173 + "tokio",
174 + ]
175 +
176 + [[package]]
177 + name = "hyper-util"
178 + version = "0.1.20"
179 + source = "registry+https://github.com/rust-lang/crates.io-index"
180 + checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
181 + dependencies = [
182 + "bytes",
183 + "http",
184 + "http-body",
185 + "hyper",
186 + "pin-project-lite",
187 + "tokio",
188 + "tower-service",
189 + ]
190 +
191 + [[package]]
192 + name = "itoa"
193 + version = "1.0.18"
194 + source = "registry+https://github.com/rust-lang/crates.io-index"
195 + checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
196 +
197 + [[package]]
198 + name = "libc"
199 + version = "0.2.189"
200 + source = "registry+https://github.com/rust-lang/crates.io-index"
201 + checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
202 +
203 + [[package]]
204 + name = "log"
205 + version = "0.4.33"
206 + source = "registry+https://github.com/rust-lang/crates.io-index"
207 + checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
208 +
5 209 [[package]]
6 210 name = "makeover-layout"
7 211 version = "0.8.2"
8 212 source = "registry+https://github.com/rust-lang/crates.io-index"
9 213 checksum = "9883c75a9d26fce10be2b979c01a74f8c07513800a70ec6bf598de1e7d411f1b"
10 214
215 + [[package]]
216 + name = "matchit"
217 + version = "0.8.4"
218 + source = "registry+https://github.com/rust-lang/crates.io-index"
219 + checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
220 +
221 + [[package]]
222 + name = "memchr"
223 + version = "2.8.3"
224 + source = "registry+https://github.com/rust-lang/crates.io-index"
225 + checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
226 +
227 + [[package]]
228 + name = "mime"
229 + version = "0.3.17"
230 + source = "registry+https://github.com/rust-lang/crates.io-index"
231 + checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
232 +
233 + [[package]]
234 + name = "mio"
235 + version = "1.2.2"
236 + source = "registry+https://github.com/rust-lang/crates.io-index"
237 + checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
238 + dependencies = [
239 + "libc",
240 + "wasi",
241 + "windows-sys",
242 + ]
243 +
244 + [[package]]
245 + name = "once_cell"
246 + version = "1.21.4"
247 + source = "registry+https://github.com/rust-lang/crates.io-index"
248 + checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
249 +
250 + [[package]]
251 + name = "percent-encoding"
252 + version = "2.3.2"
253 + source = "registry+https://github.com/rust-lang/crates.io-index"
254 + checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
255 +
256 + [[package]]
257 + name = "pin-project-lite"
258 + version = "0.2.17"
259 + source = "registry+https://github.com/rust-lang/crates.io-index"
260 + checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
261 +
262 + [[package]]
263 + name = "proc-macro2"
264 + version = "1.0.107"
265 + source = "registry+https://github.com/rust-lang/crates.io-index"
266 + checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
267 + dependencies = [
268 + "unicode-ident",
269 + ]
270 +
11 271 [[package]]
12 272 name = "quasi"
13 273 version = "0.0.0"
14 274
275 + [[package]]
276 + name = "quasi-axum"
277 + version = "0.1.0"
278 + dependencies = [
279 + "axum",
280 + "form_urlencoded",
281 + "http-body-util",
282 + "quasi-router",
283 + "tokio",
284 + "tower",
285 + ]
286 +
15 287 [[package]]
16 288 name = "quasi-router"
17 289 version = "0.1.0"
@@ -19,17 +291,230 @@
19 291 "makeover-layout",
20 292 ]
21 293
22 - [[patch.unused]]
23 - name = "synckit-client"
24 - version = "0.8.0"
294 + [[package]]
295 + name = "quote"
296 + version = "1.0.47"
297 + source = "registry+https://github.com/rust-lang/crates.io-index"
298 + checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
299 + dependencies = [
300 + "proc-macro2",
301 + ]
25 302
26 - [[patch.unused]]
27 - name = "synckit-config"
28 - version = "0.2.0"
303 + [[package]]
304 + name = "ryu"
305 + version = "1.0.23"
306 + source = "registry+https://github.com/rust-lang/crates.io-index"
307 + checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
29 308
30 - [[patch.unused]]
31 - name = "docengine"
32 - version = "0.4.0"
309 + [[package]]
310 + name = "serde"
311 + version = "1.0.229"
312 + source = "registry+https://github.com/rust-lang/crates.io-index"
313 + checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
314 + dependencies = [
315 + "serde_core",
316 + ]
317 +
318 + [[package]]
319 + name = "serde_core"
320 + version = "1.0.229"
321 + source = "registry+https://github.com/rust-lang/crates.io-index"
322 + checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
323 + dependencies = [
324 + "serde_derive",
325 + ]
326 +
327 + [[package]]
328 + name = "serde_derive"
329 + version = "1.0.229"
330 + source = "registry+https://github.com/rust-lang/crates.io-index"
331 + checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
332 + dependencies = [
333 + "proc-macro2",
334 + "quote",
335 + "syn",
336 + ]
337 +
338 + [[package]]
339 + name = "serde_json"
340 + version = "1.0.151"
341 + source = "registry+https://github.com/rust-lang/crates.io-index"
342 + checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
343 + dependencies = [
344 + "itoa",
345 + "memchr",
346 + "serde",
347 + "serde_core",
348 + "zmij",
349 + ]
350 +
351 + [[package]]
352 + name = "serde_path_to_error"
353 + version = "0.1.20"
354 + source = "registry+https://github.com/rust-lang/crates.io-index"
355 + checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
356 + dependencies = [
357 + "itoa",
358 + "serde",
359 + "serde_core",
360 + ]
361 +
362 + [[package]]
363 + name = "serde_urlencoded"
364 + version = "0.7.1"
365 + source = "registry+https://github.com/rust-lang/crates.io-index"
366 + checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
367 + dependencies = [
368 + "form_urlencoded",
369 + "itoa",
370 + "ryu",
371 + "serde",
372 + ]
373 +
374 + [[package]]
375 + name = "slab"
376 + version = "0.4.12"
377 + source = "registry+https://github.com/rust-lang/crates.io-index"
378 + checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
379 +
380 + [[package]]
381 + name = "smallvec"
382 + version = "1.15.2"
383 + source = "registry+https://github.com/rust-lang/crates.io-index"
384 + checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
385 +
386 + [[package]]
387 + name = "socket2"
388 + version = "0.6.5"
389 + source = "registry+https://github.com/rust-lang/crates.io-index"
390 + checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
391 + dependencies = [
392 + "libc",
393 + "windows-sys",
394 + ]
395 +
396 + [[package]]
397 + name = "syn"
398 + version = "3.0.3"
399 + source = "registry+https://github.com/rust-lang/crates.io-index"
400 + checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
401 + dependencies = [
402 + "proc-macro2",
403 + "quote",
404 + "unicode-ident",
405 + ]
406 +
407 + [[package]]
408 + name = "sync_wrapper"
409 + version = "1.0.2"
410 + source = "registry+https://github.com/rust-lang/crates.io-index"
411 + checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
412 +
413 + [[package]]
414 + name = "tokio"
415 + version = "1.53.1"
416 + source = "registry+https://github.com/rust-lang/crates.io-index"
417 + checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
418 + dependencies = [
419 + "libc",
420 + "mio",
421 + "pin-project-lite",
422 + "socket2",
423 + "tokio-macros",
424 + "windows-sys",
425 + ]
426 +
427 + [[package]]
428 + name = "tokio-macros"
429 + version = "2.7.2"
430 + source = "registry+https://github.com/rust-lang/crates.io-index"
431 + checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
432 + dependencies = [
433 + "proc-macro2",
434 + "quote",
435 + "syn",
436 + ]
437 +
438 + [[package]]
439 + name = "tower"
440 + version = "0.5.3"
441 + source = "registry+https://github.com/rust-lang/crates.io-index"
442 + checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
443 + dependencies = [
444 + "futures-core",
445 + "futures-util",
446 + "pin-project-lite",
447 + "sync_wrapper",
448 + "tokio",
449 + "tower-layer",
450 + "tower-service",
451 + "tracing",
452 + ]
453 +
454 + [[package]]
455 + name = "tower-layer"
456 + version = "0.3.3"
457 + source = "registry+https://github.com/rust-lang/crates.io-index"
458 + checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
459 +
460 + [[package]]
461 + name = "tower-service"
462 + version = "0.3.3"
463 + source = "registry+https://github.com/rust-lang/crates.io-index"
464 + checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
465 +
466 + [[package]]
467 + name = "tracing"
468 + version = "0.1.44"
469 + source = "registry+https://github.com/rust-lang/crates.io-index"
470 + checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
471 + dependencies = [
472 + "log",
473 + "pin-project-lite",
474 + "tracing-core",
475 + ]
476 +
477 + [[package]]
478 + name = "tracing-core"
479 + version = "0.1.36"
480 + source = "registry+https://github.com/rust-lang/crates.io-index"
481 + checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
482 + dependencies = [
483 + "once_cell",
484 + ]
485 +
486 + [[package]]
487 + name = "unicode-ident"
488 + version = "1.0.24"
489 + source = "registry+https://github.com/rust-lang/crates.io-index"
490 + checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
491 +
492 + [[package]]
493 + name = "wasi"
Lines truncated
M Cargo.toml +1
@@ -2,6 +2,7 @@
2 2 resolver = "3"
3 3 members = [
4 4 "crates/quasi",
5 + "crates/quasi-axum",
5 6 "crates/quasi-router",
6 7 ]
7 8
M README.md +18 -5
@@ -57,6 +57,7 @@
57 57 ## Layout
58 58
59 59 - `crates/quasi-router/` — the host-agnostic router. The keystone.
60 + - `crates/quasi-axum/` — the axum host adapter.
60 61 - `crates/quasi/` — the scaffolder binary.
61 62
62 63 Crates are added when their component starts, not up front.
@@ -72,11 +73,23 @@
72 73 because the two renderers shipping first call it inside a frame and an event
73 74 loop.
74 75
75 - Not yet written: the host adapters. An axum route and a Tauri custom-protocol
76 - handler are each a thin layer over `Router::handle`, and the protocol form has
77 - to be measured across webkit2gtk, WKWebView and WebView2 before the second one
78 - can be designed. The scaffolder follows the router once its shape is proven
79 - against two hosts, which is why it is still a stub rather than half-written.
76 + `quasi-axum` is the first host adapter, and it is thin on purpose. It decodes
77 + query strings and form bodies, calls the router on a blocking thread, turns an
78 + error's class into a status code, and turns a fragment's region into an
79 + `HX-Retarget` header. It mounts as a fallback, so ordinary axum routes merged in
80 + front of it keep serving the things a description has no word for: static
81 + assets, health, file uploads.
82 +
83 + It emits no markup. A `Render` implementation supplies that, because the Tauri
84 + custom-protocol adapter serves HTML to a webview too, and generating it inside
85 + one host adapter would guarantee a second copy. Whole-screen markup is
86 + `makeover-webview` phase B and is not written yet, so an app brings its own
87 + renderer until it is.
88 +
89 + Not yet written: the Tauri custom-protocol adapter, which is blocked on
90 + measuring the URL form across webkit2gtk, WKWebView and WebView2. The scaffolder
91 + follows the router once its shape is proven against two hosts, which is why it
92 + is still a stub rather than half-written.
80 93
81 94 Design and sequencing live in the wiki note `quasi-overview`; the backlog is in
82 95 GoingsOn under project `quasicoherent`.
@@ -1,0 +1,24 @@
1 + [package]
2 + name = "quasi-axum"
3 + version = "0.1.0"
4 + description = "The axum host adapter for quasi-router: an HTTP request in, a rendered description out"
5 + edition.workspace = true
6 + rust-version.workspace = true
7 + authors.workspace = true
8 + repository.workspace = true
9 + license.workspace = true
10 + publish = false
11 +
12 + [lints]
13 + workspace = true
14 +
15 + [dependencies]
16 + quasi-router = { path = "../quasi-router", version = "0.1.0" }
17 + axum = "0.8.8"
18 + tokio = { version = "1.50.0", features = ["rt"] }
19 + form_urlencoded = "1.2.2"
20 +
21 + [dev-dependencies]
22 + tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread"] }
23 + tower = { version = "0.5.3", features = ["util"] }
24 + http-body-util = "0.1.3"
@@ -1,0 +1,73 @@
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 belongs to the webview transport rather than to axum specifically, so
9 + //! it moves to a shared webview layer when the Tauri custom-protocol adapter
10 + //! arrives and becomes the second caller. It is here now because there is one.
11 +
12 + /// The header naming the element a response replaces.
13 + ///
14 + /// Set from [`Response::Fragment`](quasi_router::Response::Fragment), because
15 + /// the router is the only party that knows what it just changed. htmx wants a
16 + /// CSS selector, so a [`Slot::id`](quasi_router::Slot::id) of `detail` is sent
17 + /// as `#detail`, and the webview renderer owes every slot a matching `id`.
18 + pub const RETARGET: &str = "HX-Retarget";
19 +
20 + /// The header naming how a response is put in place.
21 + ///
22 + /// Not set by this adapter. How a swap happens is the renderer's business and
23 + /// travels with the element, and a host overriding it per response is how two
24 + /// parties end up deciding one thing.
25 + pub const RESWAP: &str = "HX-Reswap";
26 +
27 + /// The client configuration a classified error needs, as JSON.
28 + ///
29 + /// htmx 2's default `responseHandling` does not swap a 4xx:
30 + ///
31 + /// ```json
32 + /// [{"code":"204","swap":false},
33 + /// {"code":"[23]..","swap":true},
34 + /// {"code":"[45]..","swap":false,"error":true}]
35 + /// ```
36 + ///
37 + /// Decision 9 has this adapter answer 403 for a denial and 404 for a missing
38 + /// thing, so under the default the user sees **nothing at all** where a banner
39 + /// was meant to be. That is not an optional refinement of the webview adapter,
40 + /// it is a required piece of it.
41 + ///
42 + /// This value keeps `error: true`, so failures stay failures for
43 + /// `htmx:responseError` handlers and for anything counting them, and turns the
44 + /// swap back on so the notice reaches the screen. A 204 still swaps nothing,
45 + /// which is what "no content" means.
46 + pub const RESPONSE_HANDLING: &str = r#"[{"code":"204","swap":false},{"code":"[23]..","swap":true},{"code":"[45]..","swap":true,"error":true}]"#;
47 +
48 + /// The same configuration as the meta tag htmx reads at load.
49 + ///
50 + /// Emit this in the document head. A tag rather than a script keeps it working
51 + /// under a `script-src` with no `unsafe-inline`, which MNW enforces.
52 + pub const CONFIG_META: &str = concat!(
53 + r#"<meta name="htmx-config" content='{"responseHandling":"#,
54 + r#"[{"code":"204","swap":false},{"code":"[23]..","swap":true},{"code":"[45]..","swap":true,"error":true}]"#,
55 + r#"}'>"#
56 + );
57 +
58 + #[cfg(test)]
59 + mod tests {
60 + use super::*;
61 +
62 + #[test]
63 + fn the_meta_tag_carries_the_same_policy_as_the_json() {
64 + // Two spellings of one fact, so they are checked against each other
65 + // rather than kept in step by hand.
66 + assert!(CONFIG_META.contains(RESPONSE_HANDLING));
67 + }
68 +
69 + #[test]
70 + fn a_4xx_swaps_and_stays_an_error() {
71 + assert!(RESPONSE_HANDLING.contains(r#"{"code":"[45]..","swap":true,"error":true}"#));
72 + }
73 + }
@@ -1,0 +1,326 @@
1 + //! The axum host adapter for [`quasi_router`].
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! A host adapter is thin by design. This one turns an HTTP request into a
6 + //! path, a verb and a bag of named values, hands them to the router, and turns
7 + //! the answer back into a response. Everything it knows about the app is in the
8 + //! router; everything it knows about markup is in the [`Render`] it was given.
9 + //!
10 + //! Hosted web was described as free rather than aspirational, and this is what
11 + //! that meant: a router returning descriptions plus a renderer emitting HTML is
12 + //! already the shape MNW's server has, so a hosted app is a second adapter and
13 + //! not a port.
14 + //!
15 + //! # What it owns
16 + //!
17 + //! - Decoding. Query strings and form bodies arrive as [`Params`], because
18 + //! [`quasi_router`] deliberately does not parse either.
19 + //! - The blocking hop. The router is sync per decision 6, so the call happens
20 + //! on a blocking thread, which is what an axum handler over a blocking store
21 + //! pays anyway.
22 + //! - Status codes. A [`RouteError`]'s class becomes one, so a cache, a log and
23 + //! a monitor can all tell a denied action from a completed one.
24 + //! - Retargeting. A [`Response::Fragment`] names the region it replaces, and
25 + //! that becomes an `HX-Retarget` header.
26 + //!
27 + //! # What it does not own
28 + //!
29 + //! Markup. See [`Render`], and the reason it is a parameter: the Tauri
30 + //! custom-protocol adapter serves HTML to a webview too, and generating it here
31 + //! would guarantee a second implementation.
32 + //!
33 + //! # Mounting it
34 + //!
35 + //! quasi matches paths itself, so the adapter mounts as a fallback rather than
36 + //! registering each route with axum. One matcher, not two agreeing ones. Real
37 + //! axum routes merged in front of it keep working, which is how static files,
38 + //! a health endpoint and anything else outside the description layer are
39 + //! served.
40 + //!
41 + //! ```no_run
42 + //! use std::sync::Arc;
43 + //! use quasi_axum::{Adapter, Render};
44 + //! use quasi_router::{Node, Params, Response, RouteError, Router, Screen, Slot, RegionKind};
45 + //!
46 + //! struct App;
47 + //! struct Html;
48 + //!
49 + //! impl Render for Html {
50 + //! fn screen(&self, screen: &Screen) -> String { format!("<h1>{}</h1>", screen.title) }
51 + //! fn fragment(&self, _node: &Node) -> String { String::new() }
52 + //! }
53 + //!
54 + //! fn home(_app: &App, _params: Params) -> Result<Response, RouteError> {
55 + //! Ok(Screen::sidebar_content("Home")
56 + //! .with(Slot::new("content", RegionKind::Pane))
57 + //! .into())
58 + //! }
59 + //!
60 + //! # async fn run() {
61 + //! let quasi = Router::<App>::new().get("/", home);
62 + //! let app = axum::Router::new()
63 + //! .merge(Adapter::new(quasi, Arc::new(App), Arc::new(Html)).into_router());
64 + //!
65 + //! let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
66 + //! axum::serve(listener, app).await.unwrap();
67 + //! # }
68 + //! ```
69 +
70 + use std::sync::Arc;
71 +
72 + use axum::body::{Body, Bytes};
73 + use axum::extract::Request;
74 + use axum::http::{HeaderValue, Method as HttpMethod, StatusCode, header};
75 + use axum::response::Response as HttpResponse;
76 + use quasi_router::{Method, Node, Params, Response, RouteError, Router};
77 +
78 + pub mod htmx;
79 + pub mod render;
80 +
81 + pub use crate::render::Render;
82 +
83 + /// How much of a form body is read before the request is refused.
84 + ///
85 + /// A description-layer form is fields and choices, so a request an order of
86 + /// magnitude past this is a mistake or an attack rather than a long answer.
87 + /// File uploads do not come through here: they are their own axum route, since
88 + /// a byte stream is not something a description describes.
89 + pub const DEFAULT_BODY_LIMIT: usize = 256 * 1024;
90 +
91 + /// The router, the app's state and a renderer, mounted as an axum service.
92 + pub struct Adapter<S, R> {
93 + router: Router<S>,
94 + state: Arc<S>,
95 + render: Arc<R>,
96 + body_limit: usize,
97 + }
98 +
99 + /// What a request needs, once, behind one `Arc`.
100 + struct Context<S, R> {
101 + router: Router<S>,
102 + state: Arc<S>,
103 + render: Arc<R>,
104 + body_limit: usize,
105 + }
106 +
107 + impl<S, R> Adapter<S, R>
108 + where
109 + S: Send + Sync + 'static,
110 + R: Render,
111 + {
112 + /// Mount a router over this state, rendered by this renderer.
113 + #[must_use]
114 + pub fn new(router: Router<S>, state: Arc<S>, render: Arc<R>) -> Self {
115 + Self {
116 + router,
117 + state,
118 + render,
119 + body_limit: DEFAULT_BODY_LIMIT,
120 + }
121 + }
122 +
123 + /// Read at most this many bytes of a form body.
124 + #[must_use]
125 + pub fn body_limit(mut self, bytes: usize) -> Self {
126 + self.body_limit = bytes;
127 + self
128 + }
129 +
130 + /// An axum router serving every path through quasi.
131 + ///
132 + /// A fallback, so anything merged in front of it wins. That is deliberate:
133 + /// a health endpoint, static assets and a file upload are all things that
134 + /// are not descriptions, and they should stay ordinary axum routes rather
135 + /// than being forced through a description layer that has no word for them.
136 + pub fn into_router(self) -> axum::Router {
137 + let context = Arc::new(Context {
138 + router: self.router,
139 + state: self.state,
140 + render: self.render,
141 + body_limit: self.body_limit,
142 + });
143 +
144 + axum::Router::new().fallback(move |request: Request| {
145 + let context = Arc::clone(&context);
146 + async move { serve(context, request).await }
147 + })
148 + }
149 + }
150 +
151 + /// Answer one request.
152 + async fn serve<S, R>(context: Arc<Context<S, R>>, request: Request) -> HttpResponse
153 + where
154 + S: Send + Sync + 'static,
155 + R: Render,
156 + {
157 + let (parts, body) = request.into_parts();
158 +
159 + let Some(method) = translate(&parts.method) else {
160 + // quasi has two verbs, so this is the adapter's own refusal rather than
161 + // a route that is missing. `Allow` is required on a 405 and is the
162 + // difference between a client that can correct itself and one that
163 + // retries the same thing.
164 + return bare(
165 + StatusCode::METHOD_NOT_ALLOWED,
166 + [(header::ALLOW, "GET, POST")],
167 + );
168 + };
169 +
170 + let path = parts.uri.path().to_owned();
171 +
172 + // The body first, so a form field beats a query argument of the same name.
173 + // A form is the answer to the question the screen asked; a query argument
174 + // on a POST is context that came along with it.
175 + let mut params = Params::new();
176 + if method.mutates() && is_form(&parts.headers) {
177 + match read_form(body, context.body_limit).await {
178 + Ok(form) => params.absorb(form),
179 + Err(status) => return bare(status, []),
180 + }
181 + }
182 + params.absorb(decode(parts.uri.query().unwrap_or_default()));
183 +
184 + // Decision 6: the router is sync. Calling it directly would block the
185 + // executor for however long the store takes.
186 + let dispatch = {
187 + let context = Arc::clone(&context);
188 + tokio::task::spawn_blocking(move || {
189 + context.router.handle(&context.state, method, &path, params)
190 + })
191 + .await
192 + };
193 +
194 + match dispatch {
195 + Ok(Ok(response)) => render(&*context.render, response),
196 + Ok(Err(error)) => render_error(&*context.render, &error),
197 + // A panic in a handler. Reported as ours, because it is.
198 + Err(_) => render_error(
199 + &*context.render,
200 + &RouteError::internal("the request could not be completed"),
201 + ),
202 + }
203 + }
204 +
205 + /// Turn an answer into a response.
206 + fn render<R: Render>(renderer: &R, response: Response) -> HttpResponse {
207 + match response {
208 + Response::Screen(screen) => html(
209 + renderer,
210 + StatusCode::OK,
211 + renderer.screen(&screen),
212 + Vec::new(),
213 + ),
214 + Response::Fragment { region, node } => {
215 + // The router said what it changed, so the client is told rather
216 + // than left to infer it from which element was clicked. The webview
217 + // renderer owes every slot an `id` matching its
218 + // `Slot::id` for this to land.
219 + let target = HeaderValue::try_from(format!("#{region}")).ok();
220 + let headers = target
221 + .map(|value| vec![(htmx::RETARGET, value)])
222 + .unwrap_or_default();
223 + html(renderer, StatusCode::OK, renderer.fragment(&node), headers)
224 + }
225 + }
226 + }
227 +
228 + /// Turn a failure into a response.
229 + ///
230 + /// The status comes from the class and the body from the notice, so an
231 + /// operator's tooling and the user's screen are reading the same event.
232 + ///
233 + /// The body is sent, and it is worth knowing that htmx will drop it unless the
234 + /// page configures [`htmx::RESPONSE_HANDLING`]: htmx 2's default does not swap
235 + /// a 4xx, so a classified error renders nothing at all.
236 + fn render_error<R: Render>(renderer: &R, error: &RouteError) -> HttpResponse {
237 + let node = Node::Notice {
238 + kind: error.notice,
239 + tone: error.tone(),
240 + text: error.message.clone(),
241 + };
242 + let status = StatusCode::from_u16(error.class.http_status())
243 + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
244 + html(renderer, status, renderer.fragment(&node), Vec::new())
245 + }
246 +
247 + /// A rendered body, with the renderer's own content type.
248 + fn html<R: Render>(
249 + renderer: &R,
250 + status: StatusCode,
251 + body: String,
252 + extra: Vec<(&'static str, HeaderValue)>,
253 + ) -> HttpResponse {
254 + let mut builder = HttpResponse::builder()
255 + .status(status)
256 + .header(header::CONTENT_TYPE, renderer.content_type());
257 + for (name, value) in extra {
258 + builder = builder.header(name, value);
259 + }
260 + builder
261 + .body(Body::from(body))
262 + .unwrap_or_else(|_| bare(StatusCode::INTERNAL_SERVER_ERROR, []))
263 + }
264 +
265 + /// A response with no body.
266 + fn bare<'a>(
267 + status: StatusCode,
268 + headers: impl IntoIterator<Item = (header::HeaderName, &'a str)>,
269 + ) -> HttpResponse {
270 + let mut builder = HttpResponse::builder().status(status);
271 + for (name, value) in headers {
272 + builder = builder.header(name, value);
273 + }
274 + builder
275 + .body(Body::empty())
276 + .expect("a response with no body and static headers is always valid")
277 + }
278 +
279 + /// The two verbs the description layer has, and nothing else.
280 + fn translate(method: &HttpMethod) -> Option<Method> {
281 + match *method {
282 + HttpMethod::GET => Some(Method::Get),
283 + HttpMethod::POST => Some(Method::Post),
284 + _ => None,
285 + }
286 + }
287 +
288 + /// Whether the body is a form this adapter reads.
289 + ///
290 + /// `multipart/form-data` is deliberately not read here. A file is a byte
291 + /// stream, a description has no word for one, and buffering an upload into
292 + /// [`Params`] would be the wrong shape at any size.
293 + fn is_form(headers: &axum::http::HeaderMap) -> bool {
294 + headers
295 + .get(header::CONTENT_TYPE)
296 + .and_then(|value| value.to_str().ok())
297 + .is_some_and(|value| {
298 + value.split(';').next().is_some_and(|kind| {
299 + kind.trim()
300 + .eq_ignore_ascii_case("application/x-www-form-urlencoded")
301 + })
302 + })
303 + }
304 +
305 + /// Read and decode a form body.
306 + async fn read_form(body: Body, limit: usize) -> Result<Params, StatusCode> {
307 + let bytes: Bytes = axum::body::to_bytes(body, limit)
308 + .await
309 + .map_err(|_| StatusCode::PAYLOAD_TOO_LARGE)?;
310 + let text = std::str::from_utf8(&bytes).map_err(|_| StatusCode::BAD_REQUEST)?;
311 + Ok(decode(text))
312 + }
313 +
314 + /// Percent-decoded name and value pairs, repeats kept.
315 + ///
316 + /// Repeats are the point: a checkbox group submits one name several times, and
317 + /// a decoder that keeps the last is a bug that only shows on the screen with
318 + /// the multi-select on it.
319 + fn decode(encoded: &str) -> Params {
320 + form_urlencoded::parse(encoded.as_bytes())
321 + .map(|(name, value)| (name.into_owned(), value.into_owned()))
322 + .collect()
323 + }
324 +
325 + #[cfg(test)]
326 + mod tests;
@@ -1,0 +1,45 @@
1 + //! The seam between a description and bytes.
2 + //!
3 + //! This crate does not emit markup, and that is the important decision in it.
4 + //!
5 + //! A hosted axum route and a Tauri custom-protocol handler both serve HTML to a
6 + //! webview. If the HTML were generated here, the protocol adapter would need a
7 + //! second copy of it, and two copies of a markup emitter is the divergence the
8 + //! whole stack exists to end. So markup belongs to the webview renderer, which
9 + //! is `makeover-webview`, and this crate takes it as a parameter.
10 + //!
11 + //! Today `makeover-webview` emits a stylesheet, form fields and table cells,
12 + //! which is phase A. Whole-screen markup is phase B and is not written, so an
13 + //! app supplies its own [`Render`] in the meantime. When phase B lands, the
14 + //! implementation moves there and every host adapter picks it up at once
15 + //! instead of one at a time.
16 +
17 + use quasi_router::{Node, Screen};
18 +
19 + /// Turns a description into the bytes a browser gets.
20 + ///
21 + /// Two methods, because [`Response`](quasi_router::Response) has two members
22 + /// and a renderer genuinely does different work for each: a screen is a
23 + /// document and a fragment is the inside of one element.
24 + ///
25 + /// Errors are not a third method. The adapter turns a
26 + /// [`RouteError`](quasi_router::RouteError) into a [`Node::Notice`] and asks
27 + /// for a fragment, so failure becomes UI through the same path as everything
28 + /// else. That is decision 9 holding at the boundary rather than only in the
29 + /// router.
30 + pub trait Render: Send + Sync + 'static {
31 + /// A whole document.
32 + fn screen(&self, screen: &Screen) -> String;
33 +
34 + /// The inside of one region.
35 + fn fragment(&self, node: &Node) -> String;
36 +
37 + /// What the document says it is.
38 + ///
39 + /// Overridable for a renderer answering something other than HTML, which is
40 + /// the seam a JSON client or a test harness needs. Defaults to HTML because
41 + /// that is what every webview host wants.
42 + fn content_type(&self) -> &'static str {
43 + "text/html; charset=utf-8"
44 + }
45 + }
@@ -1,0 +1,257 @@
1 + //! Real requests through the mounted service.
2 + //!
3 + //! Everything here goes in as an `http::Request` and comes out as an
4 + //! `http::Response`, because the things worth testing in an adapter are exactly
5 + //! the ones that only exist at that boundary: decoding, status mapping, the
6 + //! retarget header, and the blocking hop.
7 +
8 + // Handlers take their parameters by value because `Handler` says so, and one
9 + // that only reads them is the common case rather than an oversight.
10 + #![allow(clippy::needless_pass_by_value)]
11 +
12 + use std::sync::Arc;
13 +
14 + use axum::body::Body;
15 + use axum::http::{Request, StatusCode, header};
16 + use http_body_util::BodyExt;
17 + use quasi_router::{Node, Params, RegionKind, Response, RouteError, Router, Screen, Slot};
18 + use tower::ServiceExt;
19 +
20 + /// An app with nothing in it. The router is what is under test.
21 + struct App;
22 +
23 + /// A renderer that says what it was handed, so a test can read it back.
24 + struct Spy;
25 +
26 + impl super::Render for Spy {
27 + fn screen(&self, screen: &Screen) -> String {
28 + format!("screen:{}", screen.title)
29 + }
30 +
31 + fn fragment(&self, node: &Node) -> String {
32 + match node {
33 + Node::Text { text, .. } => format!("text:{text}"),
34 + Node::Notice { tone, text, .. } => format!("notice:{tone:?}:{text}"),
35 + other => format!("other:{other:?}"),
36 + }
37 + }
38 + }
39 +
40 + fn home(_app: &App, _params: Params) -> Result<Response, RouteError> {
41 + Ok(Screen::sidebar_content("Home")
42 + .with(Slot::new("content", RegionKind::Pane))
43 + .into())
44 + }
45 +
46 + fn echo(_app: &App, params: Params) -> Result<Response, RouteError> {
47 + let joined = params
48 + .iter()
49 + .map(|(k, v)| format!("{k}={v}"))
50 + .collect::<Vec<_>>()
51 + .join(",");
52 + Ok(Response::fragment("detail", Node::text(joined)))
53 + }
54 +
55 + fn tags(_app: &App, params: Params) -> Result<Response, RouteError> {
56 + let all = params.get_all("tag").collect::<Vec<_>>().join("+");
57 + Ok(Response::fragment("detail", Node::text(all)))
58 + }
59 +
60 + fn denied(_app: &App, _params: Params) -> Result<Response, RouteError> {
61 + Err(RouteError::denied("not yours"))
62 + }
63 +
64 + fn boom(_app: &App, _params: Params) -> Result<Response, RouteError> {
65 + panic!("a handler that panics");
66 + }
67 +
68 + fn service() -> axum::Router {
69 + let router = Router::<App>::new()
70 + .get("/", home)
71 + .get("/task/:id", echo)
72 + .post("/task/:id/edit", echo)
73 + .post("/tags", tags)
74 + .post("/task/:id/delete", denied)
75 + .get("/boom", boom);
76 + super::Adapter::new(router, Arc::new(App), Arc::new(Spy)).into_router()
77 + }
78 +
79 + /// Send a request, read the whole response.
80 + async fn send(request: Request<Body>) -> (StatusCode, String, Option<String>) {
81 + let response = service().oneshot(request).await.unwrap();
82 + let status = response.status();
83 + let retarget = response
84 + .headers()
85 + .get(super::htmx::RETARGET)
86 + .map(|v| v.to_str().unwrap().to_owned());
87 + let body = response.into_body().collect().await.unwrap().to_bytes();
88 + (status, String::from_utf8(body.to_vec()).unwrap(), retarget)
89 + }
90 +
91 + fn get(uri: &str) -> Request<Body> {
92 + Request::builder().uri(uri).body(Body::empty()).unwrap()
93 + }
94 +
95 + fn post_form(uri: &str, body: &'static str) -> Request<Body> {
96 + Request::builder()
97 + .method("POST")
98 + .uri(uri)
99 + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
100 + .body(Body::from(body))
101 + .unwrap()
102 + }
103 +
104 + #[tokio::test]
105 + async fn a_screen_is_served_whole_and_names_no_target() {
106 + let (status, body, retarget) = send(get("/")).await;
107 + assert_eq!(status, StatusCode::OK);
108 + assert_eq!(body, "screen:Home");
109 + assert_eq!(retarget, None);
110 + }
111 +
112 + #[tokio::test]
113 + async fn a_fragment_carries_the_region_it_replaces() {
114 + let (status, body, retarget) = send(get("/task/7")).await;
115 + assert_eq!(status, StatusCode::OK);
116 + assert_eq!(body, "text:id=7");
117 + // A slot id becomes a CSS selector, which is what htmx wants.
118 + assert_eq!(retarget.as_deref(), Some("#detail"));
119 + }
120 +
121 + #[tokio::test]
122 + async fn a_query_string_is_decoded_into_params() {
123 + let (_, body, _) = send(get("/task/7?note=a%20b&flag=1")).await;
124 + assert!(body.contains("note=a b"), "{body}");
125 + assert!(body.contains("flag=1"), "{body}");
126 + }
127 +
128 + #[tokio::test]
129 + async fn a_form_body_is_decoded_into_params() {
130 + let (status, body, _) = send(post_form("/task/7/edit", "title=new+title")).await;
131 + assert_eq!(status, StatusCode::OK);
132 + assert!(body.contains("title=new title"), "{body}");
133 + }
134 +
135 + #[tokio::test]
136 + async fn a_form_field_beats_a_query_argument_of_the_same_name() {
137 + // The form is the answer to the question the screen asked. The query
138 + // argument is context that came along with it.
139 + let (_, body, _) = send(post_form(
140 + "/task/7/edit?title=from-query",
141 + "title=from-form",
142 + ))
143 + .await;
144 + assert_eq!(first_named(&body, "title"), "title=from-form", "{body}");
145 + }
146 +
147 + /// The first entry under a name, which is the one `Params::get` answers with.
148 + fn first_named<'a>(body: &'a str, name: &str) -> &'a str {
149 + body.trim_start_matches("text:")
150 + .split(',')
151 + .find(|entry| entry.starts_with(&format!("{name}=")))
152 + .unwrap_or("")
153 + }
154 +
155 + #[tokio::test]
156 + async fn the_path_capture_beats_both() {
157 + let (_, body, _) = send(post_form("/task/7/edit?id=9", "id=8")).await;
158 + assert_eq!(first_named(&body, "id"), "id=7", "{body}");
159 + }
160 +
161 + #[tokio::test]
162 + async fn repeated_names_all_survive_decoding() {
163 + let (_, body, _) = send(post_form("/tags", "tag=rust&tag=router&tag=axum")).await;
164 + assert_eq!(body, "text:rust+router+axum");
165 + }
166 +
167 + #[tokio::test]
168 + async fn a_denial_is_a_403_with_the_notice_as_its_body() {
169 + let (status, body, _) = send(post_form("/task/7/delete", "")).await;
170 + assert_eq!(status, StatusCode::FORBIDDEN);
171 + // The class became a status code and the notice became the screen, from one
172 + // return value.
173 + assert_eq!(body, "notice:Warning:not yours");
174 + }
175 +
176 + #[tokio::test]
177 + async fn an_unknown_path_is_a_404_that_still_renders_something() {
178 + let (status, body, _) = send(get("/nowhere")).await;
179 + assert_eq!(status, StatusCode::NOT_FOUND);
180 + assert!(body.starts_with("notice:Warning:"), "{body}");
181 + }
182 +
183 + #[tokio::test]
184 + async fn a_verb_the_description_layer_lacks_is_refused_with_allow() {
185 + let request = Request::builder()
186 + .method("DELETE")
187 + .uri("/task/7")
188 + .body(Body::empty())
189 + .unwrap();
190 + let response = service().oneshot(request).await.unwrap();
191 + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
192 + assert_eq!(response.headers().get(header::ALLOW).unwrap(), "GET, POST");
193 + }
194 +
195 + #[tokio::test]
196 + async fn a_panicking_handler_is_a_500_and_reads_as_ours() {
197 + let (status, body, _) = send(get("/boom")).await;
198 + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
199 + assert!(body.starts_with("notice:Danger:"), "{body}");
200 + }
201 +
202 + #[tokio::test]
203 + async fn an_oversized_form_is_refused_before_the_router_sees_it() {
204 + let router = Router::<App>::new().post("/tags", tags);
205 + let service = super::Adapter::new(router, Arc::new(App), Arc::new(Spy))
206 + .body_limit(16)
207 + .into_router();
208 +
209 + let request = Request::builder()
210 + .method("POST")
211 + .uri("/tags")
212 + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
213 + .body(Body::from("tag=".to_owned() + &"x".repeat(1024)))
214 + .unwrap();
215 +
216 + let response = service.oneshot(request).await.unwrap();
217 + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
218 + }
219 +
220 + #[tokio::test]
221 + async fn a_body_that_is_not_a_form_is_ignored_rather_than_guessed_at() {
222 + // Multipart is not read here on purpose. The request still routes, and the
223 + // handler simply sees no parameters from the body.
224 + let request = Request::builder()
225 + .method("POST")
226 + .uri("/task/7/edit")
227 + .header(header::CONTENT_TYPE, "multipart/form-data; boundary=xyz")
228 + .body(Body::from("--xyz--"))
229 + .unwrap();
230 + let response = service().oneshot(request).await.unwrap();
231 + assert_eq!(response.status(), StatusCode::OK);
232 + }
233 +
234 + #[tokio::test]
235 + async fn a_charset_on_the_form_content_type_still_reads_as_a_form() {
236 + let request = Request::builder()
237 + .method("POST")
238 + .uri("/task/7/edit")
239 + .header(
240 + header::CONTENT_TYPE,
241 + "application/x-www-form-urlencoded; charset=utf-8",
242 + )
243 + .body(Body::from("title=ok"))
244 + .unwrap();
245 + let response = service().oneshot(request).await.unwrap();
246 + let body = response.into_body().collect().await.unwrap().to_bytes();
247 + assert!(String::from_utf8_lossy(&body).contains("title=ok"));
248 + }
249 +
250 + #[tokio::test]
251 + async fn the_content_type_is_the_renderers_answer() {
252 + let response = service().oneshot(get("/")).await.unwrap();
253 + assert_eq!(
254 + response.headers().get(header::CONTENT_TYPE).unwrap(),
255 + "text/html; charset=utf-8"
256 + );
257 + }