How I Added Affixed Capture Support to Axum's Typed Paths
How I contributed to tokio-rs/axum to support prefixed and suffixed typed path captures like /@{username} and /files/{name}.json.
A few weeks ago, my pull request tokio-rs/axum#3782 was merged into axum. This contribution resolves a long-standing limitation in Axum's typed routing system: the inability to support affixed (prefixed or suffixed) capture patterns in typed paths.
Here is the story behind the problem, how the macro parser was updated, and what this means for your Rust APIs.
What is Axum?
Axum is a modern web application framework built for Rust, the systems programming language known for memory safety, performance, and concurrency. Axum is developed as part of the Tokio ecosystem (Rust's premier asynchronous runtime), making it highly efficient for handling concurrent network traffic at scale.
Axum stands out because of its ergonomic, declarative programming model:
Type-Safe Routing: Routes map requests directly to handler functions.
Extractors: Handlers extract parameters (such as JSON payloads, headers, query parameters, or paths) directly from signature arguments using types.
Middleware: Built on top of the
towerecosystem, allowing modular middleware stack reusability.
Here is a simple example of a standard Axum server setup in Rust:
use axum::{routing::get, Router};
#[tokio::main]
async fn main() {
// Define routing and handlers
let app = Router::new()
.route("/", get(hello_world))
.route("/users/{id}", get(get_user));
// Run server on localhost:3000
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn hello_world() -> &'static str {
"Hello, World!"
}
async fn get_user(axum::extract::Path(id): axum::extract::Path<u32>) -> String {
format!("User ID: {}", id)
}
By leveraging Rust's strong static typing and macro system, Axum compiles checks for route correctness, route parameters, and request payload safety at build time.
The Problem: Segment-Bound Captures
Axum provides a powerful macro-based router extension called TypedPath. It allows developers to define type-safe routes by deriving the TypedPath trait on structs representing route parameters:
use axum_extra::routing::TypedPath;
use serde::Deserialize;
#[derive(TypedPath, Deserialize)]
#[typed_path("/users/{id}")]
struct ShowUser {
id: u32,
}
However, the previous typed path parser in axum-macros split route paths strictly by the / separator and assumed each path segment was either entirely static or entirely a capture (surrounded by { and }).
This design meant route patterns with prefixed or suffixed captures (such as /@{username} for user profiles or /files/{name}.json for format extensions) would fail to compile, even though they are perfectly valid route patterns in standard routers.
The Solution: Parsing Affixed Captures
To solve this, we needed to update the macro parser in axum-macros/src/typed_path.rs. Instead of checking if a segment starts and ends with braces, we updated the parser to scan for capture brackets inside each path segment.
Figure 1: Visual flowchart showing the parsing logic extracting static prefixes, capture keys, and static suffixes from a route segment.
Here is the core logic implemented in the PR to split segments by capture boundaries while handling double-brace escaping (e.g., {{escaped}}):
fn parse_path(path: &LitStr) -> syn::Result<Vec<Segment>> {
let value = path.value();
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"));
}
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)
}
We also introduced a helper function, find_first_not_double, to skip escaped braces so they are treated as static route components rather than dynamic captures:
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
}
Verifying with Formatting & Tests
A crucial part of TypedPath is implementing the Display trait so path instances can be formatted back into URLs:
let path = PrefixedCapture { username: "alice".to_owned() };
assert_eq!(format!("{}", path), "/@alice");
Because the parser now isolates the static prefix and suffix parts of a segment, TypedPath preserves formatting, percent-encodes capture values correctly (e.g. spaces turn into %20), and supports all standard Rust primitive types and custom deserialize structures.
The test coverage added in the PR ensures that prefixed, suffixed, and escaped routes compile and execute correctly:
#[derive(TypedPath, Deserialize)]
#[typed_path("/@{username}")]
struct PrefixedCapture {
username: String,
}
#[derive(TypedPath, Deserialize)]
#[typed_path("/files/{name}.json")]
struct SuffixedCapture {
name: String,
}
This PR makes Axum's typed routing much more flexible and brings full path matching parity with standard dynamic route definitions.
Thanks for reading. See you in the next lab.



