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::IAuthorizationPolicy;
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    pub auth_required_role: &'static str,
63    pub auth_required_permission: &'static str,
64    /// Optional route policy built from compile-time `#[authorize]` metadata when
65    /// `HostBuilder::use_resource_authorization()` is enabled.
66    pub resource_policy: Option<Arc<crate::authz::ResourceAuthorization>>,
67    /// Dynamic authorizers (from `IDynamicAuthorizer` DI registrations).
68    /// Checked after static `#[authorize]` and before handler dispatch.
69    /// When `None`, no dynamic authorization checks run (pass-through).
70    pub authorizers: Option<Arc<AuthorizerSet>>,
71}
72
73#[async_trait::async_trait]
74impl IEndpoint for StubEndpoint {
75    async fn handle(&self, ctx: &mut dyn IHttpContext) -> Result<()> {
76        if let Some(dispatch) = self.dispatch_fn {
77            // ── Authorization check (declared on route via #[authorize]) ──
78            let auth_required =
79                !self.auth_required_role.is_empty() || !self.auth_required_permission.is_empty();
80            if auth_required {
81                match ctx.claims() {
82                    None => {
83                        write_problem(ctx, 401, "Authentication required").await;
84                        return Ok(());
85                    }
86                    Some(claims) => {
87                        if !self.auth_required_role.is_empty()
88                            && self.auth_required_role != "authenticated"
89                            && !claims
90                                .roles()
91                                .iter()
92                                .any(|r| r.as_str() == self.auth_required_role)
93                        {
94                            let mut ext = HashMap::new();
95                            ext.insert(
96                                "required_role".into(),
97                                serde_json::Value::String(self.auth_required_role.to_string()),
98                            );
99                            write_forbidden(ctx, "Forbidden: insufficient permissions", ext).await;
100                            return Ok(());
101                        }
102
103                        if !self.auth_required_permission.is_empty()
104                            && !claims
105                                .permissions()
106                                .iter()
107                                .any(|p| p.as_str() == self.auth_required_permission)
108                        {
109                            let mut ext = HashMap::new();
110                            ext.insert(
111                                "required_permission".into(),
112                                serde_json::Value::String(
113                                    self.auth_required_permission.to_string(),
114                                ),
115                            );
116                            write_forbidden(ctx, "Forbidden: insufficient permissions", ext).await;
117                            return Ok(());
118                        }
119                    }
120                }
121            }
122
123            if auth_required {
124                if let Some(ref policy) = self.resource_policy {
125                    if let Some(claims) = ctx.claims() {
126                        let resource_key = ctx.request().route_pattern().unwrap_or(self.path);
127                        if policy.covers_route(resource_key) {
128                            let method = ctx.request().method();
129                            if let Err(err) = policy.authorize(claims, resource_key, method).await {
130                                let mut ext = HashMap::new();
131                                ext.insert(
132                                    "resource".into(),
133                                    serde_json::Value::String(resource_key.to_string()),
134                                );
135                                write_forbidden(ctx, &err.to_string(), ext).await;
136                                return Ok(());
137                            }
138                        }
139                    }
140                }
141            }
142
143            // ── Dynamic authorization via IDynamicAuthorizer ──
144            if auth_required {
145                if let Some(ref authorizers) = self.authorizers {
146                    if !authorizers.is_empty() {
147                        if let Some(claims) = ctx.claims() {
148                            let resource_key = ctx.request().route_pattern().unwrap_or(self.path);
149                            let method = ctx.request().method();
150                            authorizers.authorize(claims, resource_key, method).await?;
151                        }
152                    }
153                }
154            }
155
156            let response = dispatch(&mut *ctx).await?;
157
158            ctx.response_mut().set_status(response.status);
159            if !response.content_type.is_empty() {
160                ctx.response_mut()
161                    .set_header("content-type", &response.content_type);
162            }
163            for (key, value) in &response.headers {
164                ctx.response_mut().set_header(key, value);
165            }
166            ctx.response_mut().write_body(response.body).await?;
167            return Ok(());
168        }
169
170        // Missing dispatch — route registered but handler bridge not linked.
171        write_problem(
172            ctx,
173            501,
174            &format!(
175                "Route handler not implemented: {} {} (request type: {})",
176                self.method, self.path, self.handler_type
177            ),
178        )
179        .await;
180        Ok(())
181    }
182}
183
184/// Endpoint for controller-based methods.
185#[allow(clippy::type_complexity)]
186pub struct ControllerEndpoint {
187    handler: Box<
188        dyn Fn(
189                &mut dyn IHttpContext,
190            )
191                -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + '_>>
192            + Send
193            + Sync,
194    >,
195}
196
197#[async_trait::async_trait]
198impl IEndpoint for ControllerEndpoint {
199    async fn handle(&self, ctx: &mut dyn IHttpContext) -> Result<()> {
200        (self.handler)(ctx).await
201    }
202}
203
204impl ControllerEndpoint {
205    pub fn new<F>(f: F) -> Self
206    where
207        F: for<'a> Fn(
208                &'a mut dyn IHttpContext,
209            ) -> std::pin::Pin<
210                Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>,
211            > + Send
212            + Sync
213            + 'static,
214    {
215        Self {
216            handler: Box::new(f),
217        }
218    }
219}
220
221/// Endpoint that serves a static JSON payload.
222///
223/// Used for built-in endpoints like `/openapi.json` and `/health`.
224/// The `content_type` field allows RFC 8407 `application/health+json`
225/// for health endpoints while defaulting to `application/json`.
226pub struct StaticJsonEndpoint {
227    pub body: Vec<u8>,
228    pub content_type: &'static str,
229}
230
231impl StaticJsonEndpoint {
232    /// Create a new endpoint with the default `application/json` content type.
233    pub fn new(body: Vec<u8>) -> Self {
234        Self {
235            body,
236            content_type: "application/json",
237        }
238    }
239}
240
241#[async_trait::async_trait]
242impl IEndpoint for StaticJsonEndpoint {
243    async fn handle(&self, ctx: &mut dyn IHttpContext) -> Result<()> {
244        ctx.response_mut().set_status(200);
245        ctx.response_mut()
246            .set_header("content-type", self.content_type);
247        ctx.response_mut().write_bytes(self.body.clone()).await
248    }
249}
250
251/// Endpoint that serves a static HTML payload.
252///
253/// Used for built-in endpoints like `/api/docs`.
254pub struct StaticHtmlEndpoint {
255    pub body: &'static str,
256}
257
258#[async_trait::async_trait]
259impl IEndpoint for StaticHtmlEndpoint {
260    async fn handle(&self, ctx: &mut dyn IHttpContext) -> Result<()> {
261        ctx.response_mut().set_status(200);
262        ctx.response_mut()
263            .set_header("content-type", "text/html; charset=utf-8");
264        ctx.response_mut()
265            .write_bytes(self.body.as_bytes().to_vec())
266            .await
267    }
268}