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