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
12pub 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
64pub 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
100pub struct HttpStaticConsumer {
116 config: HttpStaticConfig,
117 runtime: Arc<dyn RuntimeObservability>,
122}
123
124impl HttpStaticConsumer {
125 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 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 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 let serve_dir = ServeDir::new(&dir)
164 .precompressed_gzip()
165 .precompressed_br()
166 .append_index_html_on_directories(true);
167
168 let registry = ServerRegistry::global()
170 .get_or_spawn(
171 &self.config.host,
172 self.config.port,
173 2 * 1024 * 1024, 10 * 1024 * 1024, 1024, self.runtime.clone(),
177 ctx.route_id().to_string(),
178 None,
179 )
180 .await?;
181
182 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 ctx.mark_ready();
203
204 let mount_path_for_cleanup = self.config.mount_path.clone();
205 let registry_for_cleanup = registry.clone();
206
207 ctx.cancelled().await;
209
210 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 fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
230 camel_component_api::ConsumerStartupMode::Explicit
231 }
232}
233
234#[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 camel_component_api::{ConsumerContext, ExchangeEnvelope};
250 use std::path::PathBuf;
251 use std::sync::Arc;
252 use tokio::sync::{Notify, mpsc};
253 use tokio_util::sync::CancellationToken;
254
255 fn test_consumer_ctx(notify: Arc<Notify>) -> ConsumerContext {
257 let (tx, _rx) = mpsc::channel::<ExchangeEnvelope>(16);
258 let token = CancellationToken::new();
259 let token_clone = token.clone();
261 tokio::spawn(async move {
262 notify.notified().await;
263 token_clone.cancel();
264 });
265 ConsumerContext::new(tx, token, "http-static-test-route".to_string())
266 }
267
268 #[test]
269 fn test_component_scheme() {
270 let component = HttpStaticComponent::new();
271 assert_eq!(component.scheme(), "http-static");
272 }
273
274 #[test]
275 fn test_component_with_config() {
276 let config = HttpStaticConfig {
277 dir: PathBuf::from("/tmp"),
278 port: 9999,
279 ..HttpStaticConfig::default()
280 };
281 let component = HttpStaticComponent::with_config(config.clone());
282 assert_eq!(component.scheme(), "http-static");
283 }
284
285 #[test]
286 fn test_endpoint_creates_consumer() {
287 let config = HttpStaticConfig {
288 dir: PathBuf::from("/tmp"),
289 ..HttpStaticConfig::default()
290 };
291 let endpoint = HttpStaticEndpoint {
292 uri: "http-static:/tmp".to_string(),
293 config,
294 };
295 let consumer = endpoint.create_consumer(rt());
296 assert!(consumer.is_ok());
297 }
298
299 #[test]
300 fn test_endpoint_producer_not_supported() {
301 let config = HttpStaticConfig {
302 dir: PathBuf::from("/tmp"),
303 ..HttpStaticConfig::default()
304 };
305 let endpoint = HttpStaticEndpoint {
306 uri: "http-static:/tmp".to_string(),
307 config,
308 };
309 let ctx = camel_component_api::ProducerContext::new();
310 let result = endpoint.create_producer(rt(), &ctx);
311 assert!(result.is_err());
312 if let Err(CamelError::Config(msg)) = result {
313 assert!(msg.contains("does not support producers"));
314 } else {
315 panic!("Expected Config error");
316 }
317 }
318
319 #[test]
322 fn test_static_consumer_startup_mode_is_explicit() {
323 use camel_component_api::ConsumerStartupMode;
324 let config = HttpStaticConfig {
325 dir: PathBuf::from("/tmp"),
326 port: 0,
327 ..HttpStaticConfig::default()
328 };
329 let consumer = HttpStaticConsumer::new(config, test_rt());
330 assert_eq!(
331 consumer.startup_mode(),
332 ConsumerStartupMode::Explicit,
333 "HttpStaticConsumer must opt into Explicit startup"
334 );
335 }
336
337 #[allow(clippy::await_holding_lock)]
341 #[tokio::test]
342 async fn test_static_consumer_emits_mark_ready_after_register() {
343 use camel_component_api::{ConsumerContext, StartupSignal};
344
345 let _guard = crate::lock_registry_test_mutex();
346 ServerRegistry::reset();
347
348 let dir = std::env::temp_dir();
349 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
353 let port = listener.local_addr().unwrap().port();
354 ServerRegistry::global()
355 .stage_listener(listener)
356 .await
357 .expect("stage consumer test listener");
358
359 let config = HttpStaticConfig {
360 dir,
361 port,
362 host: "127.0.0.1".to_string(),
363 ..HttpStaticConfig::default()
364 };
365 let mut consumer = HttpStaticConsumer::new(config, test_rt());
366
367 let (tx, _rx) = mpsc::channel::<ExchangeEnvelope>(16);
368 let token = CancellationToken::new();
369 let ctx = ConsumerContext::new(tx, token.clone(), "static-ready-probe".to_string());
370
371 let (signal, startup_rx) = StartupSignal::pair();
372 let ctx = ctx.with_startup(signal);
373
374 tokio::spawn(async move {
375 let _ = consumer.start(ctx).await;
376 });
377
378 let result =
379 tokio::time::timeout(std::time::Duration::from_secs(2), startup_rx.await_ready())
380 .await
381 .expect("HttpStaticConsumer must call ctx.mark_ready() after register (rc-w1u9)");
382 assert!(result.is_ok(), "mark_ready must resolve Ok after register");
383
384 token.cancel();
385 }
386
387 #[tokio::test]
388 async fn test_consumer_start_nonexistent_dir_returns_error() {
389 let config = HttpStaticConfig {
390 dir: PathBuf::from("/nonexistent/path/that/does/not/exist"),
391 port: 19900,
392 ..HttpStaticConfig::default()
393 };
394 let mut consumer = HttpStaticConsumer::new(config, test_rt());
395 let notify = Arc::new(Notify::new());
396 let ctx = test_consumer_ctx(notify);
397
398 let result = consumer.start(ctx).await;
399 assert!(result.is_err());
400 if let Err(CamelError::Config(msg)) = result {
401 assert!(msg.contains("directory not found"));
402 } else {
403 panic!("Expected Config error for nonexistent dir");
404 }
405 }
406
407 #[allow(clippy::await_holding_lock)]
408 #[tokio::test]
409 async fn test_consumer_start_registers_mount_in_registry() {
410 let _guard = crate::lock_registry_test_mutex();
411 ServerRegistry::reset();
413
414 let dir = std::env::temp_dir();
415 let canonical_dir = std::fs::canonicalize(&dir).unwrap();
416
417 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
421 let port = listener.local_addr().unwrap().port();
422 ServerRegistry::global()
423 .stage_listener(listener)
424 .await
425 .expect("stage consumer test listener");
426
427 let config = HttpStaticConfig {
428 dir: dir.clone(),
429 port,
430 host: "127.0.0.1".to_string(),
431 ..HttpStaticConfig::default()
432 };
433
434 let serve_dir = ServeDir::new(&canonical_dir)
436 .precompressed_gzip()
437 .precompressed_br()
438 .append_index_html_on_directories(true);
439
440 let registry = ServerRegistry::global()
442 .get_or_spawn(
443 "127.0.0.1",
444 port,
445 2 * 1024 * 1024,
446 10 * 1024 * 1024,
447 1024,
448 test_rt(),
449 "test-static".into(),
450 None,
451 )
452 .await
453 .unwrap();
454
455 let mount = StaticMount {
457 mount_path: "/".to_string(),
458 mode: MountMode::Static,
459 dir: canonical_dir.clone(),
460 cache_control: config.cache_control.clone(),
461 error_pages: std::collections::HashMap::new(),
462 serve_dir,
463 };
464 registry.register_static_mount(mount).await.unwrap();
465
466 let inner = registry.inner.read().await;
468 assert_eq!(
469 inner.mounts.len(),
470 1,
471 "Expected one static mount registered"
472 );
473 assert_eq!(inner.mounts[0].dir, canonical_dir);
474 assert_eq!(inner.mounts[0].mount_path, "/");
475 }
476
477 #[allow(clippy::await_holding_lock)]
478 #[tokio::test]
479 async fn test_consumer_stop_unregisters_mount() {
480 let _guard = crate::lock_registry_test_mutex();
481 ServerRegistry::reset();
483
484 let dir = std::env::temp_dir();
485 let canonical_dir = std::fs::canonicalize(&dir).unwrap();
486
487 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
491 let port = listener.local_addr().unwrap().port();
492 ServerRegistry::global()
493 .stage_listener(listener)
494 .await
495 .expect("stage consumer test listener");
496
497 let registry = ServerRegistry::global()
499 .get_or_spawn(
500 "127.0.0.1",
501 port,
502 2 * 1024 * 1024,
503 10 * 1024 * 1024,
504 1024,
505 test_rt(),
506 "test-static".into(),
507 None,
508 )
509 .await
510 .unwrap();
511
512 let serve_dir = ServeDir::new(&canonical_dir)
514 .precompressed_gzip()
515 .precompressed_br()
516 .append_index_html_on_directories(true);
517 let mount = StaticMount {
518 mount_path: "/".to_string(),
519 mode: MountMode::Static,
520 dir: canonical_dir.clone(),
521 cache_control: "public, max-age=0".to_string(),
522 error_pages: std::collections::HashMap::new(),
523 serve_dir,
524 };
525 registry.register_static_mount(mount).await.unwrap();
526
527 {
529 let inner = registry.inner.read().await;
530 assert_eq!(inner.mounts.len(), 1);
531 }
532
533 registry.unregister_static_mount("/").await;
535
536 let inner = registry.inner.read().await;
538 assert_eq!(
539 inner.mounts.len(),
540 0,
541 "Expected static mount to be unregistered"
542 );
543 }
544}