Skip to main content

rust_webx_host/
endpoint.rs

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