Skip to main content

max / quasi

A capture is {name}, and the old form is a startup panic
Author: Max Johnson <me@maxj.phd> · 2026-08-11 19:18 UTC
Signed with PGP, not checked
Commit: ccc1cd00248f1068ded4b21c9708a04c2ef0e53d
Parent: 4728769
5 files changed, +123 insertions, -43 deletions
@@ -73,10 +73,10 @@
73 73 fn service() -> axum::Router {
74 74 let router = Router::<App>::new()
75 75 .get("/", home)
76 - .get("/task/:id", echo)
77 - .post("/task/:id/edit", echo)
76 + .get("/task/{id}", echo)
77 + .post("/task/{id}/edit", echo)
78 78 .post("/tags", tags)
79 - .post("/task/:id/delete", denied)
79 + .post("/task/{id}/delete", denied)
80 80 .get("/boom", boom);
81 81 super::Adapter::new(router, Arc::new(App), Arc::new(Spy)).into_router()
82 82 }
@@ -311,7 +311,7 @@
311 311 async fn a_refusal_reaches_the_factory_with_no_answer_to_read() {
312 312 // There is no screen to fill when the router refuses, and the factory is
313 313 // told so rather than handed something invented.
314 - let router = Router::<App>::new().post("/task/:id/delete", denied);
314 + let router = Router::<App>::new().post("/task/{id}/delete", denied);
315 315 let service =
316 316 super::Adapter::per_request(router, Arc::new(App), |_app, _params, answer| PerRequest {
317 317 greeting: match answer {
@@ -65,8 +65,8 @@
65 65 //! }
66 66 //!
67 67 //! let router = Router::<App>::new()
68 - //! .get("/task/:id", show_task)
69 - //! .post("/task/:id/complete", complete_task);
68 + //! .get("/task/{id}", show_task)
69 + //! .post("/task/{id}/complete", complete_task);
70 70 //!
71 71 //! let app = App { tasks: vec![(7, "Write the router".into(), false)] };
72 72 //! let answer = router.handle(&app, Request::get("/task/7")).unwrap();
@@ -150,10 +150,10 @@
150 150 // Deliberately registered least-specific-first, so the ordering being
151 151 // tested is the table's own and not the order of these lines.
152 152 Router::new()
153 - .get("/task/:id", show_task)
153 + .get("/task/{id}", show_task)
154 154 .get("/task/new", new_task)
155 155 .get("/", home)
156 - .post("/task/:id/delete", forbidden)
156 + .post("/task/{id}/delete", forbidden)
157 157 }
158 158
159 159 fn state() -> State {
@@ -249,7 +249,7 @@
249 249 let router = router();
250 250 let table: Vec<_> = router.routes().collect();
251 251 let new_at = table.iter().position(|(_, p)| *p == "/task/new").unwrap();
252 - let id_at = table.iter().position(|(_, p)| *p == "/task/:id").unwrap();
252 + let id_at = table.iter().position(|(_, p)| *p == "/task/{id}").unwrap();
253 253 assert!(new_at < id_at);
254 254 assert_eq!(router.len(), 4);
255 255 }
@@ -258,8 +258,8 @@
258 258 #[should_panic(expected = "registered twice")]
259 259 fn registering_one_route_twice_is_a_bug() {
260 260 let _ = Router::<State>::new()
261 - .get("/task/:id", show_task)
262 - .get("/task/:id", show_task);
261 + .get("/task/{id}", show_task)
262 + .get("/task/{id}", show_task);
263 263 }
264 264
265 265 #[test]
@@ -1,8 +1,24 @@
1 1 //! Matching a path against a registered pattern.
2 2 //!
3 - //! Segment-wise, with `:name` capturing one segment. No wildcards and no regex.
3 + //! Segment-wise, with `{name}` capturing one segment. No wildcards and no regex.
4 4 //! A pattern is a name for a screen or an action, and the moment it can match an
5 5 //! arbitrary tail it stops being one.
6 + //!
7 + //! # Why `{name}` and not `:name`
8 + //!
9 + //! It was `:name` until 2026-08-11, for no recorded reason: one commit, no note,
10 + //! and a module doc that stated the syntax without arguing it. `:name` is the
11 + //! older convention (pre-0.8 axum, actix, express, Rails) and axum moved to
12 + //! `{name}` at 0.8 along with matchit. `quasi-axum` depends on axum 0.8, so the
13 + //! router disagreed with the host its own adapter is written against, and with
14 + //! every route in the app that first used it.
15 + //!
16 + //! The cost was not the inconsistency. A `{id}` written by anyone whose fingers
17 + //! know the surrounding codebase parsed as a *static* segment named literally
18 + //! `{id}`: registration succeeded, the route could never match, and the only
19 + //! symptom was a 404 when somebody pressed the button. That shipped in the MNW
20 + //! server and survived a day. So the wrong shape is now a panic at startup, the
21 + //! same as the other two malformed cases, and the syntax matches the host.
6 22
7 23 use crate::request::Params;
8 24
@@ -28,10 +44,17 @@
28 44 ///
29 45 /// # Panics
30 46 ///
31 - /// If a segment is empty or a capture has no name. Registration happens at
32 - /// startup from literals in the source, so a malformed pattern is a bug
33 - /// that should stop the program rather than a condition to thread through
34 - /// every call site as a `Result`.
47 + /// If a segment is empty, a capture has no name, a brace is unbalanced, or a
48 + /// segment uses the retired `:name` form. Registration happens at startup
49 + /// from literals in the source, so a malformed pattern is a bug that should
50 + /// stop the program rather than a condition to thread through every call
51 + /// site as a `Result`.
52 + ///
53 + /// The last two exist because the failure they replace is silent. An
54 + /// unrecognised capture shape is not a malformed pattern to a segment-wise
55 + /// matcher; it is a perfectly good static segment that happens to match
56 + /// nothing a caller will ever send, so the route registers, never fires, and
57 + /// answers 404 to the one person who presses it.
35 58 pub(crate) fn parse(source: &str) -> Self {
36 59 assert!(
37 60 source.starts_with('/'),
@@ -44,15 +67,39 @@
44 67 !raw.is_empty(),
45 68 "route pattern `{source}` has an empty segment"
46 69 );
47 - match raw.strip_prefix(':') {
48 - Some(name) => {
70 + assert!(
71 + !raw.starts_with(':'),
72 + "route pattern `{source}` uses `:name`, which was retired in \
73 + favour of `{{name}}` on 2026-08-11. Write `{{{}}}`.",
74 + &raw[1..]
75 + );
76 + match raw.strip_prefix('{') {
77 + Some(rest) => {
78 + let name = rest.strip_suffix('}').unwrap_or_else(|| {
79 + panic!("route pattern `{source}` has an unclosed capture")
80 + });
49 81 assert!(
50 82 !name.is_empty(),
51 83 "route pattern `{source}` has an unnamed capture"
52 84 );
85 + // A capture is the whole segment or it is not one. `a{b}`
86 + // reads as a partial match, which this matcher does not
87 + // do, and silently treating it as static is the failure
88 + // this whole assertion block exists to end.
89 + assert!(
90 + !name.contains('{') && !name.contains('}'),
91 + "route pattern `{source}` has a malformed capture"
92 + );
53 93 Segment::Capture(name.to_owned())
54 94 }
55 - None => Segment::Static(raw.to_owned()),
95 + None => {
96 + assert!(
97 + !raw.contains('{') && !raw.contains('}'),
98 + "route pattern `{source}` has a brace inside a static \
99 + segment; a capture is the whole segment or none of it"
100 + );
101 + Segment::Static(raw.to_owned())
102 + }
56 103 }
57 104 })
58 105 .collect();
@@ -83,7 +130,7 @@
83 130 Segment::Static(_) => return None,
84 131 Segment::Capture(name) => {
85 132 // An empty capture would let `/task//edit` answer as
86 - // `/task/:id/edit` with a blank id, which is a request no
133 + // `/task/{id}/edit` with a blank id, which is a request no
87 134 // renderer of ours emits and a row no store has.
88 135 if part.is_empty() {
89 136 return None;
@@ -99,7 +146,7 @@
99 146 /// How specific the pattern is, most significant segment first.
100 147 ///
101 148 /// Sorted descending at registration so that `/task/new` is tried before
102 - /// `/task/:id` however they were declared. Ordering by declaration instead
149 + /// `/task/{id}` however they were declared. Ordering by declaration instead
103 150 /// would make a route table's correctness depend on the order somebody
104 151 /// happened to type it in, which is the kind of thing that works until a
105 152 /// route is moved.
@@ -155,17 +202,17 @@
155 202 #[test]
156 203 fn capture_takes_one_segment() {
157 204 assert_eq!(
158 - captures("/task/:id", "/task/7"),
205 + captures("/task/{id}", "/task/7"),
159 206 Some(vec![("id".to_owned(), "7".to_owned())])
160 207 );
161 - assert_eq!(captures("/task/:id", "/task/7/edit"), None);
162 - assert_eq!(captures("/task/:id", "/task"), None);
208 + assert_eq!(captures("/task/{id}", "/task/7/edit"), None);
209 + assert_eq!(captures("/task/{id}", "/task"), None);
163 210 }
164 211
165 212 #[test]
166 213 fn several_captures_keep_their_names() {
167 214 assert_eq!(
168 - captures("/project/:project/task/:id", "/project/quasi/task/7"),
215 + captures("/project/{project}/task/{id}", "/project/quasi/task/7"),
169 216 Some(vec![
170 217 ("project".to_owned(), "quasi".to_owned()),
171 218 ("id".to_owned(), "7".to_owned()),
@@ -177,7 +224,7 @@
177 224 fn trailing_slash_is_the_same_screen() {
178 225 assert_eq!(captures("/task", "/task/"), Some(vec![]));
179 226 assert_eq!(
180 - captures("/task/:id", "/task/7/"),
227 + captures("/task/{id}", "/task/7/"),
181 228 Some(vec![("id".to_owned(), "7".to_owned())])
182 229 );
183 230 }
@@ -185,19 +232,52 @@
185 232 #[test]
186 233 fn static_outranks_capture() {
187 234 let new = Pattern::parse("/task/new");
188 - let id = Pattern::parse("/task/:id");
235 + let id = Pattern::parse("/task/{id}");
189 236 assert!(new.specificity() > id.specificity());
190 237 }
191 238
192 239 #[test]
193 240 #[should_panic(expected = "must start with `/`")]
194 241 fn pattern_without_leading_slash_is_a_bug() {
195 - Pattern::parse("task/:id");
242 + Pattern::parse("task/{id}");
196 243 }
197 244
198 245 #[test]
199 246 #[should_panic(expected = "unnamed capture")]
200 247 fn unnamed_capture_is_a_bug() {
201 - Pattern::parse("/task/:");
248 + Pattern::parse("/task/{}");
249 + }
250 +
251 + #[test]
252 + #[should_panic(expected = "was retired")]
253 + fn the_old_colon_form_is_a_bug_and_says_so() {
254 + // The whole point of the switch. `:id` was valid yesterday, and a
255 + // pattern written from memory has to fail loudly rather than register a
256 + // static segment nothing will ever match.
257 + Pattern::parse("/task/:id");
258 + }
259 +
260 + #[test]
261 + #[should_panic(expected = "unclosed capture")]
262 + fn an_unclosed_capture_is_a_bug() {
263 + Pattern::parse("/task/{id");
264 + }
265 +
266 + #[test]
267 + #[should_panic(expected = "brace inside a static segment")]
268 + fn a_brace_in_a_static_segment_is_a_bug() {
269 + // The failure this replaces, in its purest form: `id}` is a fine static
270 + // segment and matches nothing anybody sends.
271 + Pattern::parse("/task/id}");
272 + }
273 +
274 + #[test]
275 + fn a_capture_is_the_whole_segment() {
276 + // Partial matching is not something this matcher does, so a pattern
277 + // implying it must not quietly become static.
278 + assert_eq!(
279 + captures("/file/{name}", "/file/notes.md"),
280 + Some(vec![("name".to_owned(), "notes.md".to_owned())])
281 + );
202 282 }
203 283 }
@@ -71,9 +71,9 @@
71 71 fn router() -> Router<App> {
72 72 Router::<App>::new()
73 73 .get("/", home)
74 - .get("/task/:id", echo)
75 - .post("/task/:id/edit", echo)
76 - .post("/task/:id/delete", denied)
74 + .get("/task/{id}", echo)
75 + .post("/task/{id}/edit", echo)
76 + .post("/task/{id}/delete", denied)
77 77 .get("/boom", boom)
78 78 }
79 79
@@ -8,10 +8,10 @@
8 8 //! # The routes
9 9 //!
10 10 //! - `GET /` — the screen.
11 - //! - `GET /note/:id` — the detail pane, as a fragment.
11 + //! - `GET /note/{id}` — the detail pane, as a fragment.
12 12 //! - `POST /note` — create.
13 - //! - `POST /note/:id/archive` — archive.
14 - //! - `POST /note/:id/restore` — put it back.
13 + //! - `POST /note/{id}/archive` — archive.
14 + //! - `POST /note/{id}/restore` — put it back.
15 15 //!
16 16 //! # The filter is an address
17 17 //!
@@ -27,7 +27,7 @@
27 27 //!
28 28 //! # A read swaps a region; a write answers with the screen
29 29 //!
30 - //! `GET /note/:id` changes one pane, so it answers with a fragment naming that
30 + //! `GET /note/{id}` changes one pane, so it answers with a fragment naming that
31 31 //! pane. Creating or archving changes the list *and* the detail pane at once,
32 32 //! and a [`Response`] names one region — so a write answers with the whole
33 33 //! screen rather than picking one and leaving the other stale. What makes that
@@ -289,7 +289,7 @@
289 289 .map_err(|_| RouteError::not_found("no such note"))
290 290 }
291 291
292 - /// `GET /note/:id`
292 + /// `GET /note/{id}`
293 293 fn detail(state: &AppState, request: Request) -> Result<Response, RouteError> {
294 294 let id = note_id(&request)?;
295 295 let archived = archived_flag(&request);
@@ -339,7 +339,7 @@
339 339 .toast(Tone::Success, "Note saved."))
340 340 }
341 341
342 - /// `POST /note/:id/archive` and `POST /note/:id/restore` differ by one bool.
342 + /// `POST /note/{id}/archive` and `POST /note/{id}/restore` differ by one bool.
343 343 fn set_archived(state: &AppState, request: &Request, to: bool) -> Result<Response, RouteError> {
344 344 let id = note_id(request)?;
345 345 let archived = archived_flag(request);
@@ -359,12 +359,12 @@
359 359 )
360 360 }
361 361
362 - /// `POST /note/:id/archive`
362 + /// `POST /note/{id}/archive`
363 363 fn archive(state: &AppState, request: Request) -> Result<Response, RouteError> {
364 364 set_archived(state, &request, true)
365 365 }
366 366
367 - /// `POST /note/:id/restore`
367 + /// `POST /note/{id}/restore`
368 368 fn restore(state: &AppState, request: Request) -> Result<Response, RouteError> {
369 369 set_archived(state, &request, false)
370 370 }
@@ -374,10 +374,10 @@
374 374 pub fn routes(router: Router<AppState>) -> Router<AppState> {
375 375 router
376 376 .get("/", index)
377 - .get("/note/:id", detail)
377 + .get("/note/{id}", detail)
378 378 .post("/note", create)
379 - .post("/note/:id/archive", archive)
380 - .post("/note/:id/restore", restore)
379 + .post("/note/{id}/archive", archive)
380 + .post("/note/{id}/restore", restore)
381 381 }
382 382
383 383 #[cfg(test)]