diff --git a/docs/NATIVE-HOST-WEB-SERVER.md b/docs/NATIVE-HOST-WEB-SERVER.md index 2e2223e..e715d9b 100644 --- a/docs/NATIVE-HOST-WEB-SERVER.md +++ b/docs/NATIVE-HOST-WEB-SERVER.md @@ -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_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. | | `mtp_path(path)` | `/` | Path for WebTransport extended CONNECT. | | `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. +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 `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` for incremental response chunks. diff --git a/example/server/src/main.rs b/example/server/src/main.rs index 1affc0a..8ad5964 100644 --- a/example/server/src/main.rs +++ b/example/server/src/main.rs @@ -141,7 +141,7 @@ async fn main() -> Result<(), Box> { .await?; let mut host = mtp::webserver::MTPWebServer::new(config, web_server::config()?).await?; println!( - "Server listening on https://{} (TCP HTTPS + UDP WebTransport)", + "Server listening on https://{} (HTTPS + UDP WebTransport)", host.local_addr() ); diff --git a/example/server/src/web-server.rs b/example/server/src/web-server.rs index 29a6c5c..a9338d3 100644 --- a/example/server/src/web-server.rs +++ b/example/server/src/web-server.rs @@ -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 std::{ io, @@ -19,8 +19,32 @@ async fn ok(_request: Http3Request, response: Http3Response) -> Http3Response { .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::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 diff --git a/mtp-webserver/src/lib.rs b/mtp-webserver/src/lib.rs index 9612b45..a4b65ba 100644 --- a/mtp-webserver/src/lib.rs +++ b/mtp-webserver/src/lib.rs @@ -13,7 +13,7 @@ mod transport; pub use error::WebServerError; #[cfg(feature = "pipes")] 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 stream::{Http3Request, Http3Response}; pub use transport::{ diff --git a/mtp-webserver/src/router.rs b/mtp-webserver/src/router.rs index f21cf38..6c9582c 100644 --- a/mtp-webserver/src/router.rs +++ b/mtp-webserver/src/router.rs @@ -2,6 +2,9 @@ use crate::{Http3Request, Http3Response}; use http::Method; use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc}; +/// Values captured from a parameterized route. +pub type RouteParams = HashMap; + /// An asynchronous HTTP/3 route handler. pub type HttpHandler = Arc< dyn Fn(Http3Request, Http3Response) -> Pin + Send>> @@ -9,6 +12,17 @@ pub type HttpHandler = Arc< + Sync, >; +/// An asynchronous handler for a parameterized HTTP/3 route. +pub type DynamicHttpHandler = Arc< + dyn Fn( + Http3Request, + Http3Response, + RouteParams, + ) -> Pin + Send>> + + Send + + Sync, +>; + /// Errors returned by [`Router`] route registration. #[derive(Debug, thiserror::Error)] pub enum RouterError { @@ -17,12 +31,31 @@ pub enum RouterError { #[error("a router fallback is already registered")] DuplicateFallback, + + #[error("invalid route pattern: {0}")] + InvalidPattern(String), } -/// Exact-path HTTP route table used by [`MTPWebServer`](crate::MTPWebServer). +#[derive(Clone)] +struct PatternRoute { + method: Option, + pattern: String, + segments: Vec, + static_segments: usize, + handler: DynamicHttpHandler, +} + +#[derive(Clone)] +enum PatternSegment { + Static(String), + Parameter(String), +} + +/// HTTP route table used by [`MTPWebServer`](crate::MTPWebServer). #[derive(Clone, Default)] pub struct Router { routes: HashMap<(Option, String), HttpHandler>, + pattern_routes: Vec, fallback: Option, } @@ -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( + self, + pattern: impl Into, + handler: F, + ) -> Result + where + F: Fn(Http3Request, Http3Response, RouteParams) -> Fut + Send + Sync + 'static, + Fut: Future + 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( + self, + method: Method, + pattern: impl Into, + handler: F, + ) -> Result + where + F: Fn(Http3Request, Http3Response, RouteParams) -> Fut + Send + Sync + 'static, + Fut: Future + 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(mut self, handler: F) -> Result where F: Fn(Http3Request, Http3Response) -> Fut + Send + Sync + 'static, @@ -90,17 +160,140 @@ impl Router { Ok(self) } + fn route_pattern_inner( + mut self, + method: Option, + pattern: String, + handler: DynamicHttpHandler, + ) -> Result { + 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 { self.routes .get(&(Some(method.clone()), path.to_string())) .or_else(|| self.routes.get(&(None, path.to_string()))) .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 { self.fallback.clone() } } +fn parse_pattern(pattern: &str) -> Result, 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 { + 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 { + 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 { + 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)] mod tests { use super::*; @@ -126,4 +319,72 @@ mod tests { assert_eq!(response.status, StatusCode::NO_CONTENT); 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); + } } diff --git a/mtp-webserver/src/server.rs b/mtp-webserver/src/server.rs index c97fb62..a74c74c 100644 --- a/mtp-webserver/src/server.rs +++ b/mtp-webserver/src/server.rs @@ -81,6 +81,36 @@ impl WebServerConfig { Ok(self) } + /// Register a route containing named single-segment parameters, such as + /// `/api/get/{userid}/profile.json`. + pub fn route_pattern( + mut self, + pattern: impl Into, + handler: F, + ) -> Result + where + F: Fn(Http3Request, Http3Response, crate::RouteParams) -> Fut + Send + Sync + 'static, + Fut: std::future::Future + Send + 'static, + { + self.router = self.router.route_pattern(pattern, handler)?; + Ok(self) + } + + /// Register a method-specific parameterized route. + pub fn route_pattern_method( + mut self, + method: Method, + pattern: impl Into, + handler: F, + ) -> Result + where + F: Fn(Http3Request, Http3Response, crate::RouteParams) -> Fut + Send + Sync + 'static, + Fut: std::future::Future + Send + 'static, + { + self.router = self.router.route_pattern_method(method, pattern, handler)?; + Ok(self) + } + pub fn fallback(mut self, handler: F) -> Result where F: Fn(Http3Request, Http3Response) -> Fut + Send + Sync + 'static, @@ -483,19 +513,30 @@ where )); } let path = request.uri.path().to_string(); - let handler = router - .handler(&request.method, &path) - .or_else(|| router.fallback_handler()); - match handler { + match router.handler(&request.method, &path) { 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, - )), + None => match router.pattern_handler(&request.method, &path) { + Some((handler, params)) => { + 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, + )), + }, + }, } }