max / quasi
10 files changed,
+147 insertions,
-23 deletions
| @@ -4888,6 +4888,14 @@ | |||
| 4888 | 4888 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4889 | 4889 | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" | |
| 4890 | 4890 | ||
| 4891 | + | [[patch.unused]] | |
| 4892 | + | name = "synckit-client" | |
| 4893 | + | version = "0.8.0" | |
| 4894 | + | ||
| 4895 | + | [[patch.unused]] | |
| 4896 | + | name = "synckit-config" | |
| 4897 | + | version = "0.2.0" | |
| 4898 | + | ||
| 4891 | 4899 | [[patch.unused]] | |
| 4892 | 4900 | name = "kberg" | |
| 4893 | 4901 | version = "0.1.0" | |
| @@ -4899,11 +4907,3 @@ | |||
| 4899 | 4907 | [[patch.unused]] | |
| 4900 | 4908 | name = "tagtree" | |
| 4901 | 4909 | version = "0.4.0" | |
| 4902 | - | ||
| 4903 | - | [[patch.unused]] | |
| 4904 | - | name = "synckit-client" | |
| 4905 | - | version = "0.8.0" | |
| 4906 | - | ||
| 4907 | - | [[patch.unused]] | |
| 4908 | - | name = "synckit-config" | |
| 4909 | - | version = "0.2.0" |
| @@ -187,14 +187,19 @@ | |||
| 187 | 187 | ||
| 188 | 188 | #[tokio::test] | |
| 189 | 189 | async fn a_verb_the_description_layer_lacks_is_refused_with_allow() { | |
| 190 | + | // PATCH is the verb the layer still does not have, since `61e1b069` gave it | |
| 191 | + | // DELETE and PUT. Nothing in either app writes one. | |
| 190 | 192 | let request = Request::builder() | |
| 191 | - | .method("DELETE") | |
| 193 | + | .method("PATCH") | |
| 192 | 194 | .uri("/task/7") | |
| 193 | 195 | .body(Body::empty()) | |
| 194 | 196 | .unwrap(); | |
| 195 | 197 | let response = service().oneshot(request).await.unwrap(); | |
| 196 | 198 | assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); | |
| 197 | - | assert_eq!(response.headers().get(header::ALLOW).unwrap(), "GET, POST"); | |
| 199 | + | assert_eq!( | |
| 200 | + | response.headers().get(header::ALLOW).unwrap(), | |
| 201 | + | "GET, POST, DELETE, PUT" | |
| 202 | + | ); | |
| 198 | 203 | } | |
| 199 | 204 | ||
| 200 | 205 | #[tokio::test] | |
| @@ -504,7 +509,7 @@ | |||
| 504 | 509 | .into_router(); | |
| 505 | 510 | ||
| 506 | 511 | let request = Request::builder() | |
| 507 | - | .method("PUT") | |
| 512 | + | .method("PATCH") | |
| 508 | 513 | .uri("/whoami") | |
| 509 | 514 | .body(Body::empty()) | |
| 510 | 515 | .unwrap(); |
| @@ -55,7 +55,12 @@ | |||
| 55 | 55 | pub const DEFAULT_BODY_LIMIT: usize = 256 * 1024; | |
| 56 | 56 | ||
| 57 | 57 | /// The verbs the description layer has, as an `Allow` header value. | |
| 58 | - | pub const ALLOWED_METHODS: &str = "GET, POST"; | |
| 58 | + | /// | |
| 59 | + | /// Must list exactly what `translate` accepts, or the header promises a verb | |
| 60 | + | /// the decoder refuses. `PATCH` is the one HTTP has that this does not: nothing | |
| 61 | + | /// in either app writes one, and a verb with no consumer is a verb whose | |
| 62 | + | /// meaning nobody has had to decide. | |
| 63 | + | pub const ALLOWED_METHODS: &str = "GET, POST, DELETE, PUT"; | |
| 59 | 64 | ||
| 60 | 65 | /// A request, in the terms the router takes. | |
| 61 | 66 | #[derive(Debug, Clone, PartialEq, Eq)] | |
| @@ -307,6 +312,8 @@ | |||
| 307 | 312 | match *method { | |
| 308 | 313 | http::Method::GET => Some(Method::Get), | |
| 309 | 314 | http::Method::POST => Some(Method::Post), | |
| 315 | + | http::Method::DELETE => Some(Method::Delete), | |
| 316 | + | http::Method::PUT => Some(Method::Put), | |
| 310 | 317 | _ => None, | |
| 311 | 318 | } | |
| 312 | 319 | } |
| @@ -154,8 +154,22 @@ | |||
| 154 | 154 | ||
| 155 | 155 | #[test] | |
| 156 | 156 | fn a_verb_the_description_layer_lacks_is_refused() { | |
| 157 | - | assert_eq!(read("DELETE", "/task/7", None, ""), Err(Refusal::Method)); | |
| 158 | - | assert_eq!(read("PUT", "/task/7", None, ""), Err(Refusal::Method)); | |
| 157 | + | // PATCH is the one left. DELETE and PUT arrived with `61e1b069`, because a | |
| 158 | + | // public server's verbs are part of its interface and a description that | |
| 159 | + | // cannot name them cannot address it. | |
| 160 | + | assert_eq!(read("PATCH", "/task/7", None, ""), Err(Refusal::Method)); | |
| 161 | + | assert_eq!(read("HEAD", "/task/7", None, ""), Err(Refusal::Method)); | |
| 162 | + | ||
| 163 | + | // And the two that arrived decode, rather than being accepted and then | |
| 164 | + | // silently read as a POST. | |
| 165 | + | assert_eq!( | |
| 166 | + | read("DELETE", "/task/7", None, "").unwrap().method.as_str(), | |
| 167 | + | "DELETE" | |
| 168 | + | ); | |
| 169 | + | assert_eq!( | |
| 170 | + | read("PUT", "/task/7", None, "").unwrap().method.as_str(), | |
| 171 | + | "PUT" | |
| 172 | + | ); | |
| 159 | 173 | } | |
| 160 | 174 | ||
| 161 | 175 | #[test] | |
| @@ -207,7 +221,9 @@ | |||
| 207 | 221 | assert_eq!(response.status(), 405); | |
| 208 | 222 | assert_eq!( | |
| 209 | 223 | response.headers().get(http::header::ALLOW).unwrap(), | |
| 210 | - | "GET, POST" | |
| 224 | + | // Exactly what `translate` accepts, or the header promises a verb the | |
| 225 | + | // decoder refuses. | |
| 226 | + | "GET, POST, DELETE, PUT" | |
| 211 | 227 | ); | |
| 212 | 228 | assert!(response.body().is_empty()); | |
| 213 | 229 | } |
| @@ -1,11 +1,33 @@ | |||
| 1 | 1 | //! What arrives: a verb, a path, and a flat bag of named values. | |
| 2 | 2 | //! | |
| 3 | 3 | //! Decision 2 on the wiki note is that an action is a route, so reads and | |
| 4 | - | //! mutations share one address space and the verb is what separates them. There | |
| 5 | - | //! is no third member and there is not going to be one: `PUT`, `PATCH` and | |
| 6 | - | //! `DELETE` are HTTP's vocabulary, and a terminal binding a key to a route has | |
| 7 | - | //! no opinion about which of them a deletion is. Two is what the description | |
| 8 | - | //! layer can express. | |
| 4 | + | //! mutations share one address space and the verb is what separates them. | |
| 5 | + | //! | |
| 6 | + | //! # Why there are four, when this said for months there would only ever be two | |
| 7 | + | //! | |
| 8 | + | //! `61e1b069`, decided 2026-08-10. The old text here read: "There is no third | |
| 9 | + | //! member and there is not going to be one: `PUT`, `PATCH` and `DELETE` are | |
| 10 | + | //! HTTP's vocabulary, and a terminal binding a key to a route has no opinion | |
| 11 | + | //! about which of them a deletion is." | |
| 12 | + | //! | |
| 13 | + | //! The premise was right and the conclusion did not follow. A terminal has no | |
| 14 | + | //! opinion, and it does not need one: it reads [`Method::mutates`] and binds a | |
| 15 | + | //! key. But **a public HTTP server's verbs are part of its interface**, and a | |
| 16 | + | //! description that cannot name them cannot address it. Measured across the MNW | |
| 17 | + | //! server's templates: 53 write sites use `hx-delete` or `hx-put`, in 34 files, | |
| 18 | + | //! and 45 of the tabs waiting to be described have one. The first tab that was | |
| 19 | + | //! described posted its Remove to a `/delete` path invented to avoid this, and | |
| 20 | + | //! that path was never registered, so the button rendered correctly and | |
| 21 | + | //! answered 404. | |
| 22 | + | //! | |
| 23 | + | //! The objection that this leaks HTTP into a host-agnostic vocabulary is | |
| 24 | + | //! answered by what an [`Action`](crate::Action) already is: | |
| 25 | + | //! [`Destination::Route`](crate::screen::Destination::Route) carries a path, | |
| 26 | + | //! which is exactly as HTTP-shaped as a verb, and `Get` and `Post` were here | |
| 27 | + | //! from the start. The alternative considered and rejected was naming *intent* | |
| 28 | + | //! (`remove`, `replace`) and letting each renderer map it, which breaks on any | |
| 29 | + | //! route whose verb disagrees with its intent. This server has those: | |
| 30 | + | //! `POST /api/items/bulk/delete`. | |
| 9 | 31 | ||
| 10 | 32 | use crate::error::RouteError; | |
| 11 | 33 | ||
| @@ -21,13 +43,22 @@ | |||
| 21 | 43 | Get, | |
| 22 | 44 | /// Telling. Performs, then answers with the next description. | |
| 23 | 45 | Post, | |
| 46 | + | /// Telling, where what is told is that the thing at the address goes away. | |
| 47 | + | Delete, | |
| 48 | + | /// Telling, where what is told is the thing the address should hold now. | |
| 49 | + | Put, | |
| 24 | 50 | } | |
| 25 | 51 | ||
| 26 | 52 | impl Method { | |
| 27 | 53 | /// Whether the route is allowed to change anything. | |
| 54 | + | /// | |
| 55 | + | /// The question every non-HTTP host asks, and the only one it has to. A | |
| 56 | + | /// terminal binding a key, an egui frame drawing a button and a renderer | |
| 57 | + | /// deciding between an anchor and a button all read this rather than the | |
| 58 | + | /// verb, which is why adding two verbs costs those hosts nothing. | |
| 28 | 59 | #[must_use] | |
| 29 | 60 | pub const fn mutates(self) -> bool { | |
| 30 | - | matches!(self, Self::Post) | |
| 61 | + | matches!(self, Self::Post | Self::Delete | Self::Put) | |
| 31 | 62 | } | |
| 32 | 63 | ||
| 33 | 64 | /// The name an HTTP host knows it by. | |
| @@ -36,6 +67,8 @@ | |||
| 36 | 67 | match self { | |
| 37 | 68 | Self::Get => "GET", | |
| 38 | 69 | Self::Post => "POST", | |
| 70 | + | Self::Delete => "DELETE", | |
| 71 | + | Self::Put => "PUT", | |
| 39 | 72 | } | |
| 40 | 73 | } | |
| 41 | 74 | } |
| @@ -67,6 +67,18 @@ | |||
| 67 | 67 | self.route(Method::Post, path, handler) | |
| 68 | 68 | } | |
| 69 | 69 | ||
| 70 | + | /// Register a write that removes what is at the address. | |
| 71 | + | #[must_use] | |
| 72 | + | pub fn delete(self, path: &str, handler: Handler<S>) -> Self { | |
| 73 | + | self.route(Method::Delete, path, handler) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// Register a write that replaces what is at the address. | |
| 77 | + | #[must_use] | |
| 78 | + | pub fn put(self, path: &str, handler: Handler<S>) -> Self { | |
| 79 | + | self.route(Method::Put, path, handler) | |
| 80 | + | } | |
| 81 | + | ||
| 70 | 82 | /// Register a route. | |
| 71 | 83 | /// | |
| 72 | 84 | /// # Panics |
| @@ -171,6 +171,31 @@ | |||
| 171 | 171 | } | |
| 172 | 172 | } | |
| 173 | 173 | ||
| 174 | + | /// A write that removes what is at the address. | |
| 175 | + | /// | |
| 176 | + | /// `61e1b069`. Reach for it when the route the app already answers is a | |
| 177 | + | /// `DELETE`, not to editorialise about what a `POST` means: the verb here | |
| 178 | + | /// exists to address an interface, and a route that deletes over `POST` is | |
| 179 | + | /// still [`post`](Self::post). | |
| 180 | + | pub fn delete(path: impl Into<String>) -> Self { | |
| 181 | + | Self { | |
| 182 | + | method: Method::Delete, | |
| 183 | + | destination: Destination::Route(path.into()), | |
| 184 | + | params: Params::new(), | |
| 185 | + | carried: Params::new(), | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | /// A write that replaces what is at the address. | |
| 190 | + | pub fn put(path: impl Into<String>) -> Self { | |
| 191 | + | Self { | |
| 192 | + | method: Method::Put, | |
| 193 | + | destination: Destination::Route(path.into()), | |
| 194 | + | params: Params::new(), | |
| 195 | + | carried: Params::new(), | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | ||
| 174 | 199 | /// Somewhere outside the app. | |
| 175 | 200 | /// | |
| 176 | 201 | /// [`Method::Get`], because following a link asks and does not tell, and a |
| @@ -186,8 +186,9 @@ | |||
| 186 | 186 | ||
| 187 | 187 | #[test] | |
| 188 | 188 | fn a_verb_the_description_layer_lacks_is_refused_with_allow() { | |
| 189 | + | // PATCH, since `61e1b069` gave the layer DELETE and PUT. | |
| 189 | 190 | let request = http::Request::builder() | |
| 190 | - | .method("DELETE") | |
| 191 | + | .method("PATCH") | |
| 191 | 192 | .uri("quasi://localhost/task/7") | |
| 192 | 193 | .body(Vec::new()) | |
| 193 | 194 | .unwrap(); | |
| @@ -195,7 +196,7 @@ | |||
| 195 | 196 | assert_eq!(response.status(), 405); | |
| 196 | 197 | assert_eq!( | |
| 197 | 198 | response.headers().get(http::header::ALLOW).unwrap(), | |
| 198 | - | "GET, POST" | |
| 199 | + | "GET, POST, DELETE, PUT" | |
| 199 | 200 | ); | |
| 200 | 201 | } | |
| 201 | 202 |
| @@ -303,6 +303,8 @@ | |||
| 303 | 303 | let verb = match action.method { | |
| 304 | 304 | Method::Get => " hx-get=\"", | |
| 305 | 305 | Method::Post => " hx-post=\"", | |
| 306 | + | Method::Delete => " hx-delete=\"", | |
| 307 | + | Method::Put => " hx-put=\"", | |
| 306 | 308 | }; | |
| 307 | 309 | out.push_str(verb); | |
| 308 | 310 | out.push_str(&escape(&quasi_http::route_url( |