Skip to main content

webx_host/
endpoint.rs

1//! Endpoint —IEndpoint implementations for dual-mode dispatch.
2
3use serde_json;
4use std::sync::Arc;
5use webx_core::auth::DeclaredRules;
6use webx_core::error::Result;
7use webx_core::http::IHttpContext;
8use webx_core::routing::IEndpoint;
9
10use crate::authz::AuthorizerSet;
11use crate::problem_response::{write_forbidden, write_problem};
12use std::collections::HashMap;
13
14/// Endpoint that wraps a boxed async handler.
15#[allow(clippy::type_complexity)]
16pub struct RequestEndpoint {
17    handler: Box<
18        dyn Fn(
19                &mut dyn IHttpContext,
20            )
21                -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + '_>>
22            + Send
23            + Sync,
24    >,
25}
26
27#[async_trait::async_trait]
28impl IEndpoint for RequestEndpoint {
29    async fn handle(&self, ctx: &mut dyn IHttpContext) -> Result<()> {
30        (self.handler)(ctx).await
31    }
32}
33
34impl RequestEndpoint {
35    pub fn new<F>(f: F) -> Self
36    where
37        F: for<'a> Fn(
38                &'a mut dyn IHttpContext,
39            ) -> std::pin::Pin<
40                Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>,
41            > + Send
42            + Sync
43            + 'static,
44    {
45        Self {
46            handler: Box::new(f),
47        }
48    }
49}
50
51/// A route endpoint that dispatches to the registered handler via cache.
52///
53/// When a dispatch function is available (generated by the endpoint macros),
54/// it constructs the request from the live HTTP context, calls the handler
55/// via the HandlerCache call bridge, and writes the response.
56/// Falls back to a descriptive stub message when no dispatch function is registered.
57pub struct StubEndpoint {
58    pub method: &'static str,
59    pub path: &'static str,
60    pub handler_type: &'static str,
61    pub dispatch_fn: Option<webx_core::route::scan::RouteDispatchFn>,
62    /// Rules collected from `#[authorize]`; empty means the endpoint is public.
63    /// Every rule must be satisfied.
64    pub authorization: &'static [webx_core::route::scan::AuthorizeRule],
65    /// Dynamic authorizers (from `IDynamicAuthorizer` DI registrations).
66    /// Checked after the declarative rules and before handler dispatch.
67    /// When `None`, no dynamic authorization checks run (pass-through).
68    pub authorizers: Option<Arc<AuthorizerSet>>,
69}
70
71#[async_trait::async_trait]
72impl IEndpoint for StubEndpoint {
73    async fn handle(&self, ctx: &mut dyn IHttpContext) -> Result<()> {
74        if let Some(dispatch) = self.dispatch_fn {
75            // ── Declarative authorization from `#[authorize]` ──
76            if !self.authorization.is_empty() {
77                let denied = match ctx.claims() {
78                    None => {
79                        // Single 401 write path: bubble the error up to the host
80                        // so every unauthenticated response (declarative
81                        // `#[authorize]`, binding layer, middleware) is written
82                        // by one place — including the `WWW-Authenticate`
83                        // challenge header.
84                        return Err(webx_core::error::Error::Unauthorized(
85                            "authentication required".to_string(),
86                        ));
87                    }
88                    Some(claims) => self
89                        .authorization
90                        .iter()
91                        .find(|rule| !rule.matches(claims.roles(), claims.permissions()))
92                        .map(|rule| (rule.roles, rule.permissions)),
93                };
94
95                if let Some((roles, permissions)) = denied {
96                    let mut ext = HashMap::new();
97                    ext.insert("required_roles".into(), serde_json::json!(roles));
98                    ext.insert(
99                        "required_permissions".into(),
100                        serde_json::json!(permissions),
101                    );
102                    write_forbidden(ctx, "Forbidden: insufficient permissions", ext).await;
103                    return Ok(());
104                }
105
106                // ── Dynamic authorization via IDynamicAuthorizer ──
107                if let Some(ref authorizers) = self.authorizers {
108                    if !authorizers.is_empty() {
109                        if let Some(claims) = ctx.claims() {
110                            let resource_key = ctx.request().route_pattern().unwrap_or(self.path);
111                            let method = ctx.request().method();
112                            // Hand the endpoint's `#[authorize]` rules to the
113                            // backend. The route metadata is borrowed directly
114                            // (no per-request flattening), so multiple rules keep
115                            // their AND structure.
116                            let declared = DeclaredRules::new(self.authorization);
117                            authorizers
118                                .authorize_with_rules(claims, resource_key, method, &declared)
119                                .await?;
120                        }
121                    }
122                }
123            }
124
125            let response = dispatch(&mut *ctx).await?;
126            response.execute(&mut *ctx).await?;
127            return Ok(());
128        }
129
130        // Missing dispatch — route registered but handler bridge not linked.
131        write_problem(
132            ctx,
133            501,
134            &format!(
135                "Route handler not implemented: {} {} (request type: {})",
136                self.method, self.path, self.handler_type
137            ),
138        )
139        .await;
140        Ok(())
141    }
142}
143
144/// Endpoint for controller-based methods.
145#[allow(clippy::type_complexity)]
146pub struct ControllerEndpoint {
147    handler: Box<
148        dyn Fn(
149                &mut dyn IHttpContext,
150            )
151                -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + '_>>
152            + Send
153            + Sync,
154    >,
155}
156
157#[async_trait::async_trait]
158impl IEndpoint for ControllerEndpoint {
159    async fn handle(&self, ctx: &mut dyn IHttpContext) -> Result<()> {
160        (self.handler)(ctx).await
161    }
162}
163
164impl ControllerEndpoint {
165    pub fn new<F>(f: F) -> Self
166    where
167        F: for<'a> Fn(
168                &'a mut dyn IHttpContext,
169            ) -> std::pin::Pin<
170                Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>,
171            > + Send
172            + Sync
173            + 'static,
174    {
175        Self {
176            handler: Box::new(f),
177        }
178    }
179}
180
181/// Endpoint that serves a static JSON payload.
182///
183/// Used for built-in endpoints like `/openapi.json` and `/health`.
184/// The `content_type` field allows RFC 8407 `application/health+json`
185/// for health endpoints while defaulting to `application/json`.
186pub struct StaticJsonEndpoint {
187    pub body: Vec<u8>,
188    pub content_type: &'static str,
189}
190
191impl StaticJsonEndpoint {
192    /// Create a new endpoint with the default `application/json` content type.
193    pub fn new(body: Vec<u8>) -> Self {
194        Self {
195            body,
196            content_type: "application/json",
197        }
198    }
199}
200
201#[async_trait::async_trait]
202impl IEndpoint for StaticJsonEndpoint {
203    async fn handle(&self, ctx: &mut dyn IHttpContext) -> Result<()> {
204        ctx.response_mut().set_status(200);
205        ctx.response_mut()
206            .set_header("content-type", self.content_type);
207        ctx.response_mut().write_bytes(self.body.clone()).await
208    }
209}
210
211/// Endpoint that serves a static HTML payload.
212///
213/// Used for built-in endpoints like `/api/docs`.
214pub struct StaticHtmlEndpoint {
215    pub body: &'static str,
216}
217
218#[async_trait::async_trait]
219impl IEndpoint for StaticHtmlEndpoint {
220    async fn handle(&self, ctx: &mut dyn IHttpContext) -> Result<()> {
221        ctx.response_mut().set_status(200);
222        ctx.response_mut()
223            .set_header("content-type", "text/html; charset=utf-8");
224        ctx.response_mut()
225            .write_bytes(self.body.as_bytes().to_vec())
226            .await
227    }
228}