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
14pub 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
98pub 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
134pub struct HttpStaticConsumer {
150 config: HttpStaticConfig,
151 #[allow(dead_code)]
154 runtime: Arc<dyn RuntimeObservability>,
155}
156
157impl HttpStaticConsumer {
158 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 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 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 let serve_dir = ServeDir::new(&dir)
197 .precompressed_gzip()
198 .precompressed_br()
199 .append_index_html_on_directories(true);
200
201 let registry = ServerRegistry::global()
203 .get_or_spawn(
204 &self.config.host,
205 self.config.port,
206 2 * 1024 * 1024, 10 * 1024 * 1024, 1024, self.runtime.clone(),
210 ctx.route_id().to_string(),
211 None,
212 )
213 .await?;
214
215 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 ctx.cancelled().await;
237
238 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#[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 fn test_consumer_ctx(notify: Arc<Notify>) -> ConsumerContext {
279 let (tx, _rx) = mpsc::channel::<ExchangeEnvelope>(16);
280 let token = CancellationToken::new();
281 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 ServerRegistry::reset();
367
368 let dir = std::env::temp_dir();
369 let canonical_dir = std::fs::canonicalize(&dir).unwrap();
370
371 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 let serve_dir = ServeDir::new(&canonical_dir)
385 .precompressed_gzip()
386 .precompressed_br()
387 .append_index_html_on_directories(true);
388
389 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 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 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 ServerRegistry::reset();
432
433 let dir = std::env::temp_dir();
434 let canonical_dir = std::fs::canonicalize(&dir).unwrap();
435
436 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 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 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 {
473 let inner = registry.inner.read().await;
474 assert_eq!(inner.mounts.len(), 1);
475 }
476
477 registry.unregister_static_mount("/").await;
479
480 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}