features_cli/
http_server.rs

1//! HTTP server module for serving features data and embedded static files.
2//!
3//! This module provides functionality to start an HTTP server that serves:
4//! - Features data as JSON at `/features.json`
5//! - Embedded static files from the compiled binary
6//! - A default index page at the root path
7//!
8//! The server uses the `warp` web framework and supports CORS for cross-origin requests.
9//! Static files are embedded at compile time using the `include_dir` crate.
10
11use anyhow::Result;
12use include_dir::{Dir, include_dir};
13use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher};
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use std::time::Duration;
17use tokio::sync::RwLock;
18use tokio::time::sleep;
19use warp::{Filter, Reply};
20
21use crate::file_scanner::list_files_recursive_with_changes;
22use crate::models::Feature;
23
24// Embed the public directory at compile time
25static STATIC_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/public");
26
27/// Configuration for the HTTP server
28#[derive(Debug, Clone)]
29pub struct ServerConfig {
30    /// Port to run the server on
31    pub port: u16,
32    /// Host address to bind to
33    pub host: [u8; 4],
34}
35
36impl Default for ServerConfig {
37    fn default() -> Self {
38        Self {
39            port: 3000,
40            host: [127, 0, 0, 1],
41        }
42    }
43}
44
45impl ServerConfig {
46    /// Create a new server configuration with custom port
47    pub fn new(port: u16) -> Self {
48        Self {
49            port,
50            ..Default::default()
51        }
52    }
53
54    /// Set the host address to bind to
55    #[allow(dead_code)]
56    pub fn with_host(mut self, host: [u8; 4]) -> Self {
57        self.host = host;
58        self
59    }
60}
61
62/// Starts an HTTP server with file watching for a specific directory.
63///
64/// # Arguments
65///
66/// * `features` - Initial Feature objects to serve as JSON
67/// * `port` - Port number to run the server on
68/// * `watch_path` - Path to watch for file changes
69///
70/// # Returns
71///
72/// * `Result<()>` - Ok if server starts successfully, Err otherwise
73pub async fn serve_features_with_watching(
74    features: &[Feature],
75    port: u16,
76    watch_path: PathBuf,
77) -> Result<()> {
78    let config = ServerConfig::new(port);
79    serve_features_with_config_and_watching(features, config, Some(watch_path)).await
80}
81
82/// Starts an HTTP server with custom configuration and optional file watching.
83///
84/// # Arguments
85///
86/// * `features` - Slice of Feature objects to serve as JSON
87/// * `config` - Server configuration
88/// * `watch_path` - Optional path to watch for file changes
89///
90/// # Returns
91///
92/// * `Result<()>` - Ok if server starts successfully, Err otherwise
93pub async fn serve_features_with_config_and_watching(
94    features: &[Feature],
95    config: ServerConfig,
96    watch_path: Option<PathBuf>,
97) -> Result<()> {
98    // Create shared state for features
99    let features_data = Arc::new(RwLock::new(features.to_vec()));
100
101    // Set up file watching if watch_path is provided
102    if let Some(ref path) = watch_path {
103        let features_data_clone = Arc::clone(&features_data);
104        let watch_path_clone = path.clone();
105
106        tokio::spawn(async move {
107            if let Err(e) = setup_file_watcher(features_data_clone, watch_path_clone).await {
108                eprintln!("File watcher error: {}", e);
109            }
110        });
111    }
112
113    // Route for features.json with shared state
114    let features_data_clone = Arc::clone(&features_data);
115    let features_route = warp::path("features.json")
116        .and(warp::get())
117        .and_then(move || {
118            let features_data = Arc::clone(&features_data_clone);
119            async move {
120                let features = features_data.read().await;
121                let features_json = match serde_json::to_string_pretty(&*features) {
122                    Ok(json) => json,
123                    Err(e) => {
124                        eprintln!("Failed to serialize features: {}", e);
125                        return Err(warp::reject::custom(SerializationError));
126                    }
127                };
128
129                Ok::<_, warp::Rejection>(warp::reply::with_header(
130                    features_json,
131                    "content-type",
132                    "application/json",
133                ))
134            }
135        });
136
137    // Route for metadata.json with version info
138    let metadata_route = warp::path("metadata.json")
139        .and(warp::get())
140        .and_then(|| async move {
141            let metadata = serde_json::json!({
142                "version": env!("CARGO_PKG_VERSION")
143            });
144
145            let metadata_json = match serde_json::to_string_pretty(&metadata) {
146                Ok(json) => json,
147                Err(e) => {
148                    eprintln!("Failed to serialize metadata: {}", e);
149                    return Err(warp::reject::custom(SerializationError));
150                }
151            };
152
153            Ok::<_, warp::Rejection>(warp::reply::with_header(
154                metadata_json,
155                "content-type",
156                "application/json",
157            ))
158        });
159
160    // Route for root path to serve index.html
161    let index_route = warp::path::end().and(warp::get()).and_then(serve_index);
162
163    // Route for static files from embedded directory
164    let static_route = warp::path::tail()
165        .and(warp::get())
166        .and_then(serve_static_file);
167
168    let routes = features_route
169        .or(metadata_route)
170        .or(index_route)
171        .or(static_route)
172        .with(warp::cors().allow_any_origin())
173        .recover(handle_rejection);
174
175    println!(
176        "Server running at http://{}:{}",
177        config
178            .host
179            .iter()
180            .map(|&b| b.to_string())
181            .collect::<Vec<_>>()
182            .join("."),
183        config.port,
184    );
185    warp::serve(routes).run((config.host, config.port)).await;
186
187    Ok(())
188}
189
190/// Sets up file system watching for the specified path.
191async fn setup_file_watcher(
192    features_data: Arc<RwLock<Vec<Feature>>>,
193    watch_path: PathBuf,
194) -> Result<()> {
195    let (tx, mut rx) = tokio::sync::mpsc::channel(100);
196
197    // Set up the file watcher in a blocking task
198    let watch_path_clone = watch_path.clone();
199    let _watcher = tokio::task::spawn_blocking(move || -> Result<RecommendedWatcher> {
200        let mut watcher = RecommendedWatcher::new(
201            move |res: notify::Result<Event>| {
202                match res {
203                    Ok(event) => {
204                        // Send event through channel
205                        if let Err(e) = tx.blocking_send(event) {
206                            eprintln!("Failed to send file system event: {}", e);
207                        }
208                    }
209                    Err(e) => eprintln!("File watcher error: {:?}", e),
210                }
211            },
212            Config::default(),
213        )?;
214
215        watcher.watch(&watch_path_clone, RecursiveMode::Recursive)?;
216        Ok(watcher)
217    })
218    .await??;
219
220    // Process file system events
221    while let Some(event) = rx.recv().await {
222        // Check if this is a file we care about (README.md files or directory changes)
223        let should_recompute = event.paths.iter().any(|path| {
224            path.file_name()
225                .map(|name| name == "README.md")
226                .unwrap_or(false)
227                || event.kind.is_create()
228                || event.kind.is_remove()
229        });
230
231        if should_recompute {
232            // Add a small delay to avoid excessive recomputation during rapid changes
233            sleep(Duration::from_millis(500)).await;
234
235            match list_files_recursive_with_changes(&watch_path) {
236                Ok(new_features) => {
237                    let mut features = features_data.write().await;
238                    *features = new_features;
239                    println!("✅ Features updated successfully");
240                }
241                Err(e) => {
242                    eprintln!("❌ Failed to recompute features: {}", e);
243                }
244            }
245        }
246    }
247
248    Ok(())
249}
250
251/// Custom error type for serialization failures
252#[derive(Debug)]
253struct SerializationError;
254impl warp::reject::Reject for SerializationError {}
255
256/// Serves the index page for the root path.
257///
258/// If embedded `index.html` exists, it will be served. Otherwise, a default
259/// HTML page with navigation links will be returned.
260///
261/// # Returns
262///
263/// * `Result<impl warp::Reply, warp::Rejection>` - HTML response or rejection
264async fn serve_index() -> Result<impl warp::Reply, warp::Rejection> {
265    if let Some(file) = STATIC_DIR.get_file("index.html") {
266        let content = file.contents_utf8().unwrap_or("");
267        Ok(
268            warp::reply::with_header(content, "content-type", "text/html; charset=utf-8")
269                .into_response(),
270        )
271    } else {
272        let html = create_default_index_html();
273        Ok(
274            warp::reply::with_header(html, "content-type", "text/html; charset=utf-8")
275                .into_response(),
276        )
277    }
278}
279
280/// Serves static files from the embedded directory.
281///
282/// # Arguments
283///
284/// * `path` - The requested file path
285///
286/// # Returns
287///
288/// * `Result<impl warp::Reply, warp::Rejection>` - File content or rejection
289async fn serve_static_file(path: warp::path::Tail) -> Result<impl warp::Reply, warp::Rejection> {
290    let path_str = path.as_str();
291
292    // Try to get the file from the embedded directory
293    if let Some(file) = STATIC_DIR.get_file(path_str) {
294        let content_type = get_content_type(path_str);
295
296        if let Some(contents) = file.contents_utf8() {
297            // Text file
298            Ok(warp::reply::with_header(contents, "content-type", content_type).into_response())
299        } else {
300            // Binary file
301            Ok(
302                warp::reply::with_header(file.contents(), "content-type", content_type)
303                    .into_response(),
304            )
305        }
306    } else {
307        Err(warp::reject::not_found())
308    }
309}
310
311/// Determines the content type based on file extension.
312///
313/// # Arguments
314///
315/// * `path` - The file path
316///
317/// # Returns
318///
319/// * `&'static str` - The appropriate MIME type
320fn get_content_type(path: &str) -> &'static str {
321    let extension = Path::new(path)
322        .extension()
323        .and_then(|ext| ext.to_str())
324        .unwrap_or("");
325
326    match extension.to_lowercase().as_str() {
327        "html" => "text/html; charset=utf-8",
328        "css" => "text/css; charset=utf-8",
329        "js" => "application/javascript; charset=utf-8",
330        "json" => "application/json; charset=utf-8",
331        "svg" => "image/svg+xml",
332        "png" => "image/png",
333        "jpg" | "jpeg" => "image/jpeg",
334        "gif" => "image/gif",
335        "ico" => "image/x-icon",
336        "txt" => "text/plain; charset=utf-8",
337        "pdf" => "application/pdf",
338        "xml" => "application/xml; charset=utf-8",
339        "woff" => "font/woff",
340        "woff2" => "font/woff2",
341        "ttf" => "font/ttf",
342        "eot" => "application/vnd.ms-fontobject",
343        _ => "application/octet-stream",
344    }
345}
346
347/// Creates the default HTML page when no index.html is found.
348fn create_default_index_html() -> String {
349    r#"<!DOCTYPE html>
350<html lang="en">
351<head>
352    <meta charset="UTF-8">
353    <meta name="viewport" content="width=device-width, initial-scale=1.0">
354    <title>Features Dashboard</title>
355    <style>
356        body {
357            font-family: Arial, sans-serif;
358            margin: 40px;
359            line-height: 1.6;
360            background: #f5f5f5;
361        }
362        .container {
363            max-width: 800px;
364            margin: 0 auto;
365            background: white;
366            padding: 30px;
367            border-radius: 8px;
368            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
369        }
370        h1 { color: #333; }
371        .links { list-style: none; padding: 0; }
372        .links li { margin: 10px 0; }
373        .links a {
374            color: #007acc;
375            text-decoration: none;
376            padding: 8px 15px;
377            border: 1px solid #007acc;
378            border-radius: 4px;
379            display: inline-block;
380        }
381        .links a:hover { background: #007acc; color: white; }
382    </style>
383</head>
384<body>
385    <div class="container">
386        <h1>đŸ—ī¸ Features Dashboard</h1>
387        <p>Welcome to the feature-based architecture server!</p>
388        <ul class="links">
389            <li><a href="/features.json">📊 View Features JSON</a></li>
390            <li><a href="/metadata.json">â„šī¸ View Metadata JSON</a></li>
391        </ul>
392        <p><small>This server provides features data and serves embedded static files from the binary.</small></p>
393    </div>
394</body>
395</html>"#.to_string()
396}
397
398/// Handles HTTP request rejections and converts them to appropriate responses.
399async fn handle_rejection(
400    err: warp::Rejection,
401) -> Result<impl warp::Reply, std::convert::Infallible> {
402    let code;
403    let message;
404
405    if err.is_not_found() {
406        code = warp::http::StatusCode::NOT_FOUND;
407        message = "NOT_FOUND";
408    } else if err
409        .find::<warp::filters::body::BodyDeserializeError>()
410        .is_some()
411    {
412        code = warp::http::StatusCode::BAD_REQUEST;
413        message = "BAD_REQUEST";
414    } else if err.find::<warp::reject::MethodNotAllowed>().is_some() {
415        code = warp::http::StatusCode::METHOD_NOT_ALLOWED;
416        message = "METHOD_NOT_ALLOWED";
417    } else {
418        eprintln!("Unhandled rejection: {:?}", err);
419        code = warp::http::StatusCode::INTERNAL_SERVER_ERROR;
420        message = "INTERNAL_SERVER_ERROR";
421    }
422
423    Ok(warp::reply::with_status(message, code))
424}