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}