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 (statically, or by the dynamic authorizers
63    /// when [`Self::authorization_dynamic`] is `true`).
64    pub authorization: &'static [webx_core::route::scan::AuthorizeRule],
65    /// `true` when the endpoint declared `#[authorize(dynamic)]`.
66    ///
67    /// The static gate then only requires an authenticated principal (no claims
68    /// → 401) and [`Self::authorization`] is handed to the dynamic authorizers,
69    /// which decide. `false` — the default for every other route — means the
70    /// claim snapshot must satisfy the rules before dispatch.
71    pub authorization_dynamic: bool,
72    /// Dynamic authorizers (from `IDynamicAuthorizer` DI registrations).
73    /// Checked after the declarative rules (or instead of them on a dynamic
74    /// endpoint) and before handler dispatch.
75    /// When `None`, no dynamic authorization checks run (pass-through).
76    pub authorizers: Option<Arc<AuthorizerSet>>,
77}
78
79#[async_trait::async_trait]
80impl IEndpoint for StubEndpoint {
81    async fn handle(&self, ctx: &mut dyn IHttpContext) -> Result<()> {
82        if let Some(dispatch) = self.dispatch_fn {
83            // ── Declarative authorization from `#[authorize]` ──
84            // `authorization_dynamic` alone (hand-written metadata) also makes
85            // the route protected: dynamic means "authenticated, then delegated".
86            if !self.authorization.is_empty() || self.authorization_dynamic {
87                if ctx.claims().is_none() {
88                    // Single 401 write path: bubble the error up to the host
89                    // so every unauthenticated response (declarative
90                    // `#[authorize]`, binding layer, middleware) is written
91                    // by one place — including the `WWW-Authenticate`
92                    // challenge header.
93                    return Err(webx_core::error::Error::Unauthorized(
94                        "authentication required".to_string(),
95                    ));
96                }
97
98                // Static mode: the claim snapshot must satisfy every rule.
99                // `#[authorize(dynamic)]` skips this gate — the declaration is
100                // the dynamic authorizer's input, not a claim check.
101                if !self.authorization_dynamic {
102                    let denied = {
103                        let claims = ctx.claims().expect("claims presence is checked above");
104                        self.authorization
105                            .iter()
106                            .find(|rule| !rule.matches(claims.roles(), claims.permissions()))
107                            .map(|rule| (rule.roles, rule.permissions))
108                    };
109
110                    if let Some((roles, permissions)) = denied {
111                        let mut ext = HashMap::new();
112                        ext.insert("required_roles".into(), serde_json::json!(roles));
113                        ext.insert(
114                            "required_permissions".into(),
115                            serde_json::json!(permissions),
116                        );
117                        write_forbidden(ctx, "Forbidden: insufficient permissions", ext).await;
118                        return Ok(());
119                    }
120                }
121
122                // ── Dynamic authorization via IDynamicAuthorizer ──
123                if let Some(ref authorizers) = self.authorizers {
124                    if !authorizers.is_empty() {
125                        if let Some(claims) = ctx.claims() {
126                            let resource_key = ctx.request().route_pattern().unwrap_or(self.path);
127                            let method = ctx.request().method();
128                            // Hand the endpoint's `#[authorize]` rules to the
129                            // backend as the `&'static [AuthorizeRule]` slice
130                            // itself (no per-request flattening or wrapper), so
131                            // multiple rules keep their AND structure and a
132                            // dynamic endpoint's whole declaration reaches the
133                            // authorizer.
134                            authorizers
135                                .authorize_with_declared_rules(
136                                    claims,
137                                    resource_key,
138                                    method,
139                                    self.authorization,
140                                )
141                                .await?;
142                        }
143                    }
144                }
145            }
146
147            let response = dispatch(&mut *ctx).await?;
148            response.execute(&mut *ctx).await?;
149            return Ok(());
150        }
151
152        // Missing dispatch — route registered but handler bridge not linked.
153        write_problem(
154            ctx,
155            501,
156            &format!(
157                "Route handler not implemented: {} {} (request type: {})",
158                self.method, self.path, self.handler_type
159            ),
160        )
161        .await;
162        Ok(())
163    }
164}
165
166/// Endpoint for controller-based methods.
167#[allow(clippy::type_complexity)]
168pub struct ControllerEndpoint {
169    handler: Box<
170        dyn Fn(
171                &mut dyn IHttpContext,
172            )
173                -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + '_>>
174            + Send
175            + Sync,
176    >,
177}
178
179#[async_trait::async_trait]
180impl IEndpoint for ControllerEndpoint {
181    async fn handle(&self, ctx: &mut dyn IHttpContext) -> Result<()> {
182        (self.handler)(ctx).await
183    }
184}
185
186impl ControllerEndpoint {
187    pub fn new<F>(f: F) -> Self
188    where
189        F: for<'a> Fn(
190                &'a mut dyn IHttpContext,
191            ) -> std::pin::Pin<
192                Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>,
193            > + Send
194            + Sync
195            + 'static,
196    {
197        Self {
198            handler: Box::new(f),
199        }
200    }
201}
202
203/// Endpoint that serves a static JSON payload.
204///
205/// Used for built-in endpoints like `/openapi.json` and `/health`.
206/// The `content_type` field allows RFC 8407 `application/health+json`
207/// for health endpoints while defaulting to `application/json`.
208pub struct StaticJsonEndpoint {
209    pub body: Vec<u8>,
210    pub content_type: &'static str,
211}
212
213impl StaticJsonEndpoint {
214    /// Create a new endpoint with the default `application/json` content type.
215    pub fn new(body: Vec<u8>) -> Self {
216        Self {
217            body,
218            content_type: "application/json",
219        }
220    }
221}
222
223#[async_trait::async_trait]
224impl IEndpoint for StaticJsonEndpoint {
225    async fn handle(&self, ctx: &mut dyn IHttpContext) -> Result<()> {
226        ctx.response_mut().set_status(200);
227        ctx.response_mut()
228            .set_header("content-type", self.content_type);
229        ctx.response_mut().write_bytes(self.body.clone()).await
230    }
231}
232
233/// Endpoint that serves a static HTML payload.
234///
235/// Used for built-in endpoints like `/api/docs`.
236pub struct StaticHtmlEndpoint {
237    pub body: &'static str,
238}
239
240#[async_trait::async_trait]
241impl IEndpoint for StaticHtmlEndpoint {
242    async fn handle(&self, ctx: &mut dyn IHttpContext) -> Result<()> {
243        ctx.response_mut().set_status(200);
244        ctx.response_mut()
245            .set_header("content-type", "text/html; charset=utf-8");
246        ctx.response_mut()
247            .write_bytes(self.body.as_bytes().to_vec())
248            .await
249    }
250}