[Add] Dynamic routes through path parameters
All checks were successful
CI / checks (push) Successful in 5m22s

This commit is contained in:
Alex Emmet 2026-07-19 23:48:32 +02:00
commit cf3cccd3ca
6 changed files with 379 additions and 13 deletions

View file

@ -14,6 +14,8 @@ returns `OK` while the process is running. The route is served over HTTP/3 at
| --- | --- | --- | | --- | --- | --- |
| `route(path, handler)` | None | Register an exact-path HTTP/3 handler. | | `route(path, handler)` | None | Register an exact-path HTTP/3 handler. |
| `route_method(method, path, handler)` | None | Register a method-specific handler. | | `route_method(method, path, handler)` | None | Register a method-specific handler. |
| `route_pattern(pattern, handler)` | None | Register a route with `{name}` single-segment parameters. |
| `route_pattern_method(method, pattern, handler)` | None | Register a method-specific parameterized route. |
| `fallback(handler)` | None | Handle requests that match no route. | | `fallback(handler)` | None | Handle requests that match no route. |
| `mtp_path(path)` | `/` | Path for WebTransport extended CONNECT. | | `mtp_path(path)` | `/` | Path for WebTransport extended CONNECT. |
| `max_request_body(bytes)` | 4 MiB | Maximum buffered HTTP/3 request body. | | `max_request_body(bytes)` | 4 MiB | Maximum buffered HTTP/3 request body. |
@ -24,6 +26,44 @@ returns `OK` while the process is running. The route is served over HTTP/3 at
The route and fallback builders return `Result` because duplicate routes and duplicate fallback handlers are rejected. The route and fallback builders return `Result` because duplicate routes and duplicate fallback handlers are rejected.
Parameterized routes use braces around a name and pass extracted values to the
handler as `RouteParams`. Each parameter matches exactly one path segment. Exact
routes take precedence over parameterized routes; among parameterized routes,
method-specific and more-specific routes take precedence.
```rust
use http::{Method, StatusCode};
use mtp::webserver::{Http3Request, Http3Response, RouteParams, WebServerConfig};
async fn profile(
_request: Http3Request,
response: Http3Response,
params: RouteParams,
) -> Http3Response {
let Some(userid) = params.get("userid") else {
return response.status(StatusCode::BAD_REQUEST);
};
response
.status(StatusCode::OK)
.header("content-type", "application/json")
.body(format!(r#"{{"userid":"{}"}}"#, userid))
}
let web = WebServerConfig::new()
.route_pattern_method(
Method::GET,
"/api/get/{userid}/profile.json",
profile,
)?;
```
`GET /api/get/user-123/profile.json` invokes `profile` with
`params["userid"] == "user-123"`. Percent-encoded parameter values are
UTF-8 decoded before being passed to the handler. Malformed encoded values do
not match the route. Query strings remain available through
`request.uri.query()` and are not part of route matching.
## HTTP/3 Requests and Responses ## HTTP/3 Requests and Responses
`Http3Request` contains `method`, `uri`, `headers`, and an optional buffered `body` represented by `bytes::Bytes`. `Http3Response::status`, `header`, and `body` build a buffered response. `try_header` returns an error for invalid header names or values. `stream` takes a `tokio::sync::mpsc::Receiver<Bytes>` for incremental response chunks. `Http3Request` contains `method`, `uri`, `headers`, and an optional buffered `body` represented by `bytes::Bytes`. `Http3Response::status`, `header`, and `body` build a buffered response. `try_header` returns an error for invalid header names or values. `stream` takes a `tokio::sync::mpsc::Receiver<Bytes>` for incremental response chunks.

View file

@ -141,7 +141,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.await?; .await?;
let mut host = mtp::webserver::MTPWebServer::new(config, web_server::config()?).await?; let mut host = mtp::webserver::MTPWebServer::new(config, web_server::config()?).await?;
println!( println!(
"Server listening on https://{} (TCP HTTPS + UDP WebTransport)", "Server listening on https://{} (HTTPS + UDP WebTransport)",
host.local_addr() host.local_addr()
); );

View file

