From 88ae866b91857c9b1650ea0ee2af2fdb70b92f10 Mon Sep 17 00:00:00 2001 From: Alex-Emmet Date: Thu, 23 Jul 2026 23:54:11 +0200 Subject: [PATCH] [Fix] Allowing for a following / on paths --- mtp-webserver/src/http.rs | 32 ++++++++++++++++++++++++++++++++ mtp-webserver/src/router.rs | 29 ++++++++++++++++++++++++++--- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/mtp-webserver/src/http.rs b/mtp-webserver/src/http.rs index f7c5a64..b89650f 100644 --- a/mtp-webserver/src/http.rs +++ b/mtp-webserver/src/http.rs @@ -97,4 +97,36 @@ mod tests { StatusCode::CREATED ); } + + #[tokio::test] + async fn dispatch_matches_terminal_slashes_for_exact_and_pattern_routes() { + let router = Router::new() + .route("/api", |_, response| async move { + response.status(StatusCode::ACCEPTED) + }) + .unwrap() + .route_pattern("/api/test/{id}", |_, response, _| async move { + response.status(StatusCode::CREATED) + }) + .unwrap() + .fallback(|_, response| async move { response.status(StatusCode::IM_A_TEAPOT) }) + .unwrap(); + + for uri in ["/api/test", "/api/test/"] { + assert_eq!( + dispatch_request(request(Method::GET, uri), &router) + .await + .status, + StatusCode::ACCEPTED + ); + } + for uri in ["/api/test/1", "/api/test/1/"] { + assert_eq!( + dispatch_request(request(Method::GET, uri), &router) + .await + .status, + StatusCode::CREATED + ); + } + } } diff --git a/mtp-webserver/src/router.rs b/mtp-webserver/src/router.rs index 9c1cc4a..29a9aa3 100644 --- a/mtp-webserver/src/router.rs +++ b/mtp-webserver/src/router.rs @@ -150,6 +150,7 @@ impl Router { path: String, handler: HttpHandler, ) -> Result { + let path = normalize_path(&path); if self .routes .insert((method, path.clone()), handler) @@ -166,6 +167,7 @@ impl Router { pattern: String, handler: DynamicHttpHandler, ) -> Result { + let pattern = normalize_path(&pattern); let segments = parse_pattern(&pattern)?; if self .pattern_routes @@ -189,9 +191,10 @@ impl Router { } pub(crate) fn handler(&self, method: &Method, path: &str) -> Option { + let path = normalize_path(path); self.routes - .get(&(Some(method.clone()), path.to_string())) - .or_else(|| self.routes.get(&(None, path.to_string()))) + .get(&(Some(method.clone()), path.clone())) + .or_else(|| self.routes.get(&(None, path))) .cloned() } @@ -200,10 +203,11 @@ impl Router { method: &Method, path: &str, ) -> Option<(DynamicHttpHandler, RouteParams)> { + let path = normalize_path(path); 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))) + .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)) } @@ -212,6 +216,25 @@ impl Router { } } +/// Canonicalize a route path for matching. +/// +/// HTTP request paths are absolute, but accepting an omitted leading slash in +/// route registration is convenient. A terminal slash (or several terminal +/// slashes) does not identify a different resource, except for the root path. +fn normalize_path(path: &str) -> String { + let path = if path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") + }; + let normalized = path.trim_end_matches('/'); + if normalized.is_empty() { + "/".to_string() + } else { + normalized.to_string() + } +} + fn parse_pattern(pattern: &str) -> Result, RouterError> { let path = pattern.strip_prefix('/').unwrap_or(pattern); let path = path.strip_suffix('/').unwrap_or(path);