1use std::sync::Arc;
2
3use camel_component_api::{
4 CamelError, Component, Consumer, Endpoint, ProducerContext, RuntimeObservability,
5};
6use tower_http::services::ServeDir;
7
8use crate::registry::{MountMode, StaticMount};
9use crate::{HttpStaticConfig, ServerRegistry};
10
11pub struct HttpStaticComponent {
20 config: HttpStaticConfig,
21}
22
23impl HttpStaticComponent {
24 pub fn new() -> Self {
25 Self {
26 config: HttpStaticConfig::default(),
27 }
28 }
29
30 pub fn with_config(config: HttpStaticConfig) -> Self {
31 Self { config }
32 }
33}
34
35impl Default for HttpStaticComponent {
36 fn default() -> Self {
37 Self::new()
38 }
39}
40
41impl Component for HttpStaticComponent {
42 fn scheme(&self) -> &str {
43 "http-static"
44 }
45
46 fn create_endpoint(
47 &self,
48 uri: &str,
49 _ctx: &dyn camel_component_api::ComponentContext,
50 ) -> Result<Box<dyn Endpoint>, CamelError> {
51 let config = HttpStaticConfig::from_uri_with_defaults(uri, &self.config)?;
52 Ok(Box::new(HttpStaticEndpoint {
53 uri: uri.to_string(),
54 config,
55 }))
56 }
57}
58
59pub struct HttpStaticEndpoint {
68 uri: String,
69 config: HttpStaticConfig,
70}
71
72impl Endpoint for HttpStaticEndpoint {
73 fn uri(&self) -> &str {
74 &self.uri
75 }
76
77 fn create_consumer(
78 &self,
79 rt: Arc<dyn RuntimeObservability>,
80 ) -> Result<Box<dyn Consumer>, CamelError> {
81 Ok(Box::new(HttpStaticConsumer::new(self.config.clone(), rt)))
82 }
83
84 fn create_producer(
85 &self,
86 _rt: Arc<dyn RuntimeObservability>,
87 _ctx: &ProducerContext,
88 ) -> Result<camel_component_api::BoxProcessor, CamelError> {
89 Err(CamelError::Config(
90 "http-static endpoint does not support producers".to_string(),
91 ))
92 }
93}
94
95pub struct HttpStaticConsumer {
111 config: HttpStaticConfig,
112 #[allow(dead_code)]
115 runtime: Arc<dyn RuntimeObservability>,
116}
117
118impl HttpStaticConsumer {
119 pub fn new(config: HttpStaticConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
121 Self { config, runtime }
122 }
123}
124
125#[async_trait::async_trait]
126impl Consumer for HttpStaticConsumer {
127 async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
128 let dir = std::fs::canonicalize(&self.config.dir).map_err(|e| {
130 CamelError::Config(format!(
131 "http-static directory not found: {}: {}",
132 self.config.dir.display(),
133 e
134 ))
135 })?;
136
137 let mut error_pages = std::collections::HashMap::new();
139 for (code, path) in &self.config.error_pages {
140 let resolved = if path.is_absolute() {
141 path.clone()
142 } else {
143 self.config.dir.join(path)
144 };
145 let canonical = std::fs::canonicalize(&resolved).map_err(|e| {
146 CamelError::Config(format!(
147 "http-static error page not found for status {}: {}: {}",
148 code,
149 resolved.display(),
150 e
151 ))
152 })?;
153 error_pages.insert(*code, canonical);
154 }
155
156 let serve_dir = ServeDir::new(&dir)
158 .precompressed_gzip()
159 .precompressed_br()
160 .append_index_html_on_directories(true);
161
162 let registry = ServerRegistry::global()
164 .get_or_spawn(
165 &self.config.host,
166 self.config.port,
167 2 * 1024 * 1024, 10 * 1024 * 1024, 1024, self.runtime.clone(),
171 ctx.route_id().to_string(),
172 None,
173 )
174 .await?;
175
176 let mode = if self.config.spa_fallback {
178 MountMode::Spa
179 } else {
180 MountMode::Static
181 };
182 let mount = StaticMount {
183 mount_path: self.config.mount_path.clone(),
184 mode,
185 dir: dir.clone(),
186 cache_control: self.config.cache_control.clone(),
187 error_pages,
188 serve_dir,
189 };
190
191 registry.register_static_mount(mount).await?;
192
193 let mount_path_for_cleanup = self.config.mount_path.clone();
194 let registry_for_cleanup = registry.clone();
195
196 ctx.cancelled().await;
198
199 registry_for_cleanup
201 .unregister_static_mount(&mount_path_for_cleanup)
202 .await;
203
204 Ok(())
205 }
206
207 async fn stop(&mut self) -> Result<(), CamelError> {
208 Ok(())
209 }
210
211 fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
212 camel_component_api::ConcurrencyModel::Sequential
213 }
214}
215
216#[cfg(test)]
221mod tests {
222 use camel_component_api::test_support::PanicRuntimeObservability;
223 fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
224 std::sync::Arc::new(PanicRuntimeObservability)
225 }
226 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
227 std::sync::Arc::new(PanicRuntimeObservability)
228 }
229
230 use super::*;
231 use crate::REGISTRY_TEST_MUTEX;
232 use camel_component_api::{ConsumerContext, ExchangeEnvelope};
233 use std::path::PathBuf;
234 use std::sync::Arc;
235 use tokio::sync::{Notify, mpsc};
236 use tokio_util::sync::CancellationToken;
237
238 fn test_consumer_ctx(notify: Arc<Notify>) -> ConsumerContext {
240 let (tx, _rx) = mpsc::channel::<ExchangeEnvelope>(16);
241 let token = CancellationToken::new();
242 let token_clone = token.clone();
244 tokio::spawn(async move {
245 notify.notified().await;
246 token_clone.cancel();
247 });
248 ConsumerContext::new(tx, token, "http-static-test-route".to_string())
249 }
250
251 #[test]
252 fn test_component_scheme() {
253 let component = HttpStaticComponent::new();
254 assert_eq!(component.scheme(), "http-static");
255 }
256
257 #[test]
258 fn test_component_with_config() {
259 let config = HttpStaticConfig {
260 dir: PathBuf::from("/tmp"),
261 port: 9999,
262 ..HttpStaticConfig::default()
263 };
264 let component = HttpStaticComponent::with_config(config.clone());
265 assert_eq!(component.scheme(), "http-static");
266 }
267
268 #[test]
269 fn test_endpoint_creates_consumer() {
270 let config = HttpStaticConfig {
271 dir: PathBuf::from("/tmp"),
272 ..HttpStaticConfig::default()
273 };
274 let endpoint = HttpStaticEndpoint {
275 uri: "http-static:/tmp".to_string(),
276 config,
277 };
278 let consumer = endpoint.create_consumer(rt());
279 assert!(consumer.is_ok());
280 }
281
282 #[test]
283 fn test_endpoint_producer_not_supported() {
284 let config = HttpStaticConfig {
285 dir: PathBuf::from("/tmp"),
286 ..HttpStaticConfig::default()
287 };
288 let endpoint = HttpStaticEndpoint {
289 uri: "http-static:/tmp".to_string(),
290 config,
291 };
292 let ctx = camel_component_api::ProducerContext::new();
293 let result = endpoint.create_producer(rt(), &ctx);
294 assert!(result.is_err());
295 if let Err(CamelError::Config(msg)) = result {
296 assert!(msg.contains("does not support producers"));
297 } else {
298 panic!("Expected Config error");
299 }
300 }
301
302 #[tokio::test]
303 async fn test_consumer_start_nonexistent_dir_returns_error() {
304 let config = HttpStaticConfig {
305 dir: PathBuf::from("/nonexistent/path/that/does/not/exist"),
306 port: 19900,
307 ..HttpStaticConfig::default()
308 };
309 let mut consumer = HttpStaticConsumer::new(config, test_rt());
310 let notify = Arc::new(Notify::new());
311 let ctx = test_consumer_ctx(notify);
312
313 let result = consumer.start(ctx).await;
314 assert!(result.is_err());
315 if let Err(CamelError::Config(msg)) = result {
316 assert!(msg.contains("directory not found"));
317 } else {
318 panic!("Expected Config error for nonexistent dir");
319 }
320 }
321
322 #[allow(clippy::await_holding_lock)]
323 #[tokio::test]
324 async fn test_consumer_start_registers_mount_in_registry() {
325 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
326 ServerRegistry::reset();
328
329 let dir = std::env::temp_dir();
330 let canonical_dir = std::fs::canonicalize(&dir).unwrap();
331
332 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
334 let port = listener.local_addr().unwrap().port();
335 drop(listener);
336
337 let config = HttpStaticConfig {
338 dir: dir.clone(),
339 port,
340 host: "127.0.0.1".to_string(),
341 ..HttpStaticConfig::default()
342 };
343
344 let serve_dir = ServeDir::new(&canonical_dir)
346 .precompressed_gzip()
347 .precompressed_br()
348 .append_index_html_on_directories(true);
349
350 let registry = ServerRegistry::global()
352 .get_or_spawn(
353 "127.0.0.1",
354 port,
355 2 * 1024 * 1024,
356 10 * 1024 * 1024,
357 1024,
358 test_rt(),
359 "test-static".into(),
360 None,
361 )
362 .await
363 .unwrap();
364
365 let mount = StaticMount {
367 mount_path: "/".to_string(),
368 mode: MountMode::Static,
369 dir: canonical_dir.clone(),
370 cache_control: config.cache_control.clone(),
371 error_pages: std::collections::HashMap::new(),
372 serve_dir,
373 };
374 registry.register_static_mount(mount).await.unwrap();
375
376 let inner = registry.inner.read().await;
378 assert_eq!(
379 inner.mounts.len(),
380 1,
381 "Expected one static mount registered"
382 );
383 assert_eq!(inner.mounts[0].dir, canonical_dir);
384 assert_eq!(inner.mounts[0].mount_path, "/");
385 }
386
387 #[allow(clippy::await_holding_lock)]
388 #[tokio::test]
389 async fn test_consumer_stop_unregisters_mount() {
390 let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
391 ServerRegistry::reset();
393
394 let dir = std::env::temp_dir();
395 let canonical_dir = std::fs::canonicalize(&dir).unwrap();
396
397 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
399 let port = listener.local_addr().unwrap().port();
400 drop(listener);
401
402 let registry = ServerRegistry::global()
404 .get_or_spawn(
405 "127.0.0.1",
406 port,
407 2 * 1024 * 1024,
408 10 * 1024 * 1024,
409 1024,
410 test_rt(),
411 "test-static".into(),
412 None,
413 )
414 .await
415 .unwrap();
416
417 let serve_dir = ServeDir::new(&canonical_dir)
419 .precompressed_gzip()
420 .precompressed_br()
421 .append_index_html_on_directories(true);
422 let mount = StaticMount {
423 mount_path: "/".to_string(),
424 mode: MountMode::Static,
425 dir: canonical_dir.clone(),
426 cache_control: "public, max-age=0".to_string(),
427 error_pages: std::collections::HashMap::new(),
428 serve_dir,
429 };
430 registry.register_static_mount(mount).await.unwrap();
431
432 {
434 let inner = registry.inner.read().await;
435 assert_eq!(inner.mounts.len(), 1);
436 }
437
438 registry.unregister_static_mount("/").await;
440
441 let inner = registry.inner.read().await;
443 assert_eq!(
444 inner.mounts.len(),
445 0,
446 "Expected static mount to be unregistered"
447 );
448 }
449}