use arcature::Result;
use arcature::routing::{
IntoRoutes, Middleware, Next, Request, Response, Route, RouteGroup, Routes,
};
use axum::body::Body;
use axum::http::Request as HttpRequest;
use axum::http::StatusCode;
use std::future::Future;
use std::pin::Pin;
use tower::ServiceExt;
#[derive(Clone)]
struct Stamp(&'static str);
impl Middleware for Stamp {
fn handle(
&self,
request: Request,
next: Next,
) -> Pin<Box<dyn Future<Output = Result<Response>> + Send>> {
let name = self.0;
Box::pin(async move {
let mut response = next.run(request).await;
response
.headers_mut()
.append("x-stamp", name.parse().expect("header value"));
Ok(response)
})
}
}
#[derive(Clone)]
struct Deny;
impl Middleware for Deny {
fn handle(
&self,
_request: Request,
_next: Next,
) -> Pin<Box<dyn Future<Output = Result<Response>> + Send>> {
Box::pin(async move {
Ok(axum::response::IntoResponse::into_response((
StatusCode::FORBIDDEN,
"denied",
)))
})
}
}
async fn ok() -> &'static str {
"ok"
}
async fn call(router: axum::Router, uri: &str) -> (StatusCode, Vec<String>) {
let response = router
.oneshot(
HttpRequest::builder()
.uri(uri)
.body(Body::empty())
.expect("request"),
)
.await
.expect("infallible");
let status = response.status();
let stamps = response
.headers()
.get_all("x-stamp")
.iter()
.map(|v| v.to_str().expect("utf-8").to_string())
.collect();
(status, stamps)
}
#[tokio::test]
async fn route_middleware_does_not_leak_onto_sibling_routes() {
let router = Routes::new([
Route::get("/plain", ok),
Route::get("/stamped", ok).middleware(Stamp("route")),
])
.into_router();
let (_, plain) = call(router.clone(), "/plain").await;
assert!(
plain.is_empty(),
"a route with no middleware must not inherit a sibling's: {plain:?}"
);
let (_, stamped) = call(router, "/stamped").await;
assert_eq!(stamped, ["route"]);
}
#[tokio::test]
async fn route_middleware_does_not_leak_regardless_of_declaration_order() {
let router = Routes::new([
Route::get("/stamped", ok).middleware(Stamp("route")),
Route::get("/plain", ok),
])
.into_router();
let (_, plain) = call(router, "/plain").await;
assert!(plain.is_empty(), "leaked forwards: {plain:?}");
}
#[tokio::test]
async fn a_guard_on_one_route_leaves_the_others_reachable() {
let router = Routes::new([
Route::get("/public", ok),
Route::get("/private", ok).middleware(Deny),
])
.into_router();
let (public, _) = call(router.clone(), "/public").await;
assert_eq!(public, StatusCode::OK, "public route was denied");
let (private, _) = call(router, "/private").await;
assert_eq!(private, StatusCode::FORBIDDEN, "guard did not run");
}
#[tokio::test]
async fn group_middleware_reaches_every_route_in_the_group_and_no_others() {
let mut routes = vec![Route::get("/open", ok)];
routes.extend(
RouteGroup::new("/admin", [Route::get("/a", ok), Route::get("/b", ok)])
.middleware(Stamp("group"))
.into_routes(),
);
let router = Routes::new(routes).into_router();
let (_, a) = call(router.clone(), "/admin/a").await;
assert_eq!(a, ["group"]);
let (_, b) = call(router.clone(), "/admin/b").await;
assert_eq!(b, ["group"]);
let (_, open) = call(router, "/open").await;
assert!(
open.is_empty(),
"group middleware escaped the group: {open:?}"
);
}
#[tokio::test]
async fn group_and_route_middleware_both_run() {
let router = Routes::new(
RouteGroup::new("/admin", [Route::get("/a", ok).middleware(Stamp("route"))])
.middleware(Stamp("group"))
.into_routes(),
)
.into_router();
let (_, stamps) = call(router, "/admin/a").await;
assert_eq!(stamps.len(), 2, "expected both stamps, got {stamps:?}");
assert!(stamps.contains(&"route".to_string()));
assert!(stamps.contains(&"group".to_string()));
}
#[tokio::test]
async fn collection_middleware_wraps_every_route_in_the_collection() {
let router = Routes::new([Route::get("/a", ok), Route::get("/b", ok)])
.middleware(Stamp("all"))
.into_router();
for path in ["/a", "/b"] {
let (_, stamps) = call(router.clone(), path).await;
assert_eq!(stamps, ["all"], "{path}");
}
}
#[tokio::test]
async fn merged_collections_keep_their_own_middleware() {
let public = Routes::new([Route::get("/public", ok)]);
let admin = Routes::new([Route::get("/admin", ok)]).middleware(Stamp("admin"));
let router = public.merge(admin).into_router();
let (_, open) = call(router.clone(), "/public").await;
assert!(
open.is_empty(),
"a merged collection's middleware leaked: {open:?}"
);
let (_, guarded) = call(router, "/admin").await;
assert_eq!(guarded, ["admin"]);
}
#[tokio::test]
async fn two_methods_on_one_path_still_share_the_path() {
let router = Routes::new([Route::get("/thing", ok), Route::post("/thing", ok)]).into_router();
let get = router
.clone()
.oneshot(
HttpRequest::builder()
.uri("/thing")
.body(Body::empty())
.expect("request"),
)
.await
.expect("infallible");
assert_eq!(get.status(), StatusCode::OK);
let post = router
.oneshot(
HttpRequest::builder()
.method("POST")
.uri("/thing")
.body(Body::empty())
.expect("request"),
)
.await
.expect("infallible");
assert_eq!(post.status(), StatusCode::OK);
}
#[tokio::test]
async fn per_method_middleware_stays_on_its_method() {
let router = Routes::new([
Route::get("/thing", ok),
Route::post("/thing", ok).middleware(Deny),
])
.into_router();
let get = router
.clone()
.oneshot(
HttpRequest::builder()
.uri("/thing")
.body(Body::empty())
.expect("request"),
)
.await
.expect("infallible");
assert_eq!(
get.status(),
StatusCode::OK,
"GET was denied by POST's guard"
);
let post = router
.oneshot(
HttpRequest::builder()
.method("POST")
.uri("/thing")
.body(Body::empty())
.expect("request"),
)
.await
.expect("infallible");
assert_eq!(post.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn named_routes_survive_grouping() {
let routes: Routes = Routes::new(
RouteGroup::new(
"/admin",
[Route::get("/users/{id}", ok).name("admin.users.show")],
)
.middleware(Stamp("group"))
.into_routes(),
);
assert_eq!(
routes.url_for("admin.users.show", &["7"]).expect("url"),
"/admin/users/7"
);
}