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
119            ctx.response_mut().set_status(response.status);
120            if !response.content_type.is_empty() {
121                ctx.response_mut()
122                    .set_header("content-type", &response.content_type);
123            }
124            for (key, value) in &response.headers {
125                ctx.response_mut().set_header(key, value);
126            }
127            ctx.response_mut().write_body(response.body).await?;
128            return Ok(());
129        }
130
131        // Missing dispatch — route registered but handler bridge not linked.
132        write_problem(
133            ctx,
134            501,
135            &format!(
136                "Route handler not implemented: {} {} (request type: {})",
137                self.method, self.path, self.handler_type
138            ),
139        )
140        .await;
141        Ok(())
142    }
143}
144
145/// Endpoint for controller-based methods.
146#[allow(clippy::type_complexity)]
147pub struct ControllerEndpoint {
148    handler: Box<
149        dyn Fn(
150                &mut dyn IHttpContext,
151            )
152                -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + '_>>
153            + Send
154            + Sync,
155    >,
156}
157
158#[async_trait::async_trait]
159impl IEndpoint for ControllerEndpoint {
160    async fn handle(&self, ctx: &mut dyn IHttpContext) -> Result<()> {
161        (self.handler)(ctx).await
162    }
163}
164
165impl ControllerEndpoint {
166    pub fn new<F>(f: F) -> Self
167    where
168        F: for<'a> Fn(
169                &'a mut dyn IHttpContext,
170            ) -> std::pin::Pin<
171                Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>,
172            > + Send
173            + Sync
174            + 'static,
175    {
176        Self {
177            handler: Box::new(f),
178        }
179    }
180}
181
182/// Endpoint that serves a static JSON payload.
183///
184/// Used for built-in endpoints like `/openapi.json` and `/health`.
185/// The `content_type` field allows RFC 8407 `application/health+json`
186/// for health endpoints while defaulting to `application/json`.
187pub struct StaticJsonEndpoint {
188    pub body: Vec<u8>,
189    pub content_type: &'static str,
190}
191
192impl StaticJsonEndpoint {
193    /// Create a new endpoint with the default `application/json` content type.
194    pub fn new(body: Vec<u8>) -> Self {
195        Self {
196            body,
197            content_type: "application/json",
198        }
199    }
200}
201
202#[async_trait::async_trait]
203impl IEndpoint for StaticJsonEndpoint {
204    async fn handle(&self, ctx: &mut dyn IHttpContext) -> Result<()> {
205        ctx.response_mut().set_status(200);
206        ctx.response_mut()
207            .set_header("content-type", self.content_type);
208        ctx.response_mut().write_bytes(self.body.clone()).await
209    }
210}
211
212/// Endpoint that serves a static HTML payload.
213///
214/// Used for built-in endpoints like `/api/docs`.
215pub struct StaticHtmlEndpoint {
216    pub body: &'static str,
217}
218
219#[async_trait::async_trait]
220impl IEndpoint for StaticHtmlEndpoint {
221    async fn handle(&self, ctx: &mut dyn IHttpContext) -> Result<()> {
222        ctx.response_mut().set_status(200);
223        ctx.response_mut()
224            .set_header("content-type", "text/html; charset=utf-8");
225        ctx.response_mut()
226            .write_bytes(self.body.as_bytes().to_vec())
227            .await
228    }
229}