rust-webx-host 1.3.0

rust-webx HTTP layer: Host builder, middleware pipeline, Trie-based router, hyper integration
Documentation
//! Endpoint —IEndpoint implementations for dual-mode dispatch.

use serde_json;
use std::sync::Arc;
use webx_core::error::Result;
use webx_core::http::IHttpContext;
use webx_core::routing::IEndpoint;

use crate::authz::AuthorizerSet;
use crate::problem_response::{write_forbidden, write_problem};
use std::collections::HashMap;

/// Endpoint that wraps a boxed async handler.
#[allow(clippy::type_complexity)]
pub struct RequestEndpoint {
    handler: Box<
        dyn Fn(
                &mut dyn IHttpContext,
            )
                -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + '_>>
            + Send
            + Sync,
    >,
}

#[async_trait::async_trait]
impl IEndpoint for RequestEndpoint {
    async fn handle(&self, ctx: &mut dyn IHttpContext) -> Result<()> {
        (self.handler)(ctx).await
    }
}

impl RequestEndpoint {
    pub fn new<F>(f: F) -> Self
    where
        F: for<'a> Fn(
                &'a mut dyn IHttpContext,
            ) -> std::pin::Pin<
                Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>,
            > + Send
            + Sync
            + 'static,
    {
        Self {
            handler: Box::new(f),
        }
    }
}

/// A route endpoint that dispatches to the registered handler via cache.
///
/// When a dispatch function is available (generated by the endpoint macros),
/// it constructs the request from the live HTTP context, calls the handler
/// via the HandlerCache call bridge, and writes the response.
/// Falls back to a descriptive stub message when no dispatch function is registered.
pub struct StubEndpoint {
    pub method: &'static str,
    pub path: &'static str,
    pub handler_type: &'static str,
    pub dispatch_fn: Option<webx_core::route::scan::RouteDispatchFn>,
    /// Rules collected from `#[authorize]`; empty means the endpoint is public.
    /// Every rule must be satisfied (statically, or by the dynamic authorizers
    /// when [`Self::authorization_dynamic`] is `true`).
    pub authorization: &'static [webx_core::route::scan::AuthorizeRule],
    /// `true` when the endpoint declared `#[authorize(dynamic)]`.
    ///
    /// The static gate then only requires an authenticated principal (no claims
    /// → 401) and [`Self::authorization`] is handed to the dynamic authorizers,
    /// which decide. `false` — the default for every other route — means the
    /// claim snapshot must satisfy the rules before dispatch.
    pub authorization_dynamic: bool,
    /// Dynamic authorizers (from `IDynamicAuthorizer` DI registrations).
    /// Checked after the declarative rules (or instead of them on a dynamic
    /// endpoint) and before handler dispatch.
    /// When `None`, no dynamic authorization checks run (pass-through).
    pub authorizers: Option<Arc<AuthorizerSet>>,
}

