diff --git a/crates/core/src/route_handler.rs b/crates/core/src/route_handler.rs index a1d0acb..b689f7b 100644 --- a/crates/core/src/route_handler.rs +++ b/crates/core/src/route_handler.rs @@ -180,13 +180,14 @@ pub fn filter_response_headers(source: &http::HeaderMap) -> http::HeaderMap { out } -/// The future type returned by [`RouteHandler::handle`]. +/// Boxed future used by [`ErasedRouteHandler`] to type-erase handlers. #[cfg(not(target_arch = "wasm32"))] -pub type RouteHandlerFuture<'a> = Pin> + Send + 'a>>; +pub(crate) type RouteHandlerFuture<'a> = + Pin> + Send + 'a>>; -/// The future type returned by [`RouteHandler::handle`]. +/// Boxed future used by [`ErasedRouteHandler`] to type-erase handlers. #[cfg(target_arch = "wasm32")] -pub type RouteHandlerFuture<'a> = Pin> + 'a>>; +pub(crate) type RouteHandlerFuture<'a> = Pin> + 'a>>; /// Extracted path parameters from route matching. /// @@ -412,27 +413,48 @@ impl<'a> RequestInfo<'a> { /// - `Some(result)` to handle the request (stops further handler checks) /// - `None` to pass the request to the next handler or the proxy /// +/// `handle` is a native async method: implement it with `async fn` and the +/// router boxes the future internally, the same way [`Middleware`] is erased. +/// /// ```rust,ignore /// struct HealthCheck; /// /// impl RouteHandler for HealthCheck { -/// fn handle<'a>(&'a self, _req: &'a RequestInfo<'a>) -> RouteHandlerFuture<'a> { -/// Box::pin(async move { -/// Some(ProxyResult::json(200, r#"{"ok":true}"#)) -/// }) +/// async fn handle<'a>(&'a self, _req: &'a RequestInfo<'a>) -> Option { +/// Some(ProxyResult::json(200, r#"{"ok":true}"#)) /// } /// } /// /// router.route("/health", HealthCheck); /// ``` +/// +/// [`Middleware`]: crate::middleware::Middleware pub trait RouteHandler: MaybeSend + MaybeSync { /// Handle an incoming request. /// /// Return `Some(result)` to short-circuit, or `None` to fall through /// to the next handler or the proxy dispatch pipeline. + fn handle<'a>( + &'a self, + req: &'a RequestInfo<'a>, + ) -> impl Future> + MaybeSend + 'a; +} + +/// Object-safe adapter over [`RouteHandler`]. +/// +/// `RouteHandler::handle` returns `impl Future`, which makes the trait +/// non-object-safe. The router stores `Box` instead; +/// this blanket impl boxes the future so implementors never have to. +pub(crate) trait ErasedRouteHandler: MaybeSend + MaybeSync { fn handle<'a>(&'a self, req: &'a RequestInfo<'a>) -> RouteHandlerFuture<'a>; } +impl ErasedRouteHandler for T { + fn handle<'a>(&'a self, req: &'a RequestInfo<'a>) -> RouteHandlerFuture<'a> { + Box::pin(::handle(self, req)) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/core/src/router.rs b/crates/core/src/router.rs index 970b644..cf22a51 100644 --- a/crates/core/src/router.rs +++ b/crates/core/src/router.rs @@ -5,8 +5,8 @@ //! register their routes via extension traits on `Router` (e.g. `OidcRouterExt`, //! `StsRouterExt`), making integration a single chained call. //! -//! Handlers implement `RouteHandler` and override individual HTTP method -//! handlers (`get`, `post`, etc.) or `handle` directly: +//! Handlers implement [`RouteHandler::handle`] as an `async fn` and return +//! `Some(result)` to answer the request or `None` to fall through: //! //! ```rust,ignore //! use multistore::router::Router; @@ -15,7 +15,7 @@ //! .route("/api/health", HealthCheck); //! ``` -use crate::route_handler::{HandlerAction, Params, RequestInfo, RouteHandler}; +use crate::route_handler::{ErasedRouteHandler, HandlerAction, Params, RequestInfo, RouteHandler}; /// Path-based request router. /// @@ -27,7 +27,7 @@ use crate::route_handler::{HandlerAction, Params, RequestInfo, RouteHandler}; /// registering `/.well-known/openid-configuration` alongside `/{*path}` /// will always route OIDC discovery before the catch-all. pub struct Router { - inner: matchit::Router>, + inner: matchit::Router>, } impl Router { @@ -87,6 +87,37 @@ impl Default for Router { #[cfg(test)] mod tests { + use super::*; + use crate::route_handler::ProxyResult; + + /// A handler written the way integrators will write one: a plain + /// `async fn`, no manual boxing. + struct HealthCheck; + + impl RouteHandler for HealthCheck { + async fn handle<'a>(&'a self, req: &'a RequestInfo<'a>) -> Option { + (req.method == http::Method::GET).then(|| ProxyResult::json(200, r#"{"ok":true}"#)) + } + } + + #[test] + fn async_fn_handler_dispatches_and_falls_through() { + let router = Router::new().route("/health", HealthCheck); + let headers = http::HeaderMap::new(); + let get = RequestInfo::new(&http::Method::GET, "/health", None, &headers, None); + let post = RequestInfo::new(&http::Method::POST, "/health", None, &headers, None); + let other = RequestInfo::new(&http::Method::GET, "/nope", None, &headers, None); + + futures::executor::block_on(async { + assert!(matches!( + router.dispatch(&get).await, + Some(HandlerAction::Response(r)) if r.status == 200 + )); + assert!(router.dispatch(&post).await.is_none(), "handler declined"); + assert!(router.dispatch(&other).await.is_none(), "no route matched"); + }); + } + /// `matchit`'s `/{*path}` catch-all does NOT match the bare root `/`. /// Route handlers that need to match `/` must register an explicit `/` route. #[test] diff --git a/crates/oidc-provider/src/route_handler.rs b/crates/oidc-provider/src/route_handler.rs index 76a1f9d..b5005a3 100644 --- a/crates/oidc-provider/src/route_handler.rs +++ b/crates/oidc-provider/src/route_handler.rs @@ -6,7 +6,7 @@ use crate::discovery::openid_configuration_json; use crate::jwks::jwks_json; use crate::jwt::JwtSigner; -use multistore::route_handler::{ProxyResult, RequestInfo, RouteHandler, RouteHandlerFuture}; +use multistore::route_handler::{ProxyResult, RequestInfo, RouteHandler}; use multistore::router::Router; /// Handler that serves the OpenID Connect discovery document. @@ -16,12 +16,12 @@ struct OidcConfigHandler { } impl RouteHandler for OidcConfigHandler { - fn handle<'a>(&'a self, req: &'a RequestInfo<'a>) -> RouteHandlerFuture<'a> { + async fn handle<'a>(&'a self, req: &'a RequestInfo<'a>) -> Option { if req.method.as_str() != "GET" { - return Box::pin(async { None }); + return None; } let json = openid_configuration_json(&self.issuer, &self.jwks_uri); - Box::pin(async move { Some(ProxyResult::json(200, json)) }) + Some(ProxyResult::json(200, json)) } } @@ -31,9 +31,9 @@ struct OidcJwksHandler { } impl RouteHandler for OidcJwksHandler { - fn handle<'a>(&'a self, req: &'a RequestInfo<'a>) -> RouteHandlerFuture<'a> { + async fn handle<'a>(&'a self, req: &'a RequestInfo<'a>) -> Option { if req.method.as_str() != "GET" { - return Box::pin(async { None }); + return None; } let keys: Vec<_> = self .signers @@ -41,7 +41,7 @@ impl RouteHandler for OidcJwksHandler { .map(|s| (s.public_key(), s.kid())) .collect(); let json = jwks_json(&keys); - Box::pin(async move { Some(ProxyResult::json(200, json)) }) + Some(ProxyResult::json(200, json)) } } diff --git a/crates/sts/src/route_handler.rs b/crates/sts/src/route_handler.rs index 5f0e50d..41762ff 100644 --- a/crates/sts/src/route_handler.rs +++ b/crates/sts/src/route_handler.rs @@ -7,7 +7,7 @@ use crate::{ handle_get_caller_identity, is_get_caller_identity, try_handle_sts, JwksCache, TokenKey, }; use multistore::registry::CredentialRegistry; -use multistore::route_handler::{ProxyResult, RequestInfo, RouteHandler, RouteHandlerFuture}; +use multistore::route_handler::{ProxyResult, RequestInfo, RouteHandler}; use multistore::router::Router; /// Handler that intercepts STS `AssumeRoleWithWebIdentity` and @@ -19,25 +19,23 @@ struct StsHandler { } impl RouteHandler for StsHandler { - fn handle<'a>(&'a self, req: &'a RequestInfo<'a>) -> RouteHandlerFuture<'a> { - Box::pin(async move { - // GetCallerIdentity is authenticated (SigV4 over the temporary - // credentials) and needs the full request, so it is dispatched - // before the unauthenticated AssumeRoleWithWebIdentity exchange. - if is_get_caller_identity(req.query) || is_get_caller_identity(req.form_body) { - let (status, xml) = handle_get_caller_identity(req, self.key.as_ref()); - return Some(ProxyResult::xml(status, xml)); - } - let (status, xml) = try_handle_sts( - req.query, - req.form_body, - &self.config, - &self.cache, - self.key.as_ref(), - ) - .await?; - Some(ProxyResult::xml(status, xml)) - }) + async fn handle<'a>(&'a self, req: &'a RequestInfo<'a>) -> Option { + // GetCallerIdentity is authenticated (SigV4 over the temporary + // credentials) and needs the full request, so it is dispatched + // before the unauthenticated AssumeRoleWithWebIdentity exchange. + if is_get_caller_identity(req.query) || is_get_caller_identity(req.form_body) { + let (status, xml) = handle_get_caller_identity(req, self.key.as_ref()); + return Some(ProxyResult::xml(status, xml)); + } + let (status, xml) = try_handle_sts( + req.query, + req.form_body, + &self.config, + &self.cache, + self.key.as_ref(), + ) + .await?; + Some(ProxyResult::xml(status, xml)) } } diff --git a/docs/architecture/request-lifecycle.md b/docs/architecture/request-lifecycle.md index 2a99aa2..50d0019 100644 --- a/docs/architecture/request-lifecycle.md +++ b/docs/architecture/request-lifecycle.md @@ -71,18 +71,19 @@ Built-in route handlers: - **`OidcRouterExt`** (`multistore-oidc-provider`) — Registers handlers for `/.well-known/openid-configuration` and `/.well-known/jwks.json` - **`StsRouterExt`** (`multistore-sts`) — Registers a handler that intercepts `AssumeRoleWithWebIdentity` STS requests -### Method routing +### Implementing a handler -Handlers implement the `RouteHandler` trait and override individual HTTP method handlers (`get`, `post`, `put`, `delete`, `head`) for method-specific behavior, or override `handle` directly for method-agnostic handlers: +Handlers implement the `RouteHandler` trait's single method, `handle`, as a native `async fn`. Return `Some(result)` to answer the request, or `None` to decline and let it fall through to the next handler or the S3 pipeline. The router boxes the future internally, so no `Box::pin` is needed (the same erasure pattern `Middleware` uses): ```rust +use multistore::route_handler::{ProxyResult, RequestInfo, RouteHandler}; use multistore::router::Router; struct HealthCheck; impl RouteHandler for HealthCheck { - fn get<'a>(&'a self, _req: &'a RequestInfo<'a>) -> RouteHandlerFuture<'a> { - Box::pin(async { Some(ProxyResult::json(200, r#"{"ok":true}"#)) }) + async fn handle<'a>(&'a self, req: &'a RequestInfo<'a>) -> Option { + (req.method == http::Method::GET).then(|| ProxyResult::json(200, r#"{"ok":true}"#)) } }