Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 30 additions & 8 deletions crates/core/src/route_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Box<dyn Future<Output = Option<ProxyResult>> + Send + 'a>>;
pub(crate) type RouteHandlerFuture<'a> =
Pin<Box<dyn Future<Output = Option<ProxyResult>> + 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<Box<dyn Future<Output = Option<ProxyResult>> + 'a>>;
pub(crate) type RouteHandlerFuture<'a> = Pin<Box<dyn Future<Output = Option<ProxyResult>> + 'a>>;

/// Extracted path parameters from route matching.
///
Expand Down Expand Up @@ -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<ProxyResult> {
/// 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<Output = Option<ProxyResult>> + MaybeSend + 'a;
}

/// Object-safe adapter over [`RouteHandler`].
///
/// `RouteHandler::handle` returns `impl Future`, which makes the trait
/// non-object-safe. The router stores `Box<dyn ErasedRouteHandler>` 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<T: RouteHandler> ErasedRouteHandler for T {
fn handle<'a>(&'a self, req: &'a RequestInfo<'a>) -> RouteHandlerFuture<'a> {
Box::pin(<T as RouteHandler>::handle(self, req))
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
39 changes: 35 additions & 4 deletions crates/core/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
///
Expand All @@ -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<Box<dyn RouteHandler>>,
inner: matchit::Router<Box<dyn ErasedRouteHandler>>,
}

impl Router {
Expand Down Expand Up @@ -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<ProxyResult> {
(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]
Expand Down
14 changes: 7 additions & 7 deletions crates/oidc-provider/src/route_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<ProxyResult> {
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))
}
}

Expand All @@ -31,17 +31,17 @@ 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<ProxyResult> {
if req.method.as_str() != "GET" {
return Box::pin(async { None });
return None;
}
let keys: Vec<_> = self
.signers
.iter()
.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))
}
}

Expand Down
38 changes: 18 additions & 20 deletions crates/sts/src/route_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,25 +19,23 @@ struct StsHandler<C> {
}

impl<C: CredentialRegistry> RouteHandler for StsHandler<C> {
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<ProxyResult> {
// 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))
}
}

Expand Down
9 changes: 5 additions & 4 deletions docs/architecture/request-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProxyResult> {
(req.method == http::Method::GET).then(|| ProxyResult::json(200, r#"{"ok":true}"#))
}
}

Expand Down
Loading