From 5b52bcdcfa7143d755c27d0df43e19754a3f3a0d Mon Sep 17 00:00:00 2001 From: Mochammad Farros Fatchur Roji <56761912+farrosfr@users.noreply.github.com> Date: Thu, 11 Jun 2026 02:24:35 +0700 Subject: [PATCH] Support affixed typed path captures (#3782) --- axum-macros/src/typed_path.rs | 70 +++++++++++----- .../tests/typed_path/pass/affixed_captures.rs | 79 +++++++++++++++++++ 2 files changed, 131 insertions(+), 18 deletions(-) create mode 100644 axum-macros/tests/typed_path/pass/affixed_captures.rs diff --git a/axum-macros/src/typed_path.rs b/axum-macros/src/typed_path.rs index 0e89660f..77ffa8f4 100644 --- a/axum-macros/src/typed_path.rs +++ b/axum-macros/src/typed_path.rs @@ -362,8 +362,7 @@ fn format_str_from_path(segments: &[Segment]) -> String { Segment::Capture(capture, _) => format!("{{{capture}}}"), Segment::Static(segment) => segment.to_owned(), }) - .collect::>() - .join("/") + .collect() } fn captures_from_path(segments: &[Segment]) -> Vec { @@ -387,23 +386,58 @@ fn parse_path(path: &LitStr) -> syn::Result> { return Err(syn::Error::new_spanned(path, "paths must start with a `/`")); } - path.value() - .split('/') - .map(|segment| { - if let Some(capture) = segment - .strip_prefix('{') - .and_then(|segment| segment.strip_suffix('}')) - .and_then(|segment| { - (!segment.starts_with('{') && !segment.ends_with('}')).then_some(segment) - }) - .map(|capture| capture.strip_prefix('*').unwrap_or(capture)) - { - Ok(Segment::Capture(capture.to_owned(), path.span())) - } else { - Ok(Segment::Static(segment.to_owned())) + let mut segments = Vec::new(); + let mut rest = value.as_str(); + + while let Some(start) = find_first_not_double(b'{', rest.as_bytes()) { + if start > 0 { + segments.push(Segment::Static(rest[..start].to_owned())); + } + + rest = &rest[start + 1..]; + + if let Some(end) = rest.find('}') { + let capture = &rest[..end]; + if capture.is_empty() || capture.contains('{') { + return Err(syn::Error::new_spanned(path, "invalid capture in path")); } - }) - .collect() + + segments.push(Segment::Capture( + capture.strip_prefix('*').unwrap_or(capture).to_owned(), + path.span(), + )); + + rest = &rest[end + 1..]; + } else { + segments.push(Segment::Static(format!("{{{rest}"))); + rest = ""; + } + } + + if !rest.is_empty() { + segments.push(Segment::Static(rest.to_owned())); + } + + Ok(segments) +} + +fn find_first_not_double(needle: u8, haystack: &[u8]) -> Option { + let mut possible_capture = 0; + while let Some(index) = haystack + .get(possible_capture..) + .and_then(|haystack| haystack.iter().position(|byte| byte == &needle)) + { + let index = index + possible_capture; + + if haystack.get(index + 1) == Some(&needle) { + possible_capture = index + 2; + continue; + } + + return Some(index); + } + + None } enum Segment { diff --git a/axum-macros/tests/typed_path/pass/affixed_captures.rs b/axum-macros/tests/typed_path/pass/affixed_captures.rs new file mode 100644 index 00000000..a0ac9847 --- /dev/null +++ b/axum-macros/tests/typed_path/pass/affixed_captures.rs @@ -0,0 +1,79 @@ +use axum_extra::routing::{RouterExt, TypedPath}; +use serde::Deserialize; + +#[derive(TypedPath, Deserialize)] +#[typed_path("/@{username}")] +struct PrefixedCapture { + username: String, +} + +#[derive(TypedPath, Deserialize)] +#[typed_path("/files/{name}.json")] +struct SuffixedCapture { + name: String, +} + +#[derive(TypedPath, Deserialize)] +#[typed_path("/users/{user_id}/teams/team-{team_id}")] +struct MixedCaptures { + user_id: u32, + team_id: u32, +} + +#[derive(TypedPath, Deserialize)] +#[typed_path("/{{escaped_capture}}/{id}")] +struct EscapedBrace { + id: u32, +} + +#[derive(TypedPath, Deserialize)] +#[typed_path("/@{username}")] +struct UnnamedPrefixedCapture(String); + +fn main() { + _ = axum::Router::<()>::new().typed_get(|_: PrefixedCapture| async {}); + _ = axum::Router::<()>::new().typed_get(|_: SuffixedCapture| async {}); + _ = axum::Router::<()>::new().typed_get(|_: MixedCaptures| async {}); + _ = axum::Router::<()>::new().typed_get(|_: EscapedBrace| async {}); + + assert_eq!(PrefixedCapture::PATH, "/@{username}"); + assert_eq!( + format!( + "{}", + PrefixedCapture { + username: "alice".to_owned(), + } + ), + "/@alice" + ); + + assert_eq!(SuffixedCapture::PATH, "/files/{name}.json"); + assert_eq!( + format!( + "{}", + SuffixedCapture { + name: "report final".to_owned(), + } + ), + "/files/report%20final.json" + ); + + assert_eq!( + format!( + "{}", + MixedCaptures { + user_id: 1, + team_id: 2, + } + ), + "/users/1/teams/team-2" + ); + + assert_eq!(EscapedBrace::PATH, "/{{escaped_capture}}/{id}"); + assert_eq!(format!("{}", EscapedBrace { id: 7 }), "/{escaped_capture}/7"); + + assert_eq!( + format!("{}", UnnamedPrefixedCapture("bob".to_owned())), + "/@bob" + ); +}