Support affixed typed path captures (#3782)

This commit is contained in:
Mochammad Farros Fatchur Roji
2026-06-10 21:24:35 +02:00
committed by GitHub
parent 42d6fdc80d
commit 5b52bcdcfa
2 changed files with 131 additions and 18 deletions
+52 -18
View File
@@ -362,8 +362,7 @@ fn format_str_from_path(segments: &[Segment]) -> String {
Segment::Capture(capture, _) => format!("{{{capture}}}"),
Segment::Static(segment) => segment.to_owned(),
})
.collect::<Vec<_>>()
.join("/")
.collect()
}
fn captures_from_path(segments: &[Segment]) -> Vec<syn::Ident> {
@@ -387,23 +386,58 @@ fn parse_path(path: &LitStr) -> syn::Result<Vec<Segment>> {
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<usize> {
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 {
@@ -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"
);
}