Skip to main content

webui/
server.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! High-level server helper for custom Rust servers using `webui-router`.
5//!
6//! This module provides [`serve_request`] which encapsulates:
7//! - Route parameter extraction from the URL
8//! - HTML SSR rendering via [`WebUIHandler`]
9//! - JSON partial responses for client-side navigation
10//! - Template inventory management
11//!
12//! # Example
13//!
14//! ```rust,ignore
15//! use webui::server::{serve_request, ServeRequest, ServeResponse};
16//!
17//! let request = ServeRequest {
18//!     path: "/email/thread-5",
19//!     accept_json: false,
20//!     inventory_hex: "",
21//! };
22//!
23//! match serve_request(&protocol, &handler, &state, "index.html", &request) {
24//!     Ok(ServeResponse::Html(html)) => { /* serve HTML */ }
25//!     Ok(ServeResponse::Json(json)) => { /* serve JSON */ }
26//!     Err(e) => { /* handle error */ }
27//! }
28//! ```
29
30use crate::{Protocol, ResponseWriter, WebUIHandler};
31use webui_handler::route_handler;
32use webui_handler::RenderOptions;
33
34/// A server request to be handled by [`serve_request`].
35pub struct ServeRequest<'a> {
36    /// The URL path (e.g., `"/email/thread-5"`, `"/folder/sent"`).
37    pub path: &'a str,
38    /// Whether the client accepts JSON (for partial navigation).
39    /// Check `Accept: application/json` in request headers.
40    pub accept_json: bool,
41    /// The client's current template inventory (hex bitmask).
42    /// Read from `X-WebUI-Inventory` request header. Empty string if not present.
43    pub inventory_hex: &'a str,
44}
45
46/// The response from [`serve_request`].
47pub enum ServeResponse {
48    /// Full HTML page for initial load or browser refresh.
49    Html(String),
50    /// JSON partial for client-side navigation via `webui-router`.
51    Json(String),
52}
53
54/// Handle a server request with automatic route handling.
55///
56/// For HTML requests: renders the full page with route-matched SSR.
57/// The handler emits a consolidated `window.__webui` script block
58/// containing state, chain, inventory, and template metadata.
59///
60/// For JSON requests: returns a partial response with route-scoped state,
61/// needed templates, and inventory for the `webui-router` client.
62///
63/// # Arguments
64/// - `protocol` — The compiled WebUI protocol from [`build`](crate::build)
65/// - `handler` — The WebUI handler (with plugin configured)
66/// - `state` — The state JSON to render. For HTML requests, this should be
67///   the full app state. For JSON requests, the caller should provide
68///   route-scoped state (only what the target page component needs).
69/// - `request` — The incoming request details
70///
71/// # Route Parameters
72/// Route parameters (`:param` in route paths) are automatically extracted
73/// and injected into the state object.
74pub fn serve_request(
75    protocol: &Protocol,
76    handler: &WebUIHandler,
77    state: serde_json::Value,
78    entry: &str,
79    request: &ServeRequest<'_>,
80) -> Result<ServeResponse, String> {
81    // Extract route params and inject into state
82    let params = route_handler::collect_nested_route_params(protocol, entry, request.path);
83    let mut data = state;
84    if let Some(map) = data.as_object_mut() {
85        for (k, v) in &params {
86            map.insert(k.clone(), serde_json::Value::String(v.clone()));
87        }
88    }
89
90    if request.accept_json {
91        // JSON partial response for client-side navigation.
92        let state_json =
93            serde_json::to_string(&data).map_err(|e| format!("state serialization failed: {e}"))?;
94        let partial = protocol
95            .render_partial(&state_json, entry, request.path, request.inventory_hex)
96            .map_err(|e| format!("render_partial failed: {e}"))?;
97        Ok(ServeResponse::Json(partial))
98    } else {
99        // Full HTML SSR — handler emits the consolidated window.__webui
100        // script block (state, chain, inventory, templates) automatically.
101        let mut writer = MemWriter::with_capacity(131_072);
102        let opts = RenderOptions::new(entry, request.path);
103        handler
104            .render(protocol, &data, &opts, &mut writer)
105            .map_err(|e| format!("render failed: {e}"))?;
106
107        Ok(ServeResponse::Html(writer.buf))
108    }
109}
110
111struct MemWriter {
112    buf: String,
113}
114
115impl MemWriter {
116    fn with_capacity(cap: usize) -> Self {
117        Self {
118            buf: String::with_capacity(cap),
119        }
120    }
121}
122
123impl ResponseWriter for MemWriter {
124    fn write(&mut self, content: &str) -> webui_handler::Result<()> {
125        self.buf.push_str(content);
126        Ok(())
127    }
128
129    fn end(&mut self) -> webui_handler::Result<()> {
130        Ok(())
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use serde_json::json;
138    use std::collections::HashMap;
139    use webui_protocol::{FragmentList, WebUIFragment, WebUIProtocol};
140
141    fn response_json(response: ServeResponse) -> serde_json::Value {
142        match response {
143            ServeResponse::Json(json) => {
144                serde_json::from_str(&json).expect("partial response should be valid JSON")
145            }
146            ServeResponse::Html(_) => panic!("expected JSON partial response"),
147        }
148    }
149
150    #[test]
151    fn serve_request_json_partial_preserves_template_styles() {
152        let mut fragments = HashMap::new();
153        fragments.insert(
154            "index.html".to_string(),
155            FragmentList {
156                fragments: vec![WebUIFragment::component("my-page")],
157            },
158        );
159        fragments.insert(
160            "my-page".to_string(),
161            FragmentList {
162                fragments: vec![WebUIFragment::raw("<p>page</p>")],
163            },
164        );
165
166        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
167        let component = protocol
168            .components
169            .entry("my-page".to_string())
170            .or_default();
171        component.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
172        component.css = ".page{color:red}".to_string();
173        let protocol = Protocol::new(protocol);
174
175        let handler = WebUIHandler::new();
176        let request = ServeRequest {
177            path: "/",
178            accept_json: true,
179            inventory_hex: "",
180        };
181
182        let response = serve_request(&protocol, &handler, json!({}), "index.html", &request)
183            .expect("partial response should succeed");
184
185        let json = response_json(response);
186
187        // templateStyles must be present and non-empty for module-mode components
188        assert_eq!(
189            json["templateStyles"].as_array().map(Vec::len),
190            Some(1),
191            "serve_request should include module template styles"
192        );
193        // templates must not contain any style tags
194        assert!(
195            !json["templates"]["my-page"]["h"]
196                .as_str()
197                .unwrap_or_default()
198                .contains("<style"),
199            "template metadata should not contain module style tags"
200        );
201    }
202
203    #[test]
204    fn serve_request_link_strategy_returns_empty_template_styles() {
205        let mut fragments = HashMap::new();
206        fragments.insert(
207            "index.html".to_string(),
208            FragmentList {
209                fragments: vec![WebUIFragment::component("my-page")],
210            },
211        );
212        fragments.insert(
213            "my-page".to_string(),
214            FragmentList {
215                fragments: vec![WebUIFragment::raw("<p>page</p>")],
216            },
217        );
218
219        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
220        let comp = protocol
221            .components
222            .entry("my-page".to_string())
223            .or_default();
224        comp.template_json = r#"{"h":"<p>page</p>"}"#.to_string();
225        comp.css_href = "my-page.css".to_string();
226        // No css content — Link strategy
227        let protocol = Protocol::new(protocol);
228
229        let handler = WebUIHandler::new();
230        let request = ServeRequest {
231            path: "/",
232            accept_json: true,
233            inventory_hex: "",
234        };
235
236        let response = serve_request(&protocol, &handler, json!({}), "index.html", &request)
237            .expect("partial response should succeed");
238
239        let json = response_json(response);
240
241        assert!(
242            json["templateStyles"]
243                .as_array()
244                .is_some_and(|a| a.is_empty()),
245            "Link strategy should return empty templateStyles"
246        );
247        assert!(
248            json["templates"].as_object().is_some_and(|a| a.len() == 1),
249            "Link strategy should still return templates"
250        );
251    }
252
253    #[test]
254    fn serve_request_style_strategy_returns_empty_template_styles() {
255        let mut fragments = HashMap::new();
256        fragments.insert(
257            "index.html".to_string(),
258            FragmentList {
259                fragments: vec![WebUIFragment::component("my-page")],
260            },
261        );
262        fragments.insert(
263            "my-page".to_string(),
264            FragmentList {
265                fragments: vec![WebUIFragment::raw("<p>page</p>")],
266            },
267        );
268
269        let mut protocol = WebUIProtocol::with_tokens(fragments, Vec::new());
270        let comp = protocol
271            .components
272            .entry("my-page".to_string())
273            .or_default();
274        // Style strategy: CSS is inlined in the template metadata
275        comp.template_json = r#"{"h":"<style>.p{color:red}</style><p/>"}"#.to_string();
276        let protocol = Protocol::new(protocol);
277
278        let handler = WebUIHandler::new();
279        let request = ServeRequest {
280            path: "/",
281            accept_json: true,
282            inventory_hex: "",
283        };
284
285        let response = serve_request(&protocol, &handler, json!({}), "index.html", &request)
286            .expect("partial response should succeed");
287
288        let json = response_json(response);
289
290        assert!(
291            json["templateStyles"]
292                .as_array()
293                .is_some_and(|a| a.is_empty()),
294            "Style strategy should return empty templateStyles"
295        );
296        assert!(
297            json["templates"].as_object().is_some_and(|a| a.len() == 1),
298            "Style strategy should still return templates"
299        );
300    }
301}