@ -1,4 +1,4 @@
use mtp::webserver::{Http3Request, Http3Response, WebServerConfig}; use mtp::webserver::{Http3Request, Http3Response, RouteParams, WebServerConfig};
use rustls::pki_types::{PrivateKeyDer, pem::PemObject}; use rustls::pki_types::{PrivateKeyDer, pem::PemObject};
use std::{ use std::{
io, io,
@ -19,8 +19,32 @@ async fn ok(_request: Http3Request, response: Http3Response) -> Http3Response {
.body("OK") .body("OK")
} }
async fn profile(
_request: Http3Request,
response: Http3Response,
params: RouteParams,
) -> Http3Response {
let Some(user) = params.get("user") else {
return response.body("missing user");
};
let body = serde_json::json!({
"user": user,
"profile": {
"display_name": format!("Example user {user}"),
"status": "active"
}
});
response
.header("content-type", "application/json; charset=utf-8")
.body(body.to_string())
}
pub fn config() -> Result<WebServerConfig, mtp::webserver::RouterError> { pub fn config() -> Result<WebServerConfig, mtp::webserver::RouterError> {
WebServerConfig::new().route("/", ok) WebServerConfig::new()
.route("/", ok)?
.route_pattern("/api/get/{user}/profile", profile)
} }
/// Starts the conventional HTTPS side of the example host. WebTransport uses /// Starts the conventional HTTPS side of the example host. WebTransport uses

View file

@ -13,7 +13,7 @@ mod transport;
pub use error::WebServerError; pub use error::WebServerError;
#[cfg(feature = "pipes")] #[cfg(feature = "pipes")]
pub use mtp_transport::TransportEvent; pub use mtp_transport::TransportEvent;
pub use router::{HttpHandler, Router, RouterError}; pub use router::{DynamicHttpHandler, HttpHandler, RouteParams, Router, RouterError};
pub use server::{MTPWebServer, WebServerConfig, WebServerMetrics}; pub use server::{MTPWebServer, WebServerConfig, WebServerMetrics};
pub use stream::{Http3Request, Http3Response}; pub use stream::{Http3Request, Http3Response};
pub use transport::{ pub use transport::{

View file

@ -2,6 +2,9 @@ use crate::{Http3Request, Http3Response};
use http::Method; use http::Method;
use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc}; use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc};
/// Values captured from a parameterized route.
pub type RouteParams = HashMap<String, String>;
/// An asynchronous HTTP/3 route handler. /// An asynchronous HTTP/3 route handler.
pub type HttpHandler = Arc< pub type HttpHandler = Arc<
dyn Fn(Http3Request, Http3Response) -> Pin<Box<dyn Future<Output = Http3Response> + Send>> dyn Fn(Http3Request, Http3Response) -> Pin<Box<dyn Future<Output = Http3Response> + Send>>
@ -9,6 +12,17 @@ pub type HttpHandler = Arc<
+ Sync, + Sync,
>; >;
/// An asynchronous handler for a parameterized HTTP/3 route.
pub type DynamicHttpHandler = Arc<
dyn Fn(
Http3Request,
Http3Response,
RouteParams,
) -> Pin<Box<dyn Future<Output = Http3Response> + Send>>
+ Send
+ Sync,
>;
/// Errors returned by [`Router`] route registration. /// Errors returned by [`Router`] route registration.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum RouterError { pub enum RouterError {
@ -17,12 +31,31 @@ pub enum RouterError {
#[error("a router fallback is already registered")] #[error("a router fallback is already registered")]
DuplicateFallback, DuplicateFallback,
#[error("invalid route pattern: {0}")]
InvalidPattern(String),
} }
/// Exact-path HTTP route table used by [`MTPWebServer`](crate::MTPWebServer). #[derive(Clone)]
struct PatternRoute {
method: Option<Method>,
pattern: String,
segments: Vec<PatternSegment>,
static_segments: usize,
handler: DynamicHttpHandler,
}
#[derive(Clone)]
enum PatternSegment {
Static(String),
Parameter(String),
}
/// HTTP route table used by [`MTPWebServer`](crate::MTPWebServer).
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub struct Router { pub struct Router {
routes: HashMap<(Option<Method>, String), HttpHandler>, routes: HashMap<(Option<Method>, String), HttpHandler>,
pattern_routes: Vec<PatternRoute>,
fallback: Option<HttpHandler>, fallback: Option<HttpHandler>,
} }
@ -60,6 +93,43 @@ impl Router {
) )
} }
/// Register a route containing named single-segment parameters such as
/// `/api/get/{userid}/profile.json`.
pub fn route_pattern<F, Fut>(
self,
pattern: impl Into<String>,
handler: F,
) -> Result<Self, RouterError>
where
F: Fn(Http3Request, Http3Response, RouteParams) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Http3Response> + Send + 'static,
{
self.route_pattern_inner(
None,
pattern.into(),
Arc::new(move |request, response, params| Box::pin(handler(request, response, params))),
)
}
/// Register a method-specific route containing named single-segment
/// parameters.
pub fn route_pattern_method<F, Fut>(
self,
method: Method,
pattern: impl Into<String>,
handler: F,
) -> Result<Self, RouterError>
where
F: Fn(Http3Request, Http3Response, RouteParams) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Http3Response> + Send + 'static,
{
self.route_pattern_inner(
Some(method),
pattern.into(),
Arc::new(move |request, response, params| Box::pin(handler(request, response, params))),
)
}
pub fn fallback<F, Fut>(mut self, handler: F) -> Result<Self, RouterError> pub fn fallback<F, Fut>(mut self, handler: F) -> Result<Self, RouterError>
where where
F: Fn(Http3Request, Http3Response) -> Fut + Send + Sync + 'static, F: Fn(Http3Request, Http3Response) -> Fut + Send + Sync + 'static,
@ -90,17 +160,140 @@ impl Router {
Ok(self) Ok(self)
} }
fn route_pattern_inner(
mut self,
method: Option<Method>,
pattern: String,
handler: DynamicHttpHandler,
) -> Result<Self, RouterError> {
let segments = parse_pattern(&pattern)?;
if self
.pattern_routes
.iter()
.any(|route| route.method == method && route.pattern == pattern)
{
return Err(RouterError::DuplicateRoute(pattern));
}
let static_segments = segments
.iter()
.filter(|segment| matches!(segment, PatternSegment::Static(_)))
.count();
self.pattern_routes.push(PatternRoute {
method,
pattern,
segments,
static_segments,
handler,
});
Ok(self)
}
pub(crate) fn handler(&self, method: &Method, path: &str) -> Option<HttpHandler> { pub(crate) fn handler(&self, method: &Method, path: &str) -> Option<HttpHandler> {
self.routes self.routes
.get(&(Some(method.clone()), path.to_string())) .get(&(Some(method.clone()), path.to_string()))
.or_else(|| self.routes.get(&(None, path.to_string()))) .or_else(|| self.routes.get(&(None, path.to_string())))
.cloned() .cloned()
} }
pub(crate) fn pattern_handler(
&self,
method: &Method,
path: &str,
) -> Option<(DynamicHttpHandler, RouteParams)> {
self.pattern_routes
.iter()
.filter(|route| route.method.is_none() || route.method.as_ref() == Some(method))
.filter_map(|route| match_pattern(&route.segments, path).map(|params| (route, params)))
.max_by_key(|(route, _)| (route.method.is_some(), route.static_segments))
.map(|(route, params)| (route.handler.clone(), params))
}
pub(crate) fn fallback_handler(&self) -> Option<HttpHandler> { pub(crate) fn fallback_handler(&self) -> Option<HttpHandler> {
self.fallback.clone() self.fallback.clone()
} }
} }
fn parse_pattern(pattern: &str) -> Result<Vec<PatternSegment>, RouterError> {
let path = pattern.strip_prefix('/').unwrap_or(pattern);
let path = path.strip_suffix('/').unwrap_or(path);
if path.is_empty() {
return Ok(Vec::new());
}
path.split('/')
.map(|segment| {
if segment.starts_with('{') || segment.ends_with('}') {
if segment.len() < 3 || !segment.starts_with('{') || !segment.ends_with('}') {
return Err(RouterError::InvalidPattern(pattern.to_string()));
}
let name = &segment[1..segment.len() - 1];
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|| name.chars().next().is_some_and(|c| c.is_ascii_digit())
{
return Err(RouterError::InvalidPattern(pattern.to_string()));
}
Ok(PatternSegment::Parameter(name.to_string()))
} else if segment.contains('{') || segment.contains('}') {
Err(RouterError::InvalidPattern(pattern.to_string()))
} else {
Ok(PatternSegment::Static(segment.to_string()))
}
})
.collect()
}
fn match_pattern(segments: &[PatternSegment], path: &str) -> Option<RouteParams> {
let path = path.strip_prefix('/').unwrap_or(path);
let path = path.strip_suffix('/').unwrap_or(path);
let actual: Vec<&str> = if path.is_empty() {
Vec::new()
} else {
path.split('/').collect()
};
if actual.len() != segments.len() {
return None;
}
let mut params = RouteParams::new();
for (segment, value) in segments.iter().zip(actual) {
match segment {
PatternSegment::Static(expected) if expected != value => return None,
PatternSegment::Static(_) => {}
PatternSegment::Parameter(name) => {
params.insert(name.clone(), percent_decode(value)?);
}
}
}
Some(params)
}
fn percent_decode(value: &str) -> Option<String> {
let mut bytes = Vec::with_capacity(value.len());
let raw = value.as_bytes();
let mut index = 0;
while index < raw.len() {
if raw[index] == b'%' {
if index + 2 >= raw.len() {
return None;
}
let high = hex_digit(raw[index + 1])?;
let low = hex_digit(raw[index + 2])?;
bytes.push(high * 16 + low);
index += 3;
} else {
bytes.push(raw[index]);
index += 1;
}
}
String::from_utf8(bytes).ok()
}
fn hex_digit(value: u8) -> Option<u8> {
match value {
b'0'..=b'9' => Some(value - b'0'),
b'a'..=b'f' => Some(value - b'a' + 10),
b'A'..=b'F' => Some(value - b'A' + 10),
_ => None,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -126,4 +319,72 @@ mod tests {
assert_eq!(response.status, StatusCode::NO_CONTENT); assert_eq!(response.status, StatusCode::NO_CONTENT);
assert!(router.handler(&Method::GET, "/missing").is_none()); assert!(router.handler(&Method::GET, "/missing").is_none());
} }
#[tokio::test]
async fn route_pattern_extracts_decoded_parameters() {
let router = Router::new()
.route_pattern_method(
Method::GET,
"/api/get/{userid}/profile.json",
|_, response, params| async move {
response.body(params.get("userid").unwrap().clone())
},
)
.unwrap();
let (handler, params) = router
.pattern_handler(&Method::GET, "/api/get/user%2D123/profile.json")
.unwrap();
let request = Http3Request {
method: Method::GET,
uri: Uri::from_static("/api/get/user%2D123/profile.json"),
headers: Default::default(),
body: Some(Bytes::new()),
};
let response = handler(request, Http3Response::default(), params).await;
assert_eq!(response.body, vec![Bytes::from("user-123")]);
}
#[test]
fn route_pattern_rejects_invalid_patterns_and_extra_segments() {
assert!(matches!(
Router::new()
.route_pattern("/users/{user-id}", |_, response, _| async move { response }),
Err(RouterError::InvalidPattern(_))
));
let router = Router::new()
.route_pattern("/users/{userid}", |_, response, _| async move { response })
.unwrap();
assert!(
router
.pattern_handler(&Method::GET, "/users/alex/details")
.is_none()
);
}
#[tokio::test]
async fn method_specific_and_static_routes_win() {
let router = Router::new()
.route_pattern(
"/api/{resource}/profile.json",
|_, response, _| async move { response.status(StatusCode::ACCEPTED) },
)
.unwrap()
.route_pattern_method(
Method::GET,
"/api/users/profile.json",
|_, response, _| async move { response.status(StatusCode::CREATED) },
)
.unwrap();
let (handler, params) = router
.pattern_handler(&Method::GET, "/api/users/profile.json")
.unwrap();
let request = Http3Request {
method: Method::GET,
uri: Uri::from_static("/api/users/profile.json"),
headers: Default::default(),
body: None,
};
let response = handler(request, Http3Response::default(), params).await;
assert_eq!(response.status, StatusCode::CREATED);
}
} }

View file

@ -81,6 +81,36 @@ impl WebServerConfig {
Ok(self) Ok(self)
} }
/// Register a route containing named single-segment parameters, such as
/// `/api/get/{userid}/profile.json`.
pub fn route_pattern<F, Fut>(
mut self,
pattern: impl Into<String>,
handler: F,
) -> Result<Self, RouterError>
where
F: Fn(Http3Request, Http3Response, crate::RouteParams) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = Http3Response> + Send + 'static,
{
self.router = self.router.route_pattern(pattern, handler)?;
Ok(self)
}
/// Register a method-specific parameterized route.
pub fn route_pattern_method<F, Fut>(
mut self,
method: Method,
pattern: impl Into<String>,
handler: F,
) -> Result<Self, RouterError>
where
F: Fn(Http3Request, Http3Response, crate::RouteParams) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = Http3Response> + Send + 'static,
{
self.router = self.router.route_pattern_method(method, pattern, handler)?;
Ok(self)
}
pub fn fallback<F, Fut>(mut self, handler: F) -> Result<Self, RouterError> pub fn fallback<F, Fut>(mut self, handler: F) -> Result<Self, RouterError>
where where
F: Fn(Http3Request, Http3Response) -> Fut + Send + Sync + 'static, F: Fn(Http3Request, Http3Response) -> Fut + Send + Sync + 'static,
@ -483,19 +513,30 @@ where
)); ));
} }
let path = request.uri.path().to_string(); let path = request.uri.path().to_string();
let handler = router match router.handler(&request.method, &path) {
.handler(&request.method, &path)
.or_else(|| router.fallback_handler());
match handler {
Some(handler) => { Some(handler) => {
let response = handler(request, Http3Response::default()).await; let response = handler(request, Http3Response::default()).await;
let status = response.status; let status = response.status;
Ok((response, status)) Ok((response, status))
} }
None => Ok(( None => match router.pattern_handler(&request.method, &path) {
Http3Response::new(StatusCode::NOT_FOUND), Some((handler, params)) => {
StatusCode::NOT_FOUND, let response = handler(request, Http3Response::default(), params).await;
)), let status = response.status;
Ok((response, status))
}
None => match router.fallback_handler() {
Some(handler) => {
let response = handler(request, Http3Response::default()).await;
let status = response.status;
Ok((response, status))
}
None => Ok((
Http3Response::new(StatusCode::NOT_FOUND),
StatusCode::NOT_FOUND,
)),
},
},
} }
} }