Skip to main content

camel_component_http/
static_endpoint.rs

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