Skip to main content

camel_component_http/
static_endpoint.rs

1use std::sync::Arc;
2
3use camel_api::component_metadata::ComponentMetadata;
4use camel_component_api::{
5    CamelError, Component, Consumer, Endpoint, ProducerContext, RuntimeObservability,
6};
7use tower_http::services::ServeDir;
8
9use crate::registry::{MountMode, StaticMount};
10use crate::{HttpStaticConfig, ServerRegistry};
11
12// ---------------------------------------------------------------------------
13// HttpStaticComponent
14// ---------------------------------------------------------------------------
15
16/// Component factory for the `http-static:` scheme.
17///
18/// Creates [`HttpStaticEndpoint`] instances from URIs like
19/// `http-static:/path/to/dir?port=8080&spaFallback=true`.
20pub struct HttpStaticComponent {
21    config: HttpStaticConfig,
22}
23
24impl HttpStaticComponent {
25    pub fn new() -> Self {
26        Self {
27            config: HttpStaticConfig::default(),
28        }
29    }
30
31    pub fn with_config(config: HttpStaticConfig) -> Self {
32        Self { config }
33    }
34}
35
36impl Default for HttpStaticComponent {
37    fn default() -> Self {
38        Self::new()
39    }
40}
41
42impl Component for HttpStaticComponent {
43    fn scheme(&self) -> &str {
44        "http-static"
45    }
46
47    fn metadata(&self) -> ComponentMetadata {
48        HttpStaticConfig::metadata()
49    }
50
51    fn create_endpoint(
52        &self,
53        uri: &str,
54        _ctx: &dyn camel_component_api::ComponentContext,
55    ) -> Result<Box<dyn Endpoint>, CamelError> {
56        let config = HttpStaticConfig::from_uri_with_defaults(uri, &self.config)?;
57        Ok(Box::new(HttpStaticEndpoint {
58            uri: uri.to_string(),
59            config,
60        }))
61    }
62}
63
64// ---------------------------------------------------------------------------
65// HttpStaticEndpoint
66// ---------------------------------------------------------------------------
67
68/// Endpoint for a static file serving route.
69///
70/// Holds the resolved [`HttpStaticConfig`] and creates [`HttpStaticConsumer`]
71/// instances when the route starts.
72pub struct HttpStaticEndpoint {
73    uri: String,
74    config: HttpStaticConfig,
75}
76
77impl Endpoint for HttpStaticEndpoint {
78    fn uri(&self) -> &str {
79        &self.uri
80    }
81
82    fn create_consumer(
83        &self,
84        rt: Arc<dyn RuntimeObservability>,
85    ) -> Result<Box<dyn Consumer>, CamelError> {
86        Ok(Box::new(HttpStaticConsumer::new(self.config.clone(), rt)))
87    }
88
89    fn create_producer(
90        &self,
91        _rt: Arc<dyn RuntimeObservability>,
92        _ctx: &ProducerContext,
93    ) -> Result<camel_component_api::BoxProcessor, CamelError> {
94        Err(CamelError::Config(
95            "http-static endpoint does not support producers".to_string(),
96        ))
97    }
98}
99
100// ---------------------------------------------------------------------------
101// HttpStaticConsumer
102// ---------------------------------------------------------------------------
103
104/// Consumer that registers a static file mount into the shared
105/// [`HttpRouteRegistry`] and stays idle until cancelled.
106///
107/// On start:
108/// 1. Canonicalizes the configured `dir` (fails if not found).
109/// 2. Canonicalizes each `error_pages` path (fails if any don't exist).
110/// 3. Builds a `ServeDir` for the directory.
111/// 4. Registers a `StaticMount` into the registry.
112///
113/// On stop (cancellation):
114/// - Unregisters the mount from the registry.
115pub struct HttpStaticConsumer {
116    config: HttpStaticConfig,
117    /// Feeds `ServerRegistry::get_or_spawn` — the runtime source of the
118    /// wired `e:http:accept` / `e:http:accept-tls` /
119    /// `e:http:server-task-exited` metrics. Also held for
120    /// `rt.health().force_unhealthy_for_route(...)` calls per ADR-0012.
121    runtime: Arc<dyn RuntimeObservability>,
122}
123
124impl HttpStaticConsumer {
125    /// Create a new `HttpStaticConsumer` from the given config.
126    pub fn new(config: HttpStaticConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
127        Self { config, runtime }
128    }
129}
130
131#[async_trait::async_trait]
132impl Consumer for HttpStaticConsumer {
133    async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
134        // 1. Canonicalize dir
135        let dir = std::fs::canonicalize(&self.config.dir).map_err(|e| {
136            CamelError::Config(format!(
137                "http-static directory not found: {}: {}",
138                self.config.dir.display(),
139                e
140            ))
141        })?;
142
143        // 2. Canonicalize error_pages paths (resolved relative to dir)
144        let mut error_pages = std::collections::HashMap::new();
145        for (code, path) in &self.config.error_pages {
146            let resolved = if path.is_absolute() {
147                path.clone()
148            } else {
149                self.config.dir.join(path)
150            };
151            let canonical = std::fs::canonicalize(&resolved).map_err(|e| {
152                CamelError::Config(format!(
153                    "http-static error page not found for status {}: {}: {}",
154                    code,
155                    resolved.display(),
156                    e
157                ))
158            })?;
159            error_pages.insert(*code, canonical);
160        }
161
162        // 3. Build ServeDir
163        let serve_dir = ServeDir::new(&dir)
164            .precompressed_gzip()
165            .precompressed_br()
166            .append_index_html_on_directories(true);
167
168        // 4. Get registry
169        let registry = ServerRegistry::global()
170            .get_or_spawn(
171                &self.config.host,
172                self.config.port,
173                2 * 1024 * 1024,  // max_request_body (not used for static)
174                10 * 1024 * 1024, // max_response_body (not used for static)
175                1024,             // max_inflight_requests
176                self.runtime.clone(),
177                ctx.route_id().to_string(),
178                None,
179            )
180            .await?;
181
182        // 5. Register mount
183        let mode = if self.config.spa_fallback {
184            MountMode::Spa
185        } else {
186            MountMode::Static
187        };
188        let mount = StaticMount {
189            mount_path: self.config.mount_path.clone(),
190            mode,
191            dir: dir.clone(),
192            cache_control: self.config.cache_control.clone(),
193            error_pages,
194            serve_dir,
195        };
196
197        registry.register_static_mount(mount).await?;
198
199        // rc-w1u9: Signal readiness AFTER bind + spawn + static mount
200        // registration. Same contract as HttpConsumer: the listener is
201        // accepting and this mount's path will be served (not 404'd).
202        ctx.mark_ready();
203
204        let mount_path_for_cleanup = self.config.mount_path.clone();
205        let registry_for_cleanup = registry.clone();
206
207        // 6. Wait on cancellation token
208        ctx.cancelled().await;
209
210        // 7. Unregister on stop (by mount_path identity)
211        registry_for_cleanup
212            .unregister_static_mount(&mount_path_for_cleanup)
213            .await;
214
215        Ok(())
216    }
217
218    async fn stop(&mut self) -> Result<(), CamelError> {
219        Ok(())
220    }
221
222    fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
223        camel_component_api::ConcurrencyModel::Sequential
224    }
225
226    // rc-w1u9: HttpStaticConsumer also binds a TcpListener inside start()
227    // (via ServerRegistry::get_or_spawn). Same Explicit contract as
228    // HttpConsumer — bind failures now surface as route-start errors.
229    fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
230        camel_component_api::ConsumerStartupMode::Explicit
231    }
232}
233
234// ---------------------------------------------------------------------------
235// Tests
236// ---------------------------------------------------------------------------
237
238#[cfg(test)]
239mod tests {
240    use camel_component_api::test_support::PanicRuntimeObservability;
241    fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
242        std::sync::Arc::new(PanicRuntimeObservability)
243    }
244    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
245        std::sync::Arc::new(PanicRuntimeObservability)
246    }
247
248    use super::*;
249    use crate::REGISTRY_TEST_MUTEX;
250    use camel_component_api::{ConsumerContext, ExchangeEnvelope};
251    use std::path::PathBuf;
252    use std::sync::Arc;
253    use tokio::sync::{Notify, mpsc};
254    use tokio_util::sync::CancellationToken;
255
256    /// Helper: create a test consumer context with a controllable cancellation.
257    fn test_consumer_ctx(notify: Arc<Notify>) -> ConsumerContext {
258        let (tx, _rx) = mpsc::channel::<ExchangeEnvelope>(16);
259        let token = CancellationToken::new();
260        // Spawn a task that cancels when notified
261        let token_clone = token.clone();
262        tokio::spawn(async move {
263            notify.notified().await;
264            token_clone.cancel();
265        });
266        ConsumerContext::new(tx, token, "http-static-test-route".to_string())
267    }
268
269    #[test]
270    fn test_component_scheme() {
271        let component = HttpStaticComponent::new();
272        assert_eq!(component.scheme(), "http-static");
273    }
274
275    #[test]
276    fn test_component_with_config() {
277        let config = HttpStaticConfig {
278            dir: PathBuf::from("/tmp"),
279            port: 9999,
280            ..HttpStaticConfig::default()
281        };
282        let component = HttpStaticComponent::with_config(config.clone());
283        assert_eq!(component.scheme(), "http-static");
284    }
285
286    #[test]
287    fn test_endpoint_creates_consumer() {
288        let config = HttpStaticConfig {
289            dir: PathBuf::from("/tmp"),
290            ..HttpStaticConfig::default()
291        };
292        let endpoint = HttpStaticEndpoint {
293            uri: "http-static:/tmp".to_string(),
294            config,
295        };
296        let consumer = endpoint.create_consumer(rt());
297        assert!(consumer.is_ok());
298    }
299
300    #[test]
301    fn test_endpoint_producer_not_supported() {
302        let config = HttpStaticConfig {
303            dir: PathBuf::from("/tmp"),
304            ..HttpStaticConfig::default()
305        };
306        let endpoint = HttpStaticEndpoint {
307            uri: "http-static:/tmp".to_string(),
308            config,
309        };
310        let ctx = camel_component_api::ProducerContext::new();
311        let result = endpoint.create_producer(rt(), &ctx);
312        assert!(result.is_err());
313        if let Err(CamelError::Config(msg)) = result {
314            assert!(msg.contains("does not support producers"));
315        } else {
316            panic!("Expected Config error");
317        }
318    }
319
320    /// rc-w1u9: HttpStaticConsumer MUST declare Explicit startup_mode so the
321    /// runtime waits for the listener bind before publishing RouteStarted.
322    #[test]
323    fn test_static_consumer_startup_mode_is_explicit() {
324        use camel_component_api::ConsumerStartupMode;
325        let config = HttpStaticConfig {
326            dir: PathBuf::from("/tmp"),
327            port: 0,
328            ..HttpStaticConfig::default()
329        };
330        let consumer = HttpStaticConsumer::new(config, test_rt());
331        assert_eq!(
332            consumer.startup_mode(),
333            ConsumerStartupMode::Explicit,
334            "HttpStaticConsumer must opt into Explicit startup"
335        );
336    }
337
338    /// rc-w1u9: HttpStaticConsumer::start() MUST call ctx.mark_ready() after
339    /// the static mount is registered. Inject our own StartupSignal pair and
340    /// assert the receiver resolves within a bounded window.
341    #[allow(clippy::await_holding_lock)]
342    #[tokio::test]
343    async fn test_static_consumer_emits_mark_ready_after_register() {
344        use camel_component_api::{ConsumerContext, StartupSignal};
345
346        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
347        ServerRegistry::reset();
348
349        let dir = std::env::temp_dir();
350        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
351        let port = listener.local_addr().unwrap().port();
352        drop(listener);
353
354        let config = HttpStaticConfig {
355            dir,
356            port,
357            host: "127.0.0.1".to_string(),
358            ..HttpStaticConfig::default()
359        };
360        let mut consumer = HttpStaticConsumer::new(config, test_rt());
361
362        let (tx, _rx) = mpsc::channel::<ExchangeEnvelope>(16);
363        let token = CancellationToken::new();
364        let ctx = ConsumerContext::new(tx, token.clone(), "static-ready-probe".to_string());
365
366        let (signal, startup_rx) = StartupSignal::pair();
367        let ctx = ctx.with_startup(signal);
368
369        tokio::spawn(async move {
370            let _ = consumer.start(ctx).await;
371        });
372
373        let result =
374            tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
375                .await
376                .expect("HttpStaticConsumer must call ctx.mark_ready() after register (rc-w1u9)");
377        assert!(result.is_ok(), "mark_ready must resolve Ok after register");
378
379        token.cancel();
380    }
381
382    #[tokio::test]
383    async fn test_consumer_start_nonexistent_dir_returns_error() {
384        let config = HttpStaticConfig {
385            dir: PathBuf::from("/nonexistent/path/that/does/not/exist"),
386            port: 19900,
387            ..HttpStaticConfig::default()
388        };
389        let mut consumer = HttpStaticConsumer::new(config, test_rt());
390        let notify = Arc::new(Notify::new());
391        let ctx = test_consumer_ctx(notify);
392
393        let result = consumer.start(ctx).await;
394        assert!(result.is_err());
395        if let Err(CamelError::Config(msg)) = result {
396            assert!(msg.contains("directory not found"));
397        } else {
398            panic!("Expected Config error for nonexistent dir");
399        }
400    }
401
402    #[allow(clippy::await_holding_lock)]
403    #[tokio::test]
404    async fn test_consumer_start_registers_mount_in_registry() {
405        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
406        // Reset registry for clean test
407        ServerRegistry::reset();
408
409        let dir = std::env::temp_dir();
410        let canonical_dir = std::fs::canonicalize(&dir).unwrap();
411
412        // Bind to a free port first
413        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
414        let port = listener.local_addr().unwrap().port();
415        drop(listener);
416
417        let config = HttpStaticConfig {
418            dir: dir.clone(),
419            port,
420            host: "127.0.0.1".to_string(),
421            ..HttpStaticConfig::default()
422        };
423
424        // Build ServeDir directly (same as consumer does)
425        let serve_dir = ServeDir::new(&canonical_dir)
426            .precompressed_gzip()
427            .precompressed_br()
428            .append_index_html_on_directories(true);
429
430        // Get registry (this spawns the axum server)
431        let registry = ServerRegistry::global()
432            .get_or_spawn(
433                "127.0.0.1",
434                port,
435                2 * 1024 * 1024,
436                10 * 1024 * 1024,
437                1024,
438                test_rt(),
439                "test-static".into(),
440                None,
441            )
442            .await
443            .unwrap();
444
445        // Register mount
446        let mount = StaticMount {
447            mount_path: "/".to_string(),
448            mode: MountMode::Static,
449            dir: canonical_dir.clone(),
450            cache_control: config.cache_control.clone(),
451            error_pages: std::collections::HashMap::new(),
452            serve_dir,
453        };
454        registry.register_static_mount(mount).await.unwrap();
455
456        // Verify registered
457        let inner = registry.inner.read().await;
458        assert_eq!(
459            inner.mounts.len(),
460            1,
461            "Expected one static mount registered"
462        );
463        assert_eq!(inner.mounts[0].dir, canonical_dir);
464        assert_eq!(inner.mounts[0].mount_path, "/");
465    }
466
467    #[allow(clippy::await_holding_lock)]
468    #[tokio::test]
469    async fn test_consumer_stop_unregisters_mount() {
470        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
471        // Reset registry for clean test
472        ServerRegistry::reset();
473
474        let dir = std::env::temp_dir();
475        let canonical_dir = std::fs::canonicalize(&dir).unwrap();
476
477        // Bind to a free port
478        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
479        let port = listener.local_addr().unwrap().port();
480        drop(listener);
481
482        // Get registry
483        let registry = ServerRegistry::global()
484            .get_or_spawn(
485                "127.0.0.1",
486                port,
487                2 * 1024 * 1024,
488                10 * 1024 * 1024,
489                1024,
490                test_rt(),
491                "test-static".into(),
492                None,
493            )
494            .await
495            .unwrap();
496
497        // Register mount
498        let serve_dir = ServeDir::new(&canonical_dir)
499            .precompressed_gzip()
500            .precompressed_br()
501            .append_index_html_on_directories(true);
502        let mount = StaticMount {
503            mount_path: "/".to_string(),
504            mode: MountMode::Static,
505            dir: canonical_dir.clone(),
506            cache_control: "public, max-age=0".to_string(),
507            error_pages: std::collections::HashMap::new(),
508            serve_dir,
509        };
510        registry.register_static_mount(mount).await.unwrap();
511
512        // Verify registered
513        {
514            let inner = registry.inner.read().await;
515            assert_eq!(inner.mounts.len(), 1);
516        }
517
518        // Unregister by mount_path
519        registry.unregister_static_mount("/").await;
520
521        // Verify unregistered
522        let inner = registry.inner.read().await;
523        assert_eq!(
524            inner.mounts.len(),
525            0,
526            "Expected static mount to be unregistered"
527        );
528    }
529}