[Fix] Allowing for a following / on paths
Some checks failed
CI / checks (push) Failing after 3m17s

This commit is contained in:
Alex-Emmet 2026-07-23 23:54:11 +02:00
commit 88ae866b91
2 changed files with 58 additions and 3 deletions

View file

@ -97,4 +97,36 @@ mod tests {
StatusCode::CREATED 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
);
}
}
} }

View file

@ -150,6 +150,7 @@ impl Router {
path: String, path: String,
handler: HttpHandler, handler: HttpHandler,
) -> Result<Self, RouterError> { ) -> Result<Self, RouterError> {
let path = normalize_path(&path);
if self if self
.routes .routes
.insert((method, path.clone()), handler) .insert((method, path.clone()), handler)
@ -166,6 +167,7 @@ impl Router {
pattern: String, pattern: String,
handler: DynamicHttpHandler, handler: DynamicHttpHandler,
) -> Result<Self, RouterError> { ) -> Result<Self, RouterError> {
let pattern = normalize_path(&pattern);
let segments = parse_pattern(&pattern)?; let segments = parse_pattern(&pattern)?;
if self if self
.pattern_routes .pattern_routes
@ -189,9 +191,10 @@ impl Router {
} }
pub(crate) fn handler(&self, method: &Method, path: &str) -> Option<HttpHandler> { pub(crate) fn handler(&self, method: &Method, path: &str) -> Option<HttpHandler> {
let path = normalize_path(path);
self.routes self.routes
.get(&(Some(method.clone()), path.to_string())) .get(&(Some(method.clone()), path.clone()))
.or_else(|| self.routes.get(&(None, path.to_string()))) .or_else(|| self.routes.get(&(None, path)))
.cloned() .cloned()
} }
@ -200,10 +203,11 @@ impl Router {
method: &Method, method: &Method,
path: &str, path: &str,
) -> Option<(DynamicHttpHandler, RouteParams)> { ) -> Option<(DynamicHttpHandler, RouteParams)> {
let path = normalize_path(path);
self.pattern_routes self.pattern_routes
.iter() .iter()
.filter(|route| route.method.is_none() || route.method.as_ref() == Some(method)) .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)) .max_by_key(|(route, _)| (route.method.is_some(), route.static_segments))
.map(|(route, params)| (route.handler.clone(), params)) .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<Vec<PatternSegment>, RouterError> { fn parse_pattern(pattern: &str) -> Result<Vec<PatternSegment>, RouterError> {
let path = pattern.strip_prefix('/').unwrap_or(pattern); let path = pattern.strip_prefix('/').unwrap_or(pattern);
let path = path.strip_suffix('/').unwrap_or(path); let path = path.strip_suffix('/').unwrap_or(path);