use crate::http::{Request, Response};
pub const fn validate_route_path(path: &'static str) -> &'static str {
let bytes = path.as_bytes();
if bytes.is_empty() || bytes[0] != b'/' {
panic!("Route path must start with '/'")
}
path
}
use super::path::combine_group_path;
use super::router::update_route_mcp;
use crate::middleware::{into_boxed, BoxedMiddleware, Middleware};
use crate::routing::router::{register_route_name, BoxedHandler, Router};
use std::future::Future;
use std::sync::Arc;
fn convert_route_params(path: &str) -> String {
let mut result = String::with_capacity(path.len() + 4); let mut chars = path.chars().peekable();
while let Some(ch) = chars.next() {
if ch == ':' {
result.push('{');
while let Some(&next) = chars.peek() {
if next == '/' {
break;
}
result.push(chars.next().unwrap());
}
result.push('}');
} else {
result.push(ch);
}
}
result
}
#[derive(Clone, Copy)]
pub enum HttpMethod {
Get,
Post,
Put,
Patch,
Delete,
}
pub struct RouteDefBuilder<H> {
method: HttpMethod,
path: &'static str,
handler: H,
name: Option<&'static str>,
middlewares: Vec<BoxedMiddleware>,
mcp_tool_name: Option<String>,
mcp_description: Option<String>,
mcp_hint: Option<String>,
mcp_hidden: bool,
}
impl<H, Fut> RouteDefBuilder<H>
where
H: Fn(Request) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Response> + Send + 'static,
{
pub fn new(method: HttpMethod, path: &'static str, handler: H) -> Self {
Self {
method,
path,
handler,
name: None,
middlewares: Vec::new(),
mcp_tool_name: None,
mcp_description: None,
mcp_hint: None,
mcp_hidden: false,
}
}
pub fn name(mut self, name: &'static str) -> Self {
self.name = Some(name);
self
}
pub fn middleware<M: Middleware + 'static>(mut self, middleware: M) -> Self {
self.middlewares.push(into_boxed(middleware));
self
}
pub fn mcp_tool_name(mut self, name: &str) -> Self {
self.mcp_tool_name = Some(name.to_string());
self
}
pub fn mcp_description(mut self, desc: &str) -> Self {
self.mcp_description = Some(desc.to_string());
self
}
pub fn mcp_hint(mut self, hint: &str) -> Self {
self.mcp_hint = Some(hint.to_string());
self
}
pub fn mcp_hidden(mut self) -> Self {
self.mcp_hidden = true;
self
}
pub fn register(self, router: Router) -> Router {
let converted_path = convert_route_params(self.path);
let has_mcp = self.mcp_tool_name.is_some()
|| self.mcp_description.is_some()
|| self.mcp_hint.is_some()
|| self.mcp_hidden;
let mcp_tool_name = self.mcp_tool_name;
let mcp_description = self.mcp_description;
let mcp_hint = self.mcp_hint;
let mcp_hidden = self.mcp_hidden;
let builder = match self.method {
HttpMethod::Get => router.get(&converted_path, self.handler),
HttpMethod::Post => router.post(&converted_path, self.handler),
HttpMethod::Put => router.put(&converted_path, self.handler),
HttpMethod::Patch => router.patch(&converted_path, self.handler),
HttpMethod::Delete => router.delete(&converted_path, self.handler),
};
let builder = self
.middlewares
.into_iter()
.fold(builder, |b, m| b.middleware_boxed(m));
if has_mcp {
update_route_mcp(
&converted_path,
mcp_tool_name,
mcp_description,
mcp_hint,
mcp_hidden,
);
}
if let Some(name) = self.name {
builder.name(name)
} else {
builder.into()
}
}
}
#[macro_export]
macro_rules! get {
($path:expr, $handler:expr) => {{
const _: &str = $crate::validate_route_path($path);
$crate::__get_impl($path, $handler)
}};
}
#[doc(hidden)]
pub fn __get_impl<H, Fut>(path: &'static str, handler: H) -> RouteDefBuilder<H>
where
H: Fn(Request) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Response> + Send + 'static,
{
RouteDefBuilder::new(HttpMethod::Get, path, handler)
}
#[macro_export]
macro_rules! post {
($path:expr, $handler:expr) => {{
const _: &str = $crate::validate_route_path($path);
$crate::__post_impl($path, $handler)
}};
}
#[doc(hidden)]
pub fn __post_impl<H, Fut>(path: &'static str, handler: H) -> RouteDefBuilder<H>
where
H: Fn(Request) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Response> + Send + 'static,
{
RouteDefBuilder::new(HttpMethod::Post, path, handler)
}
#[macro_export]
macro_rules! put {
($path:expr, $handler:expr) => {{
const _: &str = $crate::validate_route_path($path);
$crate::__put_impl($path, $handler)
}};
}
#[doc(hidden)]
pub fn __put_impl<H, Fut>(path: &'static str, handler: H) -> RouteDefBuilder<H>
where
H: Fn(Request) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Response> + Send + 'static,
{
RouteDefBuilder::new(HttpMethod::Put, path, handler)
}
#[macro_export]
macro_rules! patch {
($path:expr, $handler:expr) => {{
const _: &str = $crate::validate_route_path($path);
$crate::__patch_impl($path, $handler)
}};
}
#[doc(hidden)]
pub fn __patch_impl<H, Fut>(path: &'static str, handler: H) -> RouteDefBuilder<H>
where
H: Fn(Request) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Response> + Send + 'static,
{
RouteDefBuilder::new(HttpMethod::Patch, path, handler)
}
#[macro_export]
macro_rules! delete {
($path:expr, $handler:expr) => {{
const _: &str = $crate::validate_route_path($path);
$crate::__delete_impl($path, $handler)
}};
}
#[doc(hidden)]
pub fn __delete_impl<H, Fut>(path: &'static str, handler: H) -> RouteDefBuilder<H>
where
H: Fn(Request) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Response> + Send + 'static,
{
RouteDefBuilder::new(HttpMethod::Delete, path, handler)
}
pub struct FallbackDefBuilder<H> {
handler: H,
middlewares: Vec<BoxedMiddleware>,
}
impl<H, Fut> FallbackDefBuilder<H>
where
H: Fn(Request) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Response> + Send + 'static,
{
pub fn new(handler: H) -> Self {
Self {
handler,
middlewares: Vec::new(),
}
}
pub fn middleware<M: Middleware + 'static>(mut self, middleware: M) -> Self {
self.middlewares.push(into_boxed(middleware));
self
}
pub fn register(self, mut router: Router) -> Router {
let handler = self.handler;
let boxed: BoxedHandler = Box::new(move |req| Box::pin(handler(req)));
router.set_fallback(Arc::new(boxed));
for mw in self.middlewares {
router.add_fallback_middleware(mw);
}
router
}
}
#[macro_export]
macro_rules! fallback {
($handler:expr) => {{
$crate::__fallback_impl($handler)
}};
}
#[doc(hidden)]
pub fn __fallback_impl<H, Fut>(handler: H) -> FallbackDefBuilder<H>
where
H: Fn(Request) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Response> + Send + 'static,
{
FallbackDefBuilder::new(handler)
}
pub struct GroupRoute {
method: HttpMethod,
path: &'static str,
handler: Arc<BoxedHandler>,
name: Option<&'static str>,
middlewares: Vec<BoxedMiddleware>,
mcp_tool_name: Option<String>,
mcp_description: Option<String>,
mcp_hint: Option<String>,
mcp_hidden: bool,
}
pub enum GroupItem {
Route(GroupRoute),
NestedGroup(Box<GroupDef>),
}
pub trait IntoGroupItem {
fn into_group_item(self) -> GroupItem;
}
#[derive(Default, Clone)]
struct McpDefaults {
tool_name: Option<String>,
description: Option<String>,
hint: Option<String>,
hidden: bool,
}
pub struct GroupDef {
prefix: &'static str,
items: Vec<GroupItem>,
group_middlewares: Vec<BoxedMiddleware>,
mcp_tool_name: Option<String>,
mcp_description: Option<String>,
mcp_hint: Option<String>,
mcp_hidden: bool,
}
impl GroupDef {
#[doc(hidden)]
pub fn __new_unchecked(prefix: &'static str) -> Self {
Self {
prefix,
items: Vec::new(),
group_middlewares: Vec::new(),
mcp_tool_name: None,
mcp_description: None,
mcp_hint: None,
mcp_hidden: false,
}
}
#[allow(clippy::should_implement_trait)]
pub fn add<I: IntoGroupItem>(mut self, item: I) -> Self {
self.items.push(item.into_group_item());
self
}
pub fn route<H, Fut>(self, route: RouteDefBuilder<H>) -> Self
where
H: Fn(Request) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Response> + Send + 'static,
{
self.add(route)
}
pub fn middleware<M: Middleware + 'static>(mut self, middleware: M) -> Self {
self.group_middlewares.push(into_boxed(middleware));
self
}
pub fn mcp_tool_name(mut self, name: &str) -> Self {
self.mcp_tool_name = Some(name.to_string());
self
}
pub fn mcp_description(mut self, desc: &str) -> Self {
self.mcp_description = Some(desc.to_string());
self
}
pub fn mcp_hint(mut self, hint: &str) -> Self {
self.mcp_hint = Some(hint.to_string());
self
}
pub fn mcp_hidden(mut self) -> Self {
self.mcp_hidden = true;
self
}
pub fn register(self, mut router: Router) -> Router {
let mcp_defaults = McpDefaults::default();
self.register_with_inherited(&mut router, "", &[], &mcp_defaults);
router
}
fn register_with_inherited(
self,
router: &mut Router,
parent_prefix: &str,
inherited_middleware: &[BoxedMiddleware],
inherited_mcp: &McpDefaults,
) {
let full_prefix = if parent_prefix.is_empty() {
self.prefix.to_string()
} else {
let stripped_parent = parent_prefix.strip_suffix('/').unwrap_or(parent_prefix);
format!("{}{}", stripped_parent, self.prefix)
};
let combined_middleware: Vec<BoxedMiddleware> = inherited_middleware
.iter()
.cloned()
.chain(self.group_middlewares.iter().cloned())
.collect();
let combined_mcp = McpDefaults {
tool_name: self.mcp_tool_name.or(inherited_mcp.tool_name.clone()),
description: self.mcp_description.or(inherited_mcp.description.clone()),
hint: self.mcp_hint.or(inherited_mcp.hint.clone()),
hidden: self.mcp_hidden || inherited_mcp.hidden,
};
for item in self.items {
match item {
GroupItem::Route(route) => {
let converted_route_path = convert_route_params(route.path);
let (canonical, alternate) =
combine_group_path(&full_prefix, &converted_route_path);
let canonical_path: &'static str = Box::leak(canonical.into_boxed_str());
let alternate_path: Option<&'static str> =
alternate.map(|s| Box::leak(s.into_boxed_str()) as &'static str);
match route.method {
HttpMethod::Get => {
router.insert_get(canonical_path, route.handler.clone());
if let Some(alt) = alternate_path {
router.insert_get_alias(alt, route.handler, canonical_path);
}
}
HttpMethod::Post => {
router.insert_post(canonical_path, route.handler.clone());
if let Some(alt) = alternate_path {
router.insert_post_alias(alt, route.handler, canonical_path);
}
}
HttpMethod::Put => {
router.insert_put(canonical_path, route.handler.clone());
if let Some(alt) = alternate_path {
router.insert_put_alias(alt, route.handler, canonical_path);
}
}
HttpMethod::Patch => {
router.insert_patch(canonical_path, route.handler.clone());
if let Some(alt) = alternate_path {
router.insert_patch_alias(alt, route.handler, canonical_path);
}
}
HttpMethod::Delete => {
router.insert_delete(canonical_path, route.handler.clone());
if let Some(alt) = alternate_path {
router.insert_delete_alias(alt, route.handler, canonical_path);
}
}
}
if let Some(name) = route.name {
register_route_name(name, canonical_path);
}
let mcp_tool_name = route.mcp_tool_name.or(combined_mcp.tool_name.clone());
let mcp_description =
route.mcp_description.or(combined_mcp.description.clone());
let mcp_hint = route.mcp_hint.or(combined_mcp.hint.clone());
let mcp_hidden = route.mcp_hidden || combined_mcp.hidden;
if mcp_tool_name.is_some()
|| mcp_description.is_some()
|| mcp_hint.is_some()
|| mcp_hidden
{
update_route_mcp(
canonical_path,
mcp_tool_name,
mcp_description,
mcp_hint,
mcp_hidden,
);
}
for mw in &combined_middleware {
router.add_middleware(canonical_path, mw.clone());
}
for mw in route.middlewares {
router.add_middleware(canonical_path, mw);
}
}
GroupItem::NestedGroup(nested) => {
nested.register_with_inherited(
router,
&full_prefix,
&combined_middleware,
&combined_mcp,
);
}
}
}
}
}
impl<H, Fut> RouteDefBuilder<H>
where
H: Fn(Request) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Response> + Send + 'static,
{
pub fn into_group_route(self) -> GroupRoute {
let handler = self.handler;
let boxed: BoxedHandler = Box::new(move |req| Box::pin(handler(req)));
GroupRoute {
method: self.method,
path: self.path,
handler: Arc::new(boxed),
name: self.name,
middlewares: self.middlewares,
mcp_tool_name: self.mcp_tool_name,
mcp_description: self.mcp_description,
mcp_hint: self.mcp_hint,
mcp_hidden: self.mcp_hidden,
}
}
}
impl<H, Fut> IntoGroupItem for RouteDefBuilder<H>
where
H: Fn(Request) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Response> + Send + 'static,
{
fn into_group_item(self) -> GroupItem {
GroupItem::Route(self.into_group_route())
}
}
impl IntoGroupItem for GroupDef {
fn into_group_item(self) -> GroupItem {
GroupItem::NestedGroup(Box::new(self))
}
}
#[macro_export]
macro_rules! group {
($prefix:expr, { $( $item:expr ),* $(,)? }) => {{
const _: &str = $crate::validate_route_path($prefix);
let mut group = $crate::GroupDef::__new_unchecked($prefix);
$(
group = group.add($item);
)*
group
}};
}
#[macro_export]
macro_rules! routes {
( $( $route:expr ),* $(,)? ) => {
pub fn register() -> $crate::Router {
let mut router = $crate::Router::new();
$(
router = $route.register(router);
)*
router
}
};
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ResourceAction {
Index,
Create,
Store,
Show,
Edit,
Update,
Destroy,
}
impl ResourceAction {
pub const fn all() -> &'static [ResourceAction] {
&[
ResourceAction::Index,
ResourceAction::Create,
ResourceAction::Store,
ResourceAction::Show,
ResourceAction::Edit,
ResourceAction::Update,
ResourceAction::Destroy,
]
}
pub const fn method(&self) -> HttpMethod {
match self {
ResourceAction::Index => HttpMethod::Get,
ResourceAction::Create => HttpMethod::Get,
ResourceAction::Store => HttpMethod::Post,
ResourceAction::Show => HttpMethod::Get,
ResourceAction::Edit => HttpMethod::Get,
ResourceAction::Update => HttpMethod::Put,
ResourceAction::Destroy => HttpMethod::Delete,
}
}
pub const fn path_suffix(&self) -> &'static str {
match self {
ResourceAction::Index => "/",
ResourceAction::Create => "/create",
ResourceAction::Store => "/",
ResourceAction::Show => "/{id}",
ResourceAction::Edit => "/{id}/edit",
ResourceAction::Update => "/{id}",
ResourceAction::Destroy => "/{id}",
}
}
pub const fn name_suffix(&self) -> &'static str {
match self {
ResourceAction::Index => "index",
ResourceAction::Create => "create",
ResourceAction::Store => "store",
ResourceAction::Show => "show",
ResourceAction::Edit => "edit",
ResourceAction::Update => "update",
ResourceAction::Destroy => "destroy",
}
}
}
pub struct ResourceRoute {
action: ResourceAction,
handler: Arc<BoxedHandler>,
}
pub struct ResourceDef {
prefix: &'static str,
routes: Vec<ResourceRoute>,
middlewares: Vec<BoxedMiddleware>,
}
impl ResourceDef {
#[doc(hidden)]
pub fn __new_unchecked(prefix: &'static str) -> Self {
Self {
prefix,
routes: Vec::new(),
middlewares: Vec::new(),
}
}
#[doc(hidden)]
pub fn __add_route(mut self, action: ResourceAction, handler: Arc<BoxedHandler>) -> Self {
self.routes.push(ResourceRoute { action, handler });
self
}
pub fn middleware<M: Middleware + 'static>(mut self, middleware: M) -> Self {
self.middlewares.push(into_boxed(middleware));
self
}
pub fn register(self, mut router: Router) -> Router {
let name_prefix = self.prefix.trim_start_matches('/').replace('/', ".");
for route in self.routes {
let action = route.action;
let path_suffix = action.path_suffix();
let full_path = if path_suffix == "/" {
self.prefix.to_string()
} else {
format!("{}{}", self.prefix, path_suffix)
};
let full_path: &'static str = Box::leak(full_path.into_boxed_str());
let route_name = format!("{}.{}", name_prefix, action.name_suffix());
let route_name: &'static str = Box::leak(route_name.into_boxed_str());
match action.method() {
HttpMethod::Get => {
router.insert_get(full_path, route.handler);
}
HttpMethod::Post => {
router.insert_post(full_path, route.handler);
}
HttpMethod::Put => {
router.insert_put(full_path, route.handler);
}
HttpMethod::Patch => {
router.insert_patch(full_path, route.handler);
}
HttpMethod::Delete => {
router.insert_delete(full_path, route.handler);
}
}
register_route_name(route_name, full_path);
for mw in &self.middlewares {
router.add_middleware(full_path, mw.clone());
}
}
router
}
}
#[doc(hidden)]
pub fn __box_handler<H, Fut>(handler: H) -> Arc<BoxedHandler>
where
H: Fn(Request) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Response> + Send + 'static,
{
let boxed: BoxedHandler = Box::new(move |req| Box::pin(handler(req)));
Arc::new(boxed)
}
#[macro_export]
macro_rules! resource {
($path:expr, $($controller:ident)::+) => {{
const _: &str = $crate::validate_route_path($path);
$crate::ResourceDef::__new_unchecked($path)
.__add_route($crate::ResourceAction::Index, $crate::__box_handler($($controller)::+::index))
.__add_route($crate::ResourceAction::Create, $crate::__box_handler($($controller)::+::create))
.__add_route($crate::ResourceAction::Store, $crate::__box_handler($($controller)::+::store))
.__add_route($crate::ResourceAction::Show, $crate::__box_handler($($controller)::+::show))
.__add_route($crate::ResourceAction::Edit, $crate::__box_handler($($controller)::+::edit))
.__add_route($crate::ResourceAction::Update, $crate::__box_handler($($controller)::+::update))
.__add_route($crate::ResourceAction::Destroy, $crate::__box_handler($($controller)::+::destroy))
}};
($path:expr, $($controller:ident)::+, only: [$($action:ident),* $(,)?]) => {{
const _: &str = $crate::validate_route_path($path);
let mut resource = $crate::ResourceDef::__new_unchecked($path);
$(
resource = resource.__add_route(
$crate::__resource_action!($action),
$crate::__box_handler($($controller)::+::$action)
);
)*
resource
}};
}
#[doc(hidden)]
#[macro_export]
macro_rules! __resource_action {
(index) => {
$crate::ResourceAction::Index
};
(create) => {
$crate::ResourceAction::Create
};
(store) => {
$crate::ResourceAction::Store
};
(show) => {
$crate::ResourceAction::Show
};
(edit) => {
$crate::ResourceAction::Edit
};
(update) => {
$crate::ResourceAction::Update
};
(destroy) => {
$crate::ResourceAction::Destroy
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_convert_route_params() {
assert_eq!(convert_route_params("/users/:id"), "/users/{id}");
assert_eq!(
convert_route_params("/posts/:post_id/comments/:id"),
"/posts/{post_id}/comments/{id}"
);
assert_eq!(convert_route_params("/users/{id}"), "/users/{id}");
assert_eq!(convert_route_params("/users"), "/users");
assert_eq!(convert_route_params("/"), "/");
assert_eq!(
convert_route_params("/users/:user_id/posts/{post_id}"),
"/users/{user_id}/posts/{post_id}"
);
assert_eq!(
convert_route_params("/api/v1/:version"),
"/api/v1/{version}"
);
}
async fn test_handler(_req: Request) -> Response {
crate::http::text("ok")
}
#[test]
fn test_group_item_route() {
let route_builder = RouteDefBuilder::new(HttpMethod::Get, "/test", test_handler);
let item = route_builder.into_group_item();
matches!(item, GroupItem::Route(_));
}
#[test]
fn test_group_item_nested_group() {
let group_def = GroupDef::__new_unchecked("/nested");
let item = group_def.into_group_item();
matches!(item, GroupItem::NestedGroup(_));
}
#[test]
fn test_group_add_route() {
let group = GroupDef::__new_unchecked("/api").add(RouteDefBuilder::new(
HttpMethod::Get,
"/users",
test_handler,
));
assert_eq!(group.items.len(), 1);
matches!(&group.items[0], GroupItem::Route(_));
}
#[test]
fn test_group_add_nested_group() {
let nested = GroupDef::__new_unchecked("/users");
let group = GroupDef::__new_unchecked("/api").add(nested);
assert_eq!(group.items.len(), 1);
matches!(&group.items[0], GroupItem::NestedGroup(_));
}
#[test]
fn test_group_mixed_items() {
let nested = GroupDef::__new_unchecked("/admin");
let group = GroupDef::__new_unchecked("/api")
.add(RouteDefBuilder::new(
HttpMethod::Get,
"/users",
test_handler,
))
.add(nested)
.add(RouteDefBuilder::new(
HttpMethod::Post,
"/users",
test_handler,
));
assert_eq!(group.items.len(), 3);
matches!(&group.items[0], GroupItem::Route(_));
matches!(&group.items[1], GroupItem::NestedGroup(_));
matches!(&group.items[2], GroupItem::Route(_));
}
#[test]
fn test_deep_nesting() {
let level3 = GroupDef::__new_unchecked("/level3").add(RouteDefBuilder::new(
HttpMethod::Get,
"/",
test_handler,
));
let level2 = GroupDef::__new_unchecked("/level2").add(level3);
let level1 = GroupDef::__new_unchecked("/level1").add(level2);
assert_eq!(level1.items.len(), 1);
if let GroupItem::NestedGroup(l2) = &level1.items[0] {
assert_eq!(l2.items.len(), 1);
if let GroupItem::NestedGroup(l3) = &l2.items[0] {
assert_eq!(l3.items.len(), 1);
} else {
panic!("Expected nested group at level 2");
}
} else {
panic!("Expected nested group at level 1");
}
}
#[test]
fn test_backward_compatibility_route_method() {
let group = GroupDef::__new_unchecked("/api").route(RouteDefBuilder::new(
HttpMethod::Get,
"/users",
test_handler,
));
assert_eq!(group.items.len(), 1);
matches!(&group.items[0], GroupItem::Route(_));
}
#[test]
fn group_root_handler_matches_both_variants() {
use crate::routing::get_registered_routes;
use hyper::Method;
let group = GroupDef::__new_unchecked("/api-d01").add(RouteDefBuilder::new(
HttpMethod::Get,
"/",
test_handler,
));
let router = group.register(Router::new());
let routes = get_registered_routes();
let count = routes.iter().filter(|r| r.path == "/api-d01").count();
assert_eq!(
count, 1,
"expected exactly 1 RouteInfo entry for /api-d01, got {count}"
);
let hit_canonical = router.match_route(&Method::GET, "/api-d01");
assert!(hit_canonical.is_some(), "canonical /api-d01 did not match");
assert_eq!(hit_canonical.unwrap().2, "/api-d01");
let hit_alternate = router.match_route(&Method::GET, "/api-d01/");
assert!(hit_alternate.is_some(), "alternate /api-d01/ did not match");
assert_eq!(
hit_alternate.unwrap().2,
"/api-d01",
"alternate leaf must carry canonical pattern for middleware lookup"
);
}
#[test]
fn root_prefix_root_handler_is_single_slash() {
use crate::routing::get_registered_routes;
use hyper::Method;
let group = GroupDef::__new_unchecked("/").add(RouteDefBuilder::new(
HttpMethod::Get,
"/",
test_handler,
));
let router = group.register(Router::new());
let hit = router.match_route(&Method::GET, "/");
assert!(hit.is_some(), "/ did not match");
assert_eq!(hit.unwrap().2, "/");
let double = router.match_route(&Method::GET, "//");
assert!(
double.is_none(),
"// must not be registered for root-in-root group"
);
let routes = get_registered_routes();
let slash_count = routes
.iter()
.filter(|r| r.path == "/" && r.method == "GET")
.count();
assert!(slash_count >= 1, "expected at least one GET / in registry");
}
#[test]
fn trailing_slash_prefix_is_stripped() {
use hyper::Method;
let group = GroupDef::__new_unchecked("/api/").add(RouteDefBuilder::new(
HttpMethod::Get,
"/x",
test_handler,
));
let router = group.register(Router::new());
let hit = router.match_route(&Method::GET, "/api/x");
assert!(
hit.is_some(),
"/api/x did not match after trailing-slash strip"
);
let double = router.match_route(&Method::GET, "/api//x");
assert!(
double.is_none(),
"/api//x should not match (double slash must not be registered)"
);
}
#[test]
fn non_root_prefix_non_root_path_unchanged() {
use crate::routing::get_registered_routes;
use hyper::Method;
let group = GroupDef::__new_unchecked("/api-d04").add(RouteDefBuilder::new(
HttpMethod::Get,
"/users",
test_handler,
));
let router = group.register(Router::new());
let hit = router.match_route(&Method::GET, "/api-d04/users");
assert!(hit.is_some(), "/api-d04/users did not match");
let alt = router.match_route(&Method::GET, "/api-d04/users/");
assert!(
alt.is_none(),
"/api-d04/users/ must not be registered (no alternate for non-root path)"
);
let routes = get_registered_routes();
let count = routes.iter().filter(|r| r.path == "/api-d04/users").count();
assert_eq!(
count, 1,
"expected exactly 1 RouteInfo for /api-d04/users, got {count}"
);
}
#[test]
fn nested_group_root_matches_both_variants() {
use hyper::Method;
let inner1 = GroupDef::__new_unchecked("/b").add(RouteDefBuilder::new(
HttpMethod::Get,
"/",
test_handler,
));
let router1 = GroupDef::__new_unchecked("/a")
.add(inner1)
.register(Router::new());
assert!(
router1.match_route(&Method::GET, "/a/b").is_some(),
"/a/b did not match"
);
assert!(
router1.match_route(&Method::GET, "/a/b/").is_some(),
"/a/b/ did not match"
);
let inner2 = GroupDef::__new_unchecked("/b").add(RouteDefBuilder::new(
HttpMethod::Get,
"/",
test_handler,
));
let router2 = GroupDef::__new_unchecked("/a/")
.add(inner2)
.register(Router::new());
assert!(
router2.match_route(&Method::GET, "/a/b").is_some(),
"/a/b did not match with trailing-slash outer"
);
assert!(
router2.match_route(&Method::GET, "/a/b/").is_some(),
"/a/b/ did not match with trailing-slash outer"
);
}
#[test]
fn named_route_resolves_to_canonical() {
use crate::routing::route;
let group = GroupDef::__new_unchecked("/api").add(
RouteDefBuilder::new(HttpMethod::Get, "/", test_handler).name("home_canonical_test"),
);
let _router = group.register(Router::new());
let url = route("home_canonical_test", &[]);
assert_eq!(
url,
Some("/api".to_string()),
"named route must resolve to canonical /api, not /api/"
);
}
#[test]
fn top_level_root_route_is_single_slash() {
use hyper::Method;
let router: Router = Router::new().get("/", test_handler).into();
let hit = router.match_route(&Method::GET, "/");
assert!(hit.is_some(), "top-level / did not match");
assert_eq!(hit.unwrap().2, "/", "top-level / pattern must be /");
let double = router.match_route(&Method::GET, "//");
assert!(
double.is_none(),
"// must not match from a top-level get!(\"/\") registration"
);
}
}