max / quasi
8 files changed,
+1214 insertions,
-14 deletions
| @@ -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
| @@ -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 |
| @@ -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 | + | } |