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