Skip to main content

max / quasi

A described write can name its verb 61e1b069. This module said for months that there would only ever be two: "PUT, PATCH and DELETE are HTTP's vocabulary, and a terminal binding a key to a route has no opinion about which of them a deletion is." The premise was right and the conclusion did not follow. A terminal has no opinion and does not need one, because it reads mutates() and binds a key. But a public HTTP server's verbs are part of its interface, and a description that cannot name them cannot address it. Measured across the MNW server's templates: 53 write sites use hx-delete or hx-put, in 34 files, and 45 of the tabs waiting to be described have one. It has already cost something. The first tab described posted its Remove to /api/users/me/ssh-keys/{id}/delete, a path invented to avoid this and registered nowhere, so the button rendered correctly and answered 404. The objection that this leaks HTTP into a host-agnostic vocabulary is answered by what an Action already is: Destination::Route carries a path, which is as HTTP-shaped as a verb, and Get and Post were here from the start. Naming intent instead was considered and rejected, because it breaks on any route whose verb disagrees with its intent, and this server has those: POST /api/items/bulk/delete. PATCH is still absent, and stays absent while nothing writes one. A verb with no consumer is a verb whose meaning nobody has had to decide.
Author: Max Johnson <me@maxj.phd> · 2026-08-11 00:02 UTC
Signed with PGP, not checked
Commit: bfe40fe25c8c2a4af526786ed8d4a4f87941a41e
Parent: fcf57a8
10 files changed, +147 insertions, -23 deletions
M Cargo.lock +8 -8
@@ -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(