#[async_trait::async_trait]
impl IEndpoint for StubEndpoint {
    async fn handle(&self, ctx: &mut dyn IHttpContext) -> Result<()> {
        if let Some(dispatch) = self.dispatch_fn {
            // ── Declarative authorization from `#[authorize]` ──
            // `authorization_dynamic` alone (hand-written metadata) also makes
            // the route protected: dynamic means "authenticated, then delegated".
            if !self.authorization.is_empty() || self.authorization_dynamic {
                if ctx.claims().is_none() {
                    // Single 401 write path: bubble the error up to the host
                    // so every unauthenticated response (declarative
                    // `#[authorize]`, binding layer, middleware) is written
                    // by one place — including the `WWW-Authenticate`
                    // challenge header.
                    return Err(webx_core::error::Error::Unauthorized(
                        "authentication required".to_string(),
                    ));
                }

                // Static mode: the claim snapshot must satisfy every rule.
                // `#[authorize(dynamic)]` skips this gate — the declaration is
                // the dynamic authorizer's input, not a claim check.
                if !self.authorization_dynamic {
                    let denied = {
                        let claims = ctx.claims().expect("claims presence is checked above");
                        self.authorization
                            .iter()
                            .find(|rule| !rule.matches(claims.roles(), claims.permissions()))
                            .map(|rule| (rule.roles, rule.permissions))
                    };

                    if let Some((roles, permissions)) = denied {
                        let mut ext = HashMap::new();
                        ext.insert("required_roles".into(), serde_json::json!(roles));
                        ext.insert(
                            "required_permissions".into(),
                            serde_json::json!(permissions),
                        );
                        write_forbidden(ctx, "Forbidden: insufficient permissions", ext).await;
                        return Ok(());
                    }
                }

                // ── Dynamic authorization via IDynamicAuthorizer ──
                if let Some(ref authorizers) = self.authorizers {
                    if !authorizers.is_empty() {
                        if let Some(claims) = ctx.claims() {
                            let resource_key = ctx.request().route_pattern().unwrap_or(self.path);
                            let method = ctx.request().method();
                            // Hand the endpoint's `#[authorize]` rules to the
                            // backend as the `&'static [AuthorizeRule]` slice
                            // itself (no per-request flattening or wrapper), so
                            // multiple rules keep their AND structure and a
                            // dynamic endpoint's whole declaration reaches the
                            // authorizer.
                            authorizers
                                .authorize_with_declared_rules(
                                    claims,
                                    resource_key,
                                    method,
                                    self.authorization,
                                )
                                .await?;
                        }
                    }
                }
            }

            let response = dispatch(&mut *ctx).await?;
            response.execute(&mut *ctx).await?;
            return Ok(());
        }

        // Missing dispatch — route registered but handler bridge not linked.
        write_problem(
            ctx,
            501,
            &format!(
                "Route handler not implemented: {} {} (request type: {})",
                self.method, self.path, self.handler_type
            ),
        )
        .await;
        Ok(())
    }
}

/// Endpoint for controller-based methods.
#[allow(clippy::type_complexity)]
pub struct ControllerEndpoint {
    handler: Box<
        dyn Fn(
                &mut dyn IHttpContext,
            )
                -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + '_>>
            + Send
            + Sync,
    >,
}

#[async_trait::async_trait]
impl IEndpoint for ControllerEndpoint {
    async fn handle(&self, ctx: &mut dyn IHttpContext) -> Result<()> {
        (self.handler)(ctx).await
    }
}

impl ControllerEndpoint {
    pub fn new<F>(f: F) -> Self
    where
        F: for<'a> Fn(
                &'a mut dyn IHttpContext,
            ) -> std::pin::Pin<
                Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>,
            > + Send
            + Sync
            + 'static,
    {
        Self {
            handler: Box::new(f),
        }
    }
}

/// Endpoint that serves a static JSON payload.
///
/// Used for built-in endpoints like `/openapi.json` and `/health`.
/// The `content_type` field allows RFC 8407 `application/health+json`
/// for health endpoints while defaulting to `application/json`.
pub struct StaticJsonEndpoint {
    pub body: Vec<u8>,
    pub content_type: &'static str,
}

impl StaticJsonEndpoint {
    /// Create a new endpoint with the default `application/json` content type.
    pub fn new(body: Vec<u8>) -> Self {
        Self {
            body,
            content_type: "application/json",
        }
    }
}

#[async_trait::async_trait]
impl IEndpoint for StaticJsonEndpoint {
    async fn handle(&self, ctx: &mut dyn IHttpContext) -> Result<()> {
        ctx.response_mut().set_status(200);
        ctx.response_mut()
            .set_header("content-type", self.content_type);
        ctx.response_mut().write_bytes(self.body.clone()).await
    }
}

/// Endpoint that serves a static HTML payload.
///
/// Used for built-in endpoints like `/api/docs`.
pub struct StaticHtmlEndpoint {
    pub body: &'static str,
}

#[async_trait::async_trait]
impl IEndpoint for StaticHtmlEndpoint {
    async fn handle(&self, ctx: &mut dyn IHttpContext) -> Result<()> {
        ctx.response_mut().set_status(200);
        ctx.response_mut()
            .set_header("content-type", "text/html; charset=utf-8");
        ctx.response_mut()
            .write_bytes(self.body.as_bytes().to_vec())
            .await
    }
}