Skip to main content

camel_component_http/
lib.rs

1pub mod auth;
2pub mod bundle;
3pub mod config;
4pub mod health;
5pub mod registry;
6pub mod static_config;
7pub mod static_dispatch;
8pub mod static_endpoint;
9use crate::config::parse_ok_status_code_range;
10pub use bundle::HttpBundle;
11pub use bundle::HttpStaticBundle;
12pub use config::HttpConfig;
13pub use health::HttpHealthCheck;
14pub use registry::HttpRouteRegistry;
15pub use static_config::HttpStaticConfig;
16pub use static_endpoint::{HttpStaticComponent, HttpStaticConsumer, HttpStaticEndpoint};
17
18use std::collections::HashMap;
19use std::future::Future;
20use std::net::IpAddr;
21use std::pin::Pin;
22use std::sync::{Arc, Mutex, OnceLock};
23use std::task::{Context, Poll};
24use std::time::Duration;
25
26use tokio::sync::OnceCell;
27use tower::Layer;
28use tower::Service;
29use tracing::debug;
30
31use axum::body::BodyDataStream;
32use camel_auth::bearer_token_layer::BearerTokenLayer;
33use camel_auth::oauth2::TokenProvider;
34use camel_component_api::{Body, BoxProcessor, CamelError, Exchange, StreamBody, StreamMetadata};
35use camel_component_api::{Component, Consumer, Endpoint, ProducerContext, RuntimeObservability};
36use camel_component_api::{UriComponents, UriConfig, parse_uri};
37use futures::TryStreamExt;
38use futures::stream::BoxStream;
39
40// ---------------------------------------------------------------------------
41// HttpEndpointConfig
42// ---------------------------------------------------------------------------
43
44/// Configuration for an HTTP client (producer) endpoint.
45///
46/// # Memory Limits
47///
48/// HTTP operations enforce conservative memory limits to prevent denial-of-service
49/// attacks from untrusted network sources. These limits are significantly lower than
50/// file component limits (100MB) because HTTP typically handles API responses rather
51/// than large file transfers, and clients may be untrusted.
52///
53/// ## Default Limits
54///
55/// - **HTTP client body**: 10MB (typical API responses)
56/// - **HTTP server request**: 2MB (untrusted network input - see `HttpServerConfig`)
57/// - **HTTP server response**: 10MB (same as client - see `HttpServerConfig`)
58///
59/// ## Rationale
60///
61/// The 10MB limit for HTTP client responses is appropriate for most API interactions
62/// while providing protection against:
63/// - Malicious servers sending oversized responses
64/// - Runaway processes generating unexpectedly large payloads
65/// - Memory exhaustion attacks
66///
67/// The 2MB server request limit is even more conservative because it handles input
68/// from potentially untrusted clients on the public internet.
69///
70/// ## Overriding Limits
71///
72/// Override the default client body limit using the `maxBodySize` URI parameter:
73///
74/// ```text
75/// http://api.example.com/large-data?maxBodySize=52428800
76/// ```
77///
78/// For server endpoints, use `maxRequestBody` and `maxResponseBody` parameters:
79///
80/// ```text
81/// http://0.0.0.0:8080/upload?maxRequestBody=52428800
82/// ```
83///
84/// ## Behavior When Exceeded
85///
86/// When a body exceeds the configured limit:
87/// - An error is returned immediately
88/// - No memory is exhausted - the limit is checked before allocation
89/// - The HTTP connection is terminated cleanly
90///
91/// ## Security Considerations
92///
93/// HTTP endpoints should be treated with more caution than file endpoints because:
94/// - Clients may be unknown and untrusted
95/// - Network traffic can be spoofed or malicious
96/// - DoS attacks often exploit unbounded resource consumption
97///
98/// Only increase limits when you control both ends of the connection or when
99/// business requirements demand larger payloads.
100#[derive(Debug, Clone)]
101pub struct HttpEndpointConfig {
102    pub base_url: String,
103    pub http_method: Option<String>,
104    pub throw_exception_on_failure: bool,
105    pub ok_status_code_range: (u16, u16),
106    pub response_timeout: Option<Duration>,
107    pub query_params: HashMap<String, String>,
108    pub allow_private_ips: bool,
109    pub blocked_hosts: Vec<String>,
110    pub max_body_size: usize,
111    pub read_timeout_ms: u64,
112    pub max_response_bytes: usize,
113    pub auth: HttpAuth,
114    pub token_provider: Option<Arc<dyn TokenProvider>>,
115    pub user_agent: Option<String>,
116    pub cookie_handling: CookieHandling,
117    pub bridge_endpoint: bool,
118    pub connection_close: bool,
119    pub skip_request_headers: Vec<String>,
120    pub skip_response_headers: Vec<String>,
121}
122
123#[derive(Clone, PartialEq)]
124pub enum HttpAuth {
125    None,
126    Basic { username: String, password: String },
127    Bearer { token: String },
128}
129
130impl std::fmt::Debug for HttpAuth {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        match self {
133            HttpAuth::None => f.write_str("None"),
134            HttpAuth::Basic { username, .. } => f
135                .debug_struct("Basic")
136                .field("username", username)
137                .field("password", &"***")
138                .finish(),
139            HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
140        }
141    }
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum CookieHandling {
146    Disabled,
147    InMemory,
148}
149
150/// Camel options that should NOT be forwarded as HTTP query params
151const HTTP_CAMEL_OPTIONS: &[&str] = &[
152    "httpMethod",
153    "throwExceptionOnFailure",
154    "okStatusCodeRange",
155    "followRedirects",
156    "connectTimeout",
157    "responseTimeout",
158    "allowPrivateIps",
159    "blockedHosts",
160    "maxBodySize",
161    "readTimeout",
162    "maxResponseBytes",
163    "authMethod",
164    "authUsername",
165    "authPassword",
166    "authBearerToken",
167    "userAgent",
168    "cookieHandling",
169    "bridgeEndpoint",
170    "connectionClose",
171    "skipRequestHeaders",
172    "skipResponseHeaders",
173];
174
175impl UriConfig for HttpEndpointConfig {
176    /// Returns "http" as the primary scheme (also accepts "https")
177    fn scheme() -> &'static str {
178        "http"
179    }
180
181    fn from_uri(uri: &str) -> Result<Self, CamelError> {
182        let parts = parse_uri(uri)?;
183        Self::from_components(parts)
184    }
185
186    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
187        // Validate scheme - accept both http and https
188        if parts.scheme != "http" && parts.scheme != "https" {
189            return Err(CamelError::InvalidUri(format!(
190                "expected scheme 'http' or 'https', got '{}'",
191                parts.scheme
192            )));
193        }
194
195        // Construct base_url from scheme + path
196        // e.g., "http://localhost:8080/api" from scheme "http" and path "//localhost:8080/api"
197        let base_url = format!("{}:{}", parts.scheme, parts.path);
198
199        let http_method = parts.params.get("httpMethod").cloned();
200
201        let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
202            Some(v) => parse_bool_param_http(v).map_err(|e| {
203                CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
204            })?,
205            None => true,
206        };
207
208        // Parse status code range from "start-end" format (e.g., "200-299")
209        let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
210            Some(v) => parse_ok_status_code_range(v)?,
211            None => (200, 299),
212        };
213
214        let response_timeout = match parts.params.get("responseTimeout") {
215            Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
216                CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
217            })?),
218            None => None,
219        };
220
221        // SSRF protection settings
222        let allow_private_ips = match parts.params.get("allowPrivateIps") {
223            Some(v) => parse_bool_param_http(v).map_err(|e| {
224                CamelError::InvalidUri(format!("invalid value for allowPrivateIps: {e}"))
225            })?,
226            None => false, // Default: block private IPs
227        };
228
229        // Parse comma-separated blocked hosts
230        let blocked_hosts = parts
231            .params
232            .get("blockedHosts")
233            .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
234            .unwrap_or_default();
235
236        let max_body_size = match parts.params.get("maxBodySize") {
237            Some(v) => v.parse::<usize>().map_err(|e| {
238                CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
239            })?,
240            None => 10 * 1024 * 1024, // Default: 10MB
241        };
242
243        let read_timeout_ms = match parts.params.get("readTimeout") {
244            Some(v) => v.parse::<u64>().map_err(|e| {
245                CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
246            })?,
247            None => 30_000, // Default: 30s
248        };
249
250        let max_response_bytes = match parts.params.get("maxResponseBytes") {
251            Some(v) => v.parse::<usize>().map_err(|e| {
252                CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
253            })?,
254            None => 10 * 1024 * 1024, // Default: 10MB
255        };
256
257        let auth = parse_auth_from_params(&parts.params)?;
258
259        let user_agent = parts.params.get("userAgent").cloned();
260
261        let cookie_handling = match parts.params.get("cookieHandling") {
262            Some(v) if v.eq_ignore_ascii_case("inmemory") => CookieHandling::InMemory,
263            Some(v) if v.eq_ignore_ascii_case("disabled") => CookieHandling::Disabled,
264            Some(v) => {
265                return Err(CamelError::InvalidUri(format!(
266                    "invalid value for cookieHandling: {v} (expected Disabled or InMemory)"
267                )));
268            }
269            None => CookieHandling::Disabled,
270        };
271
272        let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
273            Some(v) => parse_bool_param_http(v).map_err(|e| {
274                CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
275            })?,
276            None => false,
277        };
278
279        let connection_close = match parts.params.get("connectionClose") {
280            Some(v) => parse_bool_param_http(v).map_err(|e| {
281                CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
282            })?,
283            None => false,
284        };
285
286        let skip_request_headers = parts
287            .params
288            .get("skipRequestHeaders")
289            .map(|v| {
290                v.split(',')
291                    .map(str::trim)
292                    .filter(|s| !s.is_empty())
293                    .map(|s| s.to_ascii_lowercase())
294                    .collect::<Vec<_>>()
295            })
296            .unwrap_or_default();
297
298        let skip_response_headers = parts
299            .params
300            .get("skipResponseHeaders")
301            .map(|v| {
302                v.split(',')
303                    .map(str::trim)
304                    .filter(|s| !s.is_empty())
305                    .map(|s| s.to_ascii_lowercase())
306                    .collect::<Vec<_>>()
307            })
308            .unwrap_or_default();
309
310        // Collect remaining params (not Camel options) as query params
311        let query_params: HashMap<String, String> = parts
312            .params
313            .into_iter()
314            .filter(|(k, _)| !HTTP_CAMEL_OPTIONS.contains(&k.as_str()))
315            .collect();
316
317        Ok(Self {
318            base_url,
319            http_method,
320            throw_exception_on_failure,
321            ok_status_code_range,
322            response_timeout,
323            query_params,
324            allow_private_ips,
325            blocked_hosts,
326            max_body_size,
327            read_timeout_ms,
328            max_response_bytes,
329            auth,
330            token_provider: None,
331            user_agent,
332            cookie_handling,
333            bridge_endpoint,
334            connection_close,
335            skip_request_headers,
336            skip_response_headers,
337        })
338    }
339}
340
341fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
342    let Some(method) = params.get("authMethod") else {
343        return Ok(HttpAuth::None);
344    };
345
346    if method.eq_ignore_ascii_case("none") {
347        return Ok(HttpAuth::None);
348    }
349
350    if method.eq_ignore_ascii_case("basic") {
351        let username = params.get("authUsername").cloned().ok_or_else(|| {
352            CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
353        })?;
354        let password = params.get("authPassword").cloned().ok_or_else(|| {
355            CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
356        })?;
357        return Ok(HttpAuth::Basic { username, password });
358    }
359
360    if method.eq_ignore_ascii_case("bearer") {
361        let token = params.get("authBearerToken").cloned().ok_or_else(|| {
362            CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
363        })?;
364        return Ok(HttpAuth::Bearer { token });
365    }
366
367    Err(CamelError::InvalidUri(format!(
368        "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
369    )))
370}
371
372fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
373    match value.to_ascii_lowercase().as_str() {
374        "true" | "1" | "yes" => Ok(true),
375        "false" | "0" | "no" => Ok(false),
376        _ => Err(CamelError::InvalidUri(format!(
377            "invalid boolean value: '{value}'"
378        ))),
379    }
380}
381
382impl HttpEndpointConfig {
383    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
384        let parts = parse_uri(uri)?;
385        let mut endpoint = Self::from_components(parts.clone())?;
386        if endpoint.response_timeout.is_none() {
387            endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
388        }
389        if !parts.params.contains_key("allowPrivateIps") {
390            endpoint.allow_private_ips = config.allow_private_ips;
391        }
392        if !parts.params.contains_key("blockedHosts") {
393            endpoint.blocked_hosts = config.blocked_hosts.clone();
394        }
395        if !parts.params.contains_key("maxBodySize") {
396            endpoint.max_body_size = config.max_body_size;
397        }
398        if !parts.params.contains_key("readTimeout") {
399            endpoint.read_timeout_ms = config.read_timeout_ms;
400        }
401        if !parts.params.contains_key("maxResponseBytes") {
402            endpoint.max_response_bytes = config.max_response_bytes;
403        }
404        if !parts.params.contains_key("okStatusCodeRange")
405            && let Some(range) = &config.ok_status_code_range
406        {
407            endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
408        }
409
410        Ok(endpoint)
411    }
412}
413
414// ---------------------------------------------------------------------------
415// HttpServerConfig
416// ---------------------------------------------------------------------------
417
418/// Configuration for an HTTP server (consumer) endpoint.
419#[derive(Debug, Clone)]
420pub struct HttpServerConfig {
421    /// Bind address, e.g. "0.0.0.0" or "127.0.0.1".
422    pub host: String,
423    /// TCP port to listen on.
424    pub port: u16,
425    /// URL path this consumer handles, e.g. "/orders".
426    pub path: String,
427    /// Maximum request body size in bytes.
428    pub max_request_body: usize,
429    /// Maximum response body size for materializing streams in bytes.
430    pub max_response_body: usize,
431    /// Maximum number of in-flight requests handled concurrently by this server.
432    pub max_inflight_requests: usize,
433}
434
435impl UriConfig for HttpServerConfig {
436    /// Returns "http" as the primary scheme (also accepts "https")
437    fn scheme() -> &'static str {
438        "http"
439    }
440
441    fn from_uri(uri: &str) -> Result<Self, CamelError> {
442        let parts = parse_uri(uri)?;
443        Self::from_components(parts)
444    }
445
446    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
447        // Validate scheme - accept both http and https
448        if parts.scheme != "http" && parts.scheme != "https" {
449            return Err(CamelError::InvalidUri(format!(
450                "expected scheme 'http' or 'https', got '{}'",
451                parts.scheme
452            )));
453        }
454
455        // parts.path is everything after the scheme colon, e.g. "//0.0.0.0:8080/orders"
456        // Strip leading "//"
457        let authority_and_path = parts.path.trim_start_matches('/');
458
459        // Split on the first "/" to separate "host:port" from "/path"
460        let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
461            (&authority_and_path[..idx], &authority_and_path[idx..])
462        } else {
463            (authority_and_path, "/")
464        };
465
466        let path = if path_suffix.is_empty() {
467            "/"
468        } else {
469            path_suffix
470        }
471        .to_string();
472
473        // Parse host:port from authority
474        let (host, port) = if let Some(colon) = authority.rfind(':') {
475            let port_str = &authority[colon + 1..];
476            match port_str.parse::<u16>() {
477                Ok(p) => (authority[..colon].to_string(), p),
478                Err(_) => {
479                    return Err(CamelError::InvalidUri(format!(
480                        "invalid port '{}' in authority",
481                        port_str
482                    )));
483                }
484            }
485        } else {
486            // Default port based on scheme: 443 for https, 80 for http
487            let default_port = if parts.scheme == "https" { 443 } else { 80 };
488            (authority.to_string(), default_port)
489        };
490
491        let max_request_body = parts
492            .params
493            .get("maxRequestBody")
494            .and_then(|v| v.parse::<usize>().ok())
495            .unwrap_or(2 * 1024 * 1024); // Default: 2MB
496
497        let max_response_body = parts
498            .params
499            .get("maxResponseBody")
500            .and_then(|v| v.parse::<usize>().ok())
501            .unwrap_or(10 * 1024 * 1024); // Default: 10MB
502
503        let max_inflight_requests = parts
504            .params
505            .get("maxInflightRequests")
506            .and_then(|v| v.parse::<usize>().ok())
507            .unwrap_or(1024);
508
509        Ok(Self {
510            host,
511            port,
512            path,
513            max_request_body,
514            max_response_body,
515            max_inflight_requests,
516        })
517    }
518}
519
520impl HttpServerConfig {
521    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
522        let parts = parse_uri(uri)?;
523        let mut server = Self::from_components(parts.clone())?;
524        if !parts.params.contains_key("maxRequestBody") {
525            server.max_request_body = config.max_request_body;
526        }
527        if !parts.params.contains_key("maxResponseBody") {
528            // Default max_response_body is 10MB via HttpConfig::default().max_body_size.
529            server.max_response_body = config.max_body_size;
530        }
531        Ok(server)
532    }
533}
534
535// ---------------------------------------------------------------------------
536// RequestEnvelope / HttpReply
537// ---------------------------------------------------------------------------
538
539/// Body of the HTTP response: already-materialized bytes or a lazy stream.
540///
541/// **Internal plumbing** — subject to change without notice.
542pub enum HttpReplyBody {
543    Bytes(bytes::Bytes),
544    Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
545}
546
547/// An inbound HTTP request sent from the Axum dispatch handler to an
548/// `HttpConsumer` receive loop.
549///
550/// **Internal plumbing** — subject to change without notice.
551pub struct RequestEnvelope {
552    pub method: String,
553    pub path: String,
554    pub query: String,
555    pub headers: http::HeaderMap,
556    pub body: StreamBody,
557    pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
558}
559
560/// The HTTP response that `HttpConsumer` sends back to the Axum handler.
561///
562/// **Internal plumbing** — subject to change without notice.
563pub struct HttpReply {
564    pub status: u16,
565    pub headers: Vec<(String, String)>,
566    pub body: HttpReplyBody,
567}
568
569// ---------------------------------------------------------------------------
570// HttpRouteRegistry / ServerRegistry
571// ---------------------------------------------------------------------------
572
573type ServerKey = (String, u16);
574
575/// Handle to a running Axum server on one interface/port.
576struct ServerHandle {
577    registry: HttpRouteRegistry,
578    max_request_body: usize,
579    max_response_body: usize,
580    max_inflight_requests: usize,
581}
582
583/// Process-global registry mapping (host, port) → running Axum server handle.
584pub struct ServerRegistry {
585    inner: Mutex<HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>>,
586}
587
588impl ServerRegistry {
589    /// Returns the global singleton.
590    pub fn global() -> &'static Self {
591        static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
592        INSTANCE.get_or_init(|| ServerRegistry {
593            inner: Mutex::new(HashMap::new()),
594        })
595    }
596
597    /// Returns route registry for `port`, spawning new Axum server if
598    /// none is running on that port yet.
599    #[allow(clippy::too_many_arguments)]
600    pub async fn get_or_spawn(
601        &'static self,
602        host: &str,
603        port: u16,
604        max_request_body: usize,
605        max_response_body: usize,
606        max_inflight_requests: usize,
607        runtime: Arc<dyn RuntimeObservability>,
608        route_id: String,
609    ) -> Result<HttpRouteRegistry, CamelError> {
610        let host_owned = host.to_string();
611
612        let cell = {
613            let mut guard = self.inner.lock().map_err(|_| {
614                CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
615            })?;
616            let key = (host.to_string(), port);
617            guard
618                .entry(key)
619                .or_insert_with(|| Arc::new(OnceCell::new()))
620                .clone()
621        };
622
623        if let Some(existing) = cell.get()
624            && existing.max_request_body != max_request_body
625        {
626            return Err(CamelError::EndpointCreationFailed(format!(
627                "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
628                existing.max_request_body, max_request_body
629            )));
630        }
631
632        if let Some(existing) = cell.get()
633            && existing.max_response_body != max_response_body
634        {
635            return Err(CamelError::EndpointCreationFailed(format!(
636                "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
637                existing.max_response_body, max_response_body
638            )));
639        }
640
641        if let Some(existing) = cell.get()
642            && existing.max_inflight_requests != max_inflight_requests
643        {
644            return Err(CamelError::EndpointCreationFailed(format!(
645                "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
646                existing.max_inflight_requests, max_inflight_requests
647            )));
648        }
649
650        let handle = cell
651            .get_or_try_init(|| {
652                let rt = Arc::clone(&runtime);
653                let rid = route_id.clone();
654                async move {
655                    let addr = format!("{host_owned}:{port}");
656                    let listener = tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
657                        CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
658                    })?;
659                    let registry = HttpRouteRegistry::new();
660                    let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
661                    let task = tokio::spawn(run_axum_server(
662                        listener,
663                        registry.clone(),
664                        max_request_body,
665                        max_response_body,
666                        Arc::clone(&inflight),
667                        Arc::clone(&rt),
668                        rid.clone(),
669                    ));
670                    let addr_for_monitor = format!("{host_owned}:{port}");
671                    tokio::spawn(monitor_axum_task(
672                        task,
673                        addr_for_monitor,
674                        Arc::clone(&rt),
675                        rid,
676                    ));
677                    Ok::<ServerHandle, CamelError>(ServerHandle {
678                        registry,
679                        max_request_body,
680                        max_response_body,
681                        max_inflight_requests,
682                    })
683                }
684            })
685            .await?;
686
687        Ok(handle.registry.clone())
688    }
689
690    /// Reset the global registry — **test-only**.
691    ///
692    /// Clears all registered server handles so that tests can start from a clean
693    /// state. This is intentionally `#[cfg(test)]` because the registry is a
694    /// process-global singleton in production and resetting it would break
695    /// running servers.
696    #[cfg(test)]
697    pub fn reset() {
698        let instance = Self::global();
699        let mut guard = instance
700            .inner
701            .lock()
702            .expect("ServerRegistry lock poisoned during test reset");
703        guard.clear();
704    }
705}
706
707// ---------------------------------------------------------------------------
708// Axum server
709// ---------------------------------------------------------------------------
710
711use axum::{
712    Router,
713    body::Body as AxumBody,
714    extract::{Request, State},
715    http::{Response, StatusCode},
716    response::IntoResponse,
717};
718
719#[derive(Clone)]
720pub(crate) struct AppState {
721    registry: HttpRouteRegistry,
722    max_request_body: usize,
723    max_response_body: usize,
724    inflight: Arc<tokio::sync::Semaphore>,
725}
726
727async fn run_axum_server(
728    listener: tokio::net::TcpListener,
729    registry: HttpRouteRegistry,
730    max_request_body: usize,
731    max_response_body: usize,
732    inflight: Arc<tokio::sync::Semaphore>,
733    runtime: Arc<dyn RuntimeObservability>,
734    route_id: String,
735) {
736    let state = AppState {
737        registry,
738        max_request_body,
739        max_response_body,
740        inflight,
741    };
742    let app = Router::new().fallback(dispatch_handler).with_state(state);
743
744    axum::serve(listener, app).await.unwrap_or_else(|e| {
745        runtime
746            .metrics()
747            .increment_errors(&route_id, "e:http:accept");
748        // log-policy: outside-contract
749        tracing::error!(error = %e, "Axum server error");
750    });
751}
752
753/// Monitors an Axum server task and emits a structured error event if it
754/// exits unexpectedly.
755///
756/// # Limitations
757/// The HTTP server is shared across all routes on a port. Full per-route
758/// CrashNotification propagation is deferred — this provides observable
759/// structured logging as a first guard.
760async fn monitor_axum_task(
761    handle: tokio::task::JoinHandle<()>,
762    addr: String,
763    runtime: Arc<dyn RuntimeObservability>,
764    route_id: String,
765) {
766    match handle.await {
767        Ok(()) => {
768            // Clean exit (process shutdown or normal stop)
769        }
770        Err(join_err) => {
771            runtime
772                .metrics()
773                .increment_errors(&route_id, "e:http:server-task-exited");
774            // log-policy: outside-contract
775            tracing::error!(
776                addr = %addr,
777                error = %join_err,
778                "Axum server task exited unexpectedly — all routes on this port are now dead"
779            );
780        }
781    }
782}
783
784async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
785    let path = req.uri().path().to_owned();
786
787    // 1. API match — lookup by path, only consume body if matched
788    let api_sender = {
789        let inner = state.registry.inner.read().await;
790        inner.api_routes.get(&path).cloned()
791    }; // lock released BEFORE any IO
792
793    if let Some(sender) = api_sender {
794        let method = req.method().to_string();
795        let query = req.uri().query().unwrap_or("").to_string();
796        let headers = req.headers().clone();
797
798        // Check Content-Length against limit BEFORE opening the stream
799        let content_length: Option<u64> = headers
800            .get(http::header::CONTENT_LENGTH)
801            .and_then(|v| v.to_str().ok())
802            .and_then(|s| s.parse().ok());
803
804        if let Some(len) = content_length
805            && len > state.max_request_body as u64
806        {
807            return Response::builder()
808                .status(StatusCode::PAYLOAD_TOO_LARGE)
809                .body(AxumBody::from("Request body exceeds configured limit"))
810                .expect("infallible"); // allow-unwrap
811        }
812
813        let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
814            Ok(permit) => permit,
815            Err(_) => {
816                return Response::builder()
817                    .status(StatusCode::SERVICE_UNAVAILABLE)
818                    .body(AxumBody::from("Service Unavailable"))
819                    .expect("infallible"); // allow-unwrap
820            }
821        };
822
823        // Build StreamBody from Axum body WITHOUT materializing
824        let content_type = headers
825            .get(http::header::CONTENT_TYPE)
826            .and_then(|v| v.to_str().ok())
827            .map(|s| s.to_string());
828
829        let data_stream: BodyDataStream = req.into_body().into_data_stream();
830        let mapped_stream = data_stream.map_err(|e| CamelError::Io(e.to_string()));
831        let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(mapped_stream);
832
833        let stream_body = StreamBody {
834            stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
835            metadata: StreamMetadata {
836                size_hint: content_length,
837                content_type,
838                origin: None,
839            },
840        };
841
842        let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
843        let envelope = RequestEnvelope {
844            method,
845            path,
846            query,
847            headers,
848            body: stream_body,
849            reply_tx,
850        };
851
852        if sender.send(envelope).await.is_err() {
853            return Response::builder()
854                .status(StatusCode::SERVICE_UNAVAILABLE)
855                .body(AxumBody::from("Consumer unavailable"))
856                .expect("infallible"); // allow-unwrap
857        }
858
859        match reply_rx.await {
860            Ok(reply) => {
861                let reply = match reply.body {
862                    HttpReplyBody::Bytes(b)
863                        if exceeds_max_response_body(b.len(), state.max_response_body) =>
864                    {
865                        HttpReply {
866                            status: 500,
867                            headers: vec![],
868                            body: HttpReplyBody::Bytes(bytes::Bytes::from(
869                                "Response body exceeds configured limit",
870                            )),
871                        }
872                    }
873                    _ => reply,
874                };
875
876                let status =
877                    StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
878                let mut builder = Response::builder().status(status);
879                for (k, v) in &reply.headers {
880                    builder = builder.header(k.as_str(), v.as_str());
881                }
882                match reply.body {
883                    HttpReplyBody::Bytes(b) => {
884                        builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
885                            Response::builder()
886                                .status(StatusCode::INTERNAL_SERVER_ERROR)
887                                .body(AxumBody::from("Invalid response headers from consumer"))
888                                .expect("infallible") // allow-unwrap
889                        })
890                    }
891                    HttpReplyBody::Stream(stream) => builder
892                        .body(AxumBody::from_stream(stream))
893                        .unwrap_or_else(|_| {
894                            Response::builder()
895                                .status(StatusCode::INTERNAL_SERVER_ERROR)
896                                .body(AxumBody::from("Invalid response headers from consumer"))
897                                .expect("infallible") // allow-unwrap
898                        }),
899                }
900            }
901            Err(_) => Response::builder()
902                .status(StatusCode::INTERNAL_SERVER_ERROR)
903                .body(AxumBody::from("Pipeline error"))
904                .expect("infallible"), // allow-unwrap
905        }
906    } else {
907        // No API route matched — try static mounts
908        static_dispatch::dispatch_static(&state, req, &path).await
909    }
910}
911
912fn exceeds_max_response_body(len: usize, max: usize) -> bool {
913    len > max
914}
915
916fn title_case_header(name: &str) -> String {
917    name.split('-')
918        .map(|part| {
919            let mut chars = part.chars();
920            match chars.next() {
921                None => String::new(),
922                Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
923            }
924        })
925        .collect::<Vec<_>>()
926        .join("-")
927}
928
929// ---------------------------------------------------------------------------
930// HttpConsumer
931// ---------------------------------------------------------------------------
932
933pub struct HttpConsumer {
934    config: HttpServerConfig,
935    /// Runtime observability handle for ADR-0012 metrics and health calls.
936    runtime: Arc<dyn RuntimeObservability>,
937}
938
939impl HttpConsumer {
940    pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
941        Self { config, runtime }
942    }
943}
944
945#[async_trait::async_trait]
946impl Consumer for HttpConsumer {
947    async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
948        use camel_component_api::{Body, Exchange, Message};
949
950        let registry = ServerRegistry::global()
951            .get_or_spawn(
952                &self.config.host,
953                self.config.port,
954                self.config.max_request_body,
955                self.config.max_response_body,
956                self.config.max_inflight_requests,
957                self.runtime.clone(),
958                ctx.route_id().to_string(),
959            )
960            .await?;
961
962        // Create channel for this path and register it
963        let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(64);
964        registry
965            .register_api_route(self.config.path.clone(), env_tx)
966            .await;
967
968        let path = self.config.path.clone();
969        let registry_for_cleanup = registry.clone();
970        let cancel_token = ctx.cancel_token();
971        loop {
972            tokio::select! {
973                _ = ctx.cancelled() => {
974                    break;
975                }
976                envelope = env_rx.recv() => {
977                    let Some(envelope) = envelope else { break; };
978
979                    // Build Exchange from HTTP request
980                    let mut msg = Message::default();
981
982                    // Set standard Camel HTTP headers
983                    msg.set_header("CamelHttpMethod",
984                        serde_json::Value::String(envelope.method.clone()));
985                    msg.set_header("CamelHttpPath",
986                        serde_json::Value::String(envelope.path.clone()));
987                    msg.set_header("CamelHttpQuery",
988                        serde_json::Value::String(envelope.query.clone()));
989
990                    // Forward HTTP headers with Title-Case names (hyper lowercases them)
991                    for (k, v) in &envelope.headers {
992                        if let Ok(val_str) = v.to_str() {
993                            msg.set_header(
994                                title_case_header(k.as_str()),
995                                serde_json::Value::String(val_str.to_string()),
996                            );
997                        }
998                    }
999
1000                    // Body: always arrives as Body::Stream (native streaming)
1001                    // Routes can call into_bytes() if they need to materialize
1002                    msg.body = Body::Stream(envelope.body);
1003
1004                    #[allow(unused_mut)]
1005                    let mut exchange = Exchange::new(msg);
1006
1007                    // Extract W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
1008                    #[cfg(feature = "otel")]
1009                    {
1010                        let headers: HashMap<String, String> = envelope
1011                            .headers
1012                            .iter()
1013                            .filter_map(|(k, v)| {
1014                                Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1015                            })
1016                            .collect();
1017                        camel_otel::extract_into_exchange(&mut exchange, &headers);
1018                    }
1019
1020                    let reply_tx = envelope.reply_tx;
1021                    let sender = ctx.sender().clone();
1022                    let path_clone = path.clone();
1023                    let cancel = cancel_token.clone();
1024
1025                    // Spawn a task to handle this request concurrently
1026                    //
1027                    // NOTE: This spawns a separate tokio task for each incoming HTTP request to enable
1028                    // true concurrent request processing. This change was introduced as part of the
1029                    // pipeline concurrency feature and was NOT part of the original HttpConsumer design.
1030                    //
1031                    // Rationale:
1032                    // 1. Without spawning per-request tasks, the send_and_wait() operation would block
1033                    //    the consumer's main loop until the pipeline processing completes
1034                    // 2. This blocking would prevent multiple HTTP requests from being processed
1035                    //    concurrently, even when ConcurrencyModel::Concurrent is enabled on the pipeline
1036                    // 3. The channel would never have multiple exchanges buffered simultaneously,
1037                    //    defeating the purpose of pipeline-side concurrency
1038                    // 4. By spawning a task per request, we allow the consumer loop to continue
1039                    //    accepting new requests while existing ones are processed in the pipeline
1040                    //
1041                    // This approach effectively decouples request acceptance from pipeline processing,
1042                    // allowing the channel to buffer multiple exchanges that can be processed concurrently
1043                    // by the pipeline when ConcurrencyModel::Concurrent is active.
1044                    tokio::spawn(async move {
1045                        // Check for cancellation before sending to pipeline.
1046                        // Returns 503 (Service Unavailable) instead of letting the request
1047                        // enter a shutting-down pipeline. This is a behavioral change from
1048                        // the pre-concurrency implementation where cancellation during
1049                        // processing would result in a 500 (Internal Server Error).
1050                        // 503 is more semantically correct: the server is temporarily
1051                        // unable to handle the request due to shutdown.
1052                        if cancel.is_cancelled() {
1053                            let _ = reply_tx.send(HttpReply {
1054                                status: 503,
1055                                headers: vec![],
1056                                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
1057                            });
1058                            return;
1059                        }
1060
1061                        // Send through pipeline and await result
1062                        let (tx, rx) = tokio::sync::oneshot::channel();
1063                        let envelope = camel_component_api::consumer::ExchangeEnvelope {
1064                            exchange,
1065                            reply_tx: Some(tx),
1066                        };
1067
1068                        let result = match sender.send(envelope).await {
1069                            Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
1070                            Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
1071                        }
1072                        .and_then(|r| r);
1073
1074                        let reply = match result {
1075                            Ok(out) => {
1076                                let status = out
1077                                    .input
1078                                    .header("CamelHttpResponseCode")
1079                                    .and_then(|v| {
1080                                        let raw = v.as_u64()
1081                                            .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
1082                                        let code = raw as u16;
1083                                        (100..1000).contains(&code).then_some(code)
1084                                    })
1085                                    .unwrap_or(200);
1086
1087                                let user_content_type = out
1088                                    .input
1089                                    .header("Content-Type")
1090                                    .and_then(|v| v.as_str().map(|s| s.to_string()));
1091
1092                                let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
1093                                    Body::Empty => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
1094                                    Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
1095                                    Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
1096                                    Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
1097                                    Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
1098                                        v.to_string().into_bytes(),
1099                                    )), Some("application/json".to_string())),
1100                                    Body::Stream(s) => {
1101                                        let ct = s.metadata.content_type.clone();
1102                                        match s.stream.lock().await.take() {
1103                                            Some(stream) => (
1104                                                HttpReplyBody::Stream(stream),
1105                                                ct,
1106                                            ),
1107                                            None => {
1108                                                // log-policy: system-broken
1109                                                tracing::error!(
1110                                                    "Body::Stream already consumed before HTTP reply — returning 500"
1111                                                );
1112                                                let error_reply = HttpReply {
1113                                                    status: 500,
1114                                                    headers: vec![],
1115                                                    body: HttpReplyBody::Bytes(bytes::Bytes::new()),
1116                                                };
1117                                                if reply_tx.send(error_reply).is_err() {
1118                                                    debug!("reply_tx dropped before error reply could be sent");
1119                                                }
1120                                                return;
1121                                            }
1122                                        }
1123                                    }
1124                                };
1125
1126                                let mut resp_headers: Vec<(String, String)> = out
1127                                    .input
1128                                    .headers
1129                                    .iter()
1130                                    .filter(|(k, _)| !k.starts_with("Camel"))
1131                                    .filter(|(k, _)| {
1132                                        !matches!(
1133                                            k.to_lowercase().as_str(),
1134                                            "content-length"
1135                                            | "content-type"
1136                                            | "transfer-encoding"
1137                                            | "connection"
1138                                            | "cache-control"
1139                                            | "date"
1140                                            | "pragma"
1141                                            | "trailer"
1142                                            | "upgrade"
1143                                            | "via"
1144                                            | "warning"
1145                                            | "host"
1146                                            | "user-agent"
1147                                            | "accept"
1148                                            | "accept-encoding"
1149                                            | "accept-language"
1150                                            | "accept-charset"
1151                                            | "authorization"
1152                                            | "proxy-authorization"
1153                                            | "cookie"
1154                                            | "expect"
1155                                            | "from"
1156                                            | "if-match"
1157                                            | "if-modified-since"
1158                                            | "if-none-match"
1159                                            | "if-range"
1160                                            | "if-unmodified-since"
1161                                            | "max-forwards"
1162                                            | "proxy-connection"
1163                                            | "range"
1164                                            | "referer"
1165                                            | "te"
1166                                        )
1167                                    })
1168                                    .filter_map(|(k, v)| {
1169                                        v.as_str().map(|s| (k.clone(), s.to_string()))
1170                                    })
1171                                    .collect();
1172
1173                                let content_type = user_content_type
1174                                    .or(inferred_content_type);
1175                                if let Some(ct) = content_type {
1176                                    resp_headers.push(("Content-Type".to_string(), ct));
1177                                }
1178
1179                                HttpReply {
1180                                    status,
1181                                    headers: resp_headers,
1182                                    body: reply_body,
1183                                }
1184                            }
1185                            Err(CamelError::Unauthenticated(msg)) => {
1186                                tracing::warn!(error = %msg, path = %path_clone, "Authentication failed");
1187                                HttpReply {
1188                                    status: 401,
1189                                    headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
1190                                    body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
1191                                }
1192                            }
1193                            Err(CamelError::Unauthorized(msg)) => {
1194                                tracing::warn!(error = %msg, path = %path_clone, "Authorization failed");
1195                                HttpReply {
1196                                    status: 403,
1197                                    headers: vec![],
1198                                    body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
1199                                }
1200                            }
1201                            Err(e) => {
1202                                // log-policy: handler-owned
1203                                tracing::warn!(error = %e, path = %path_clone, "Pipeline error processing HTTP request");
1204                                HttpReply {
1205                                    status: 500,
1206                                    headers: vec![],
1207                                    body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
1208                                }
1209                            }
1210                        };
1211
1212                        // Reply to Axum handler (ignore error if client disconnected)
1213                        let _ = reply_tx.send(reply);
1214                    });
1215                }
1216            }
1217        }
1218
1219        // Deregister this path
1220        registry_for_cleanup.unregister_api_route(&path).await;
1221
1222        Ok(())
1223    }
1224
1225    async fn stop(&mut self) -> Result<(), CamelError> {
1226        Ok(())
1227    }
1228
1229    fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
1230        camel_component_api::ConcurrencyModel::Concurrent { max: None }
1231    }
1232}
1233
1234// ---------------------------------------------------------------------------
1235// HttpComponent / HttpsComponent
1236// ---------------------------------------------------------------------------
1237
1238pub struct HttpComponent {
1239    config: HttpConfig,
1240}
1241
1242fn build_client(config: &HttpConfig, cookie_handling: CookieHandling) -> reqwest::Client {
1243    let mut builder = reqwest::Client::builder()
1244        .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
1245        .pool_max_idle_per_host(config.pool_max_idle_per_host)
1246        .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
1247
1248    if !config.follow_redirects {
1249        builder = builder.redirect(reqwest::redirect::Policy::none());
1250    } else if let Some(max_redirects) = config.max_redirects {
1251        builder = builder.redirect(reqwest::redirect::Policy::limited(max_redirects));
1252    }
1253
1254    if let Some(proxy_url) = &config.proxy_url
1255        && let Ok(proxy) = reqwest::Proxy::all(proxy_url)
1256    {
1257        builder = builder.proxy(proxy);
1258    }
1259
1260    if matches!(cookie_handling, CookieHandling::InMemory) {
1261        // TODO(HTTP-013): enable reqwest cookie jar once workspace reqwest features include cookie_store.
1262    }
1263
1264    if let Some(tls) = &config.tls
1265        && tls.enabled
1266    {
1267        if tls.insecure || !tls.verify_peer {
1268            builder = builder.danger_accept_invalid_certs(true);
1269        }
1270
1271        if let Some(ca_path) = &tls.ca_cert_path
1272            && let Ok(ca_bytes) = std::fs::read(ca_path)
1273        {
1274            let cert = reqwest::Certificate::from_pem(&ca_bytes)
1275                .or_else(|_| reqwest::Certificate::from_der(&ca_bytes));
1276            if let Ok(ca_cert) = cert {
1277                builder = builder.add_root_certificate(ca_cert);
1278            }
1279        }
1280
1281        if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path)
1282            && let (Ok(cert_bytes), Ok(key_bytes)) =
1283                (std::fs::read(cert_path), std::fs::read(key_path))
1284        {
1285            let mut identity_pem = cert_bytes;
1286            identity_pem.extend_from_slice(&key_bytes);
1287            if let Ok(identity) = reqwest::Identity::from_pem(&identity_pem) {
1288                builder = builder.identity(identity);
1289            }
1290        }
1291    }
1292
1293    builder
1294        .build()
1295        .expect("reqwest::Client::build() with valid config should not fail") // allow-unwrap
1296}
1297
1298impl HttpComponent {
1299    pub fn new() -> Self {
1300        let config = HttpConfig::default();
1301        Self { config }
1302    }
1303
1304    pub fn with_config(config: HttpConfig) -> Self {
1305        Self { config }
1306    }
1307
1308    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1309        match config {
1310            Some(cfg) => Self::with_config(cfg),
1311            None => Self::new(),
1312        }
1313    }
1314}
1315
1316impl Default for HttpComponent {
1317    fn default() -> Self {
1318        Self::new()
1319    }
1320}
1321
1322impl Component for HttpComponent {
1323    fn scheme(&self) -> &str {
1324        "http"
1325    }
1326
1327    fn create_endpoint(
1328        &self,
1329        uri: &str,
1330        ctx: &dyn camel_component_api::ComponentContext,
1331    ) -> Result<Box<dyn Endpoint>, CamelError> {
1332        self.config.validate()?;
1333        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
1334        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
1335        let client = build_client(&self.config, config.cookie_handling);
1336        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
1337            server_config.host.clone(),
1338            server_config.port,
1339        )));
1340        Ok(Box::new(HttpEndpoint {
1341            uri: uri.to_string(),
1342            config,
1343            server_config,
1344            client,
1345        }))
1346    }
1347}
1348
1349pub struct HttpsComponent {
1350    config: HttpConfig,
1351}
1352
1353impl HttpsComponent {
1354    pub fn new() -> Self {
1355        let config = HttpConfig::default();
1356        Self { config }
1357    }
1358
1359    pub fn with_config(config: HttpConfig) -> Self {
1360        Self { config }
1361    }
1362
1363    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1364        match config {
1365            Some(cfg) => Self::with_config(cfg),
1366            None => Self::new(),
1367        }
1368    }
1369}
1370
1371impl Default for HttpsComponent {
1372    fn default() -> Self {
1373        Self::new()
1374    }
1375}
1376
1377impl Component for HttpsComponent {
1378    fn scheme(&self) -> &str {
1379        "https"
1380    }
1381
1382    fn create_endpoint(
1383        &self,
1384        uri: &str,
1385        ctx: &dyn camel_component_api::ComponentContext,
1386    ) -> Result<Box<dyn Endpoint>, CamelError> {
1387        self.config.validate()?;
1388        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
1389        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
1390        let client = build_client(&self.config, config.cookie_handling);
1391        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
1392            server_config.host.clone(),
1393            server_config.port,
1394        )));
1395        Ok(Box::new(HttpEndpoint {
1396            uri: uri.to_string(),
1397            config,
1398            server_config,
1399            client,
1400        }))
1401    }
1402}
1403
1404// ---------------------------------------------------------------------------
1405// HttpEndpoint
1406// ---------------------------------------------------------------------------
1407
1408struct HttpEndpoint {
1409    uri: String,
1410    config: HttpEndpointConfig,
1411    server_config: HttpServerConfig,
1412    client: reqwest::Client,
1413}
1414
1415impl Endpoint for HttpEndpoint {
1416    fn uri(&self) -> &str {
1417        &self.uri
1418    }
1419
1420    fn create_consumer(
1421        &self,
1422        rt: Arc<dyn camel_component_api::RuntimeObservability>,
1423    ) -> Result<Box<dyn Consumer>, CamelError> {
1424        Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
1425    }
1426
1427    fn create_producer(
1428        &self,
1429        _rt: Arc<dyn camel_component_api::RuntimeObservability>,
1430        _ctx: &ProducerContext,
1431    ) -> Result<BoxProcessor, CamelError> {
1432        let producer = HttpProducer {
1433            config: Arc::new(self.config.clone()),
1434            client: self.client.clone(),
1435        };
1436        if let Some(ref provider) = self.config.token_provider {
1437            let layer = BearerTokenLayer::new(Arc::clone(provider));
1438            Ok(BoxProcessor::new(layer.layer(producer)))
1439        } else {
1440            Ok(BoxProcessor::new(producer))
1441        }
1442    }
1443}
1444
1445// ---------------------------------------------------------------------------
1446// SSRF Protection
1447// ---------------------------------------------------------------------------
1448
1449fn validate_url_for_ssrf(url: &str, config: &HttpEndpointConfig) -> Result<(), CamelError> {
1450    let parsed = url::Url::parse(url)
1451        .map_err(|e| CamelError::ProcessorError(format!("Invalid URL: {}", e)))?;
1452
1453    // Check blocked hosts
1454    if let Some(host) = parsed.host_str()
1455        && config.blocked_hosts.iter().any(|blocked| host == blocked)
1456    {
1457        return Err(CamelError::ProcessorError(format!(
1458            "Host '{}' is blocked",
1459            host
1460        )));
1461    }
1462
1463    // Check private IPs if not allowed
1464    if !config.allow_private_ips
1465        && let Some(host) = parsed.host()
1466    {
1467        match host {
1468            url::Host::Ipv4(ip) => {
1469                if ip.is_private() || ip.is_loopback() || ip.is_link_local() {
1470                    return Err(CamelError::ProcessorError(format!(
1471                        "Private IP '{}' not allowed (set allowPrivateIps=true to override)",
1472                        ip
1473                    )));
1474                }
1475            }
1476            url::Host::Ipv6(ip) => {
1477                if ip.is_loopback() {
1478                    return Err(CamelError::ProcessorError(format!(
1479                        "Loopback IP '{}' not allowed",
1480                        ip
1481                    )));
1482                }
1483            }
1484            url::Host::Domain(domain) => {
1485                // Block common internal domains
1486                let blocked_domains = ["localhost", "127.0.0.1", "0.0.0.0", "local"];
1487                if blocked_domains.contains(&domain) {
1488                    return Err(CamelError::ProcessorError(format!(
1489                        "Domain '{}' is not allowed",
1490                        domain
1491                    )));
1492                }
1493            }
1494        }
1495    }
1496
1497    Ok(())
1498}
1499
1500fn is_private_ip(ip: &IpAddr) -> bool {
1501    match ip {
1502        IpAddr::V4(ipv4) => {
1503            ipv4.is_private() || ipv4.is_loopback() || ipv4.is_link_local() || ipv4.octets()[0] == 0
1504        }
1505        IpAddr::V6(ipv6) => {
1506            let seg0 = ipv6.segments()[0];
1507            ipv6.is_loopback()
1508                // fc00::/7 (ULA)
1509                || (seg0 & 0xfe00) == 0xfc00
1510                // fe80::/10 (link-local)
1511                || (seg0 & 0xffc0) == 0xfe80
1512                // ::ffff:0:0/96 (IPv4-mapped): only block if the mapped IPv4 is private
1513                || ipv6
1514                    .to_ipv4_mapped()
1515                    .map(|v4| {
1516                        v4.is_private()
1517                            || v4.is_loopback()
1518                            || v4.is_link_local()
1519                            || v4.octets()[0] == 0
1520                    })
1521                    .unwrap_or(false)
1522        }
1523    }
1524}
1525
1526async fn validate_resolved_host_for_ssrf(
1527    url: &str,
1528    config: &HttpEndpointConfig,
1529) -> Result<(), CamelError> {
1530    if config.allow_private_ips {
1531        return Ok(());
1532    }
1533
1534    let parsed = url::Url::parse(url)
1535        .map_err(|e| CamelError::ProcessorError(format!("Invalid URL: {}", e)))?;
1536    let Some(host) = parsed.host_str() else {
1537        return Ok(());
1538    };
1539    let Some(port) = parsed.port_or_known_default() else {
1540        return Ok(());
1541    };
1542
1543    let resolved = tokio::net::lookup_host((host, port)).await.map_err(|e| {
1544        CamelError::ProcessorError(format!("Failed to resolve host '{}': {}", host, e))
1545    })?;
1546
1547    for addr in resolved {
1548        let ip = addr.ip();
1549        if is_private_ip(&ip) {
1550            return Err(CamelError::ProcessorError(format!(
1551                "Target resolved to private IP: {}",
1552                ip
1553            )));
1554        }
1555    }
1556
1557    Ok(())
1558}
1559
1560// ---------------------------------------------------------------------------
1561// HttpProducer
1562// ---------------------------------------------------------------------------
1563
1564#[derive(Clone)]
1565struct HttpProducer {
1566    config: Arc<HttpEndpointConfig>,
1567    client: reqwest::Client,
1568}
1569
1570impl HttpProducer {
1571    fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
1572        if let Some(ref method) = config.http_method {
1573            return method.to_uppercase();
1574        }
1575        if let Some(method) = exchange
1576            .input
1577            .header("CamelHttpMethod")
1578            .and_then(|v| v.as_str())
1579        {
1580            return method.to_uppercase();
1581        }
1582        if !exchange.input.body.is_empty() {
1583            return "POST".to_string();
1584        }
1585        "GET".to_string()
1586    }
1587
1588    fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
1589        if let Some(uri) = exchange
1590            .input
1591            .header("CamelHttpUri")
1592            .and_then(|v| v.as_str())
1593        {
1594            let mut url = uri.to_string();
1595            if let Some(path) = exchange
1596                .input
1597                .header("CamelHttpPath")
1598                .and_then(|v| v.as_str())
1599            {
1600                if !url.ends_with('/') && !path.starts_with('/') {
1601                    url.push('/');
1602                }
1603                url.push_str(path);
1604            }
1605            if let Some(query) = exchange
1606                .input
1607                .header("CamelHttpQuery")
1608                .and_then(|v| v.as_str())
1609            {
1610                url.push('?');
1611                url.push_str(query);
1612            }
1613            return url;
1614        }
1615
1616        let mut url = config.base_url.clone();
1617
1618        if let Some(path) = exchange
1619            .input
1620            .header("CamelHttpPath")
1621            .and_then(|v| v.as_str())
1622        {
1623            if !url.ends_with('/') && !path.starts_with('/') {
1624                url.push('/');
1625            }
1626            url.push_str(path);
1627        }
1628
1629        if let Some(query) = exchange
1630            .input
1631            .header("CamelHttpQuery")
1632            .and_then(|v| v.as_str())
1633        {
1634            url.push('?');
1635            url.push_str(query);
1636        } else if !config.query_params.is_empty() {
1637            let mut parsed = url::Url::parse(&url).expect("base URL must be valid"); // allow-unwrap
1638            for (k, v) in &config.query_params {
1639                parsed.query_pairs_mut().append_pair(k, v);
1640            }
1641            url = parsed.to_string();
1642        }
1643
1644        url
1645    }
1646
1647    fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
1648        status >= range.0 && status <= range.1
1649    }
1650}
1651
1652impl Service<Exchange> for HttpProducer {
1653    type Response = Exchange;
1654    type Error = CamelError;
1655    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
1656
1657    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
1658        Poll::Ready(Ok(()))
1659    }
1660
1661    fn call(&mut self, mut exchange: Exchange) -> Self::Future {
1662        let config = self.config.clone();
1663        let client = self.client.clone();
1664
1665        Box::pin(async move {
1666            let method_str = HttpProducer::resolve_method(&exchange, &config);
1667            let url = HttpProducer::resolve_url(&exchange, &config);
1668
1669            // SECURITY: Validate URL for SSRF
1670            validate_url_for_ssrf(&url, &config)?;
1671            validate_resolved_host_for_ssrf(&url, &config).await?;
1672
1673            debug!(
1674                correlation_id = %exchange.correlation_id(),
1675                method = %method_str,
1676                url = %url,
1677                "HTTP request"
1678            );
1679
1680            let method = method_str.parse::<reqwest::Method>().map_err(|e| {
1681                CamelError::ProcessorError(format!("Invalid HTTP method '{}': {}", method_str, e))
1682            })?;
1683
1684            let mut request = client.request(method, &url);
1685
1686            if let Some(timeout) = config.response_timeout {
1687                request = request.timeout(timeout);
1688            }
1689
1690            if let Some(user_agent) = &config.user_agent
1691                && !config.bridge_endpoint
1692            {
1693                request = request.header("User-Agent", user_agent.clone());
1694            }
1695
1696            // Inject W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
1697            #[cfg(feature = "otel")]
1698            let should_inject_otel = !config.bridge_endpoint;
1699            #[cfg(feature = "otel")]
1700            if should_inject_otel {
1701                let mut otel_headers = HashMap::new();
1702                camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
1703                for (k, v) in otel_headers {
1704                    if let (Ok(name), Ok(val)) = (
1705                        reqwest::header::HeaderName::from_bytes(k.as_bytes()),
1706                        reqwest::header::HeaderValue::from_str(&v),
1707                    ) {
1708                        request = request.header(name, val);
1709                    }
1710                }
1711            }
1712
1713            for (key, value) in &exchange.input.headers {
1714                if !key.starts_with("Camel")
1715                    && !config
1716                        .skip_request_headers
1717                        .iter()
1718                        .any(|h| h.eq_ignore_ascii_case(key))
1719                    && let Some(val_str) = value.as_str()
1720                    && let (Ok(name), Ok(val)) = (
1721                        reqwest::header::HeaderName::from_bytes(key.as_bytes()),
1722                        reqwest::header::HeaderValue::from_str(val_str),
1723                    )
1724                {
1725                    request = request.header(name, val);
1726                }
1727            }
1728
1729            if !config.bridge_endpoint {
1730                match &config.auth {
1731                    HttpAuth::None => {}
1732                    HttpAuth::Basic { username, password } => {
1733                        request = request.basic_auth(username, Some(password));
1734                    }
1735                    HttpAuth::Bearer { token } => {
1736                        request = request.bearer_auth(token);
1737                    }
1738                }
1739
1740                if config.connection_close {
1741                    request = request.header("Connection", "close");
1742                }
1743            }
1744
1745            match exchange.input.body {
1746                Body::Stream(ref s) => {
1747                    let mut stream_lock = s.stream.lock().await;
1748                    if let Some(stream) = stream_lock.take() {
1749                        request = request.body(reqwest::Body::wrap_stream(stream));
1750                    } else {
1751                        return Err(CamelError::AlreadyConsumed);
1752                    }
1753                }
1754                _ => {
1755                    // For other types, materialize with configured limit
1756                    let body = std::mem::take(&mut exchange.input.body);
1757                    let bytes = body.into_bytes(config.max_body_size).await?;
1758                    if !bytes.is_empty() {
1759                        request = request.body(bytes);
1760                    }
1761                }
1762            }
1763
1764            let response = request
1765                .send()
1766                .await
1767                .map_err(|e| CamelError::ProcessorError(format!("HTTP request failed: {e}")))?;
1768
1769            let status_code = response.status().as_u16();
1770            let status_text = response
1771                .status()
1772                .canonical_reason()
1773                .unwrap_or("Unknown")
1774                .to_string();
1775
1776            for (key, value) in response.headers() {
1777                if config
1778                    .skip_response_headers
1779                    .iter()
1780                    .any(|h| h.eq_ignore_ascii_case(key.as_str()))
1781                {
1782                    continue;
1783                }
1784                if let Ok(val_str) = value.to_str() {
1785                    exchange.input.set_header(
1786                        title_case_header(key.as_str()),
1787                        serde_json::Value::String(val_str.to_string()),
1788                    );
1789                }
1790            }
1791
1792            exchange.input.set_header(
1793                "CamelHttpResponseCode",
1794                serde_json::Value::Number(status_code.into()),
1795            );
1796            exchange.input.set_header(
1797                "CamelHttpResponseText",
1798                serde_json::Value::String(status_text.clone()),
1799            );
1800
1801            // Read response body with timeout and size guard (HTTP-004, HTTP-005)
1802            let read_timeout = Duration::from_millis(config.read_timeout_ms);
1803            let response_body = tokio::time::timeout(read_timeout, async {
1804                // Check Content-Length header before allocating
1805                if let Some(content_len) = response.content_length()
1806                    && content_len > config.max_response_bytes as u64
1807                {
1808                    return Err(CamelError::ProcessorError(format!(
1809                        "Response body too large: {} bytes exceeds limit of {} bytes",
1810                        content_len, config.max_response_bytes
1811                    )));
1812                }
1813                // Use bytes_stream() for lazy streaming with size guard
1814                use futures::TryStreamExt;
1815                let mut stream = response.bytes_stream();
1816                let mut total: usize = 0;
1817                let mut collected = Vec::new();
1818                while let Some(chunk) = stream.try_next().await.map_err(|e| {
1819                    CamelError::ProcessorError(format!("Failed to read response body: {e}"))
1820                })? {
1821                    total += chunk.len();
1822                    if total > config.max_response_bytes {
1823                        return Err(CamelError::ProcessorError(format!(
1824                            "Response body too large: {} bytes exceeds limit of {} bytes",
1825                            total, config.max_response_bytes
1826                        )));
1827                    }
1828                    collected.push(chunk);
1829                }
1830                let mut result = bytes::BytesMut::with_capacity(total);
1831                for chunk in collected {
1832                    result.extend_from_slice(&chunk);
1833                }
1834                Ok::<bytes::Bytes, CamelError>(result.freeze())
1835            })
1836            .await
1837            .map_err(|_| {
1838                CamelError::ProcessorError(format!(
1839                    "Read timeout after {}ms",
1840                    config.read_timeout_ms
1841                ))
1842            })??;
1843
1844            if config.throw_exception_on_failure
1845                && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
1846            {
1847                return Err(CamelError::HttpOperationFailed {
1848                    method: method_str,
1849                    url,
1850                    status_code,
1851                    status_text,
1852                    response_body: Some(String::from_utf8_lossy(&response_body).to_string()),
1853                });
1854            }
1855
1856            if !response_body.is_empty() {
1857                exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
1858            }
1859
1860            debug!(
1861                correlation_id = %exchange.correlation_id(),
1862                status = status_code,
1863                url = %url,
1864                "HTTP response"
1865            );
1866            Ok(exchange)
1867        })
1868    }
1869}
1870
1871/// Serializes tests that mutate or depend on the global `ServerRegistry`.
1872///
1873/// `ServerRegistry::global()` is a process-wide singleton that persists
1874/// across tests. `ServerRegistry::reset()` clears ALL entries; if it races
1875/// with another test that has a live server on a fixed port (e.g. 9991),
1876/// the registry entry is removed while the OS socket is still bound, so
1877/// the next `get_or_spawn` call on that port fails with "Address already
1878/// in use". Holding this mutex for the full body of each affected test
1879/// prevents the race without requiring `--test-threads=1`.
1880#[cfg(test)]
1881pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
1882
1883#[cfg(test)]
1884mod tests {
1885    use camel_component_api::test_support::{NoopRuntimeObservability, PanicRuntimeObservability};
1886    fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
1887        std::sync::Arc::new(PanicRuntimeObservability)
1888    }
1889    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
1890        std::sync::Arc::new(PanicRuntimeObservability)
1891    }
1892    fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
1893        std::sync::Arc::new(NoopRuntimeObservability)
1894    }
1895
1896    use super::*;
1897    use camel_component_api::{Message, NoOpComponentContext};
1898    use std::sync::Arc;
1899    use std::time::Duration;
1900
1901    fn test_producer_ctx() -> ProducerContext {
1902        ProducerContext::new()
1903    }
1904
1905    #[test]
1906    fn test_http_config_defaults() {
1907        let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
1908        assert_eq!(config.base_url, "http://localhost:8080/api");
1909        assert!(config.http_method.is_none());
1910        assert!(config.throw_exception_on_failure);
1911        assert_eq!(config.ok_status_code_range, (200, 299));
1912        assert!(config.response_timeout.is_none());
1913        assert!(matches!(config.auth, HttpAuth::None));
1914        assert!(matches!(config.cookie_handling, CookieHandling::Disabled));
1915        assert!(!config.bridge_endpoint);
1916        assert!(!config.connection_close);
1917    }
1918
1919    #[test]
1920    fn test_http_config_scheme() {
1921        // UriConfig trait method returns "http" as primary scheme
1922        assert_eq!(HttpEndpointConfig::scheme(), "http");
1923    }
1924
1925    #[test]
1926    fn test_http_config_from_components() {
1927        // Test from_components directly (trait method)
1928        let components = camel_component_api::UriComponents {
1929            scheme: "https".to_string(),
1930            path: "//api.example.com/v1".to_string(),
1931            params: std::collections::HashMap::from([(
1932                "httpMethod".to_string(),
1933                "POST".to_string(),
1934            )]),
1935        };
1936        let config = HttpEndpointConfig::from_components(components).unwrap();
1937        assert_eq!(config.base_url, "https://api.example.com/v1");
1938        assert_eq!(config.http_method, Some("POST".to_string()));
1939    }
1940
1941    #[test]
1942    fn test_http_config_with_options() {
1943        let config = HttpEndpointConfig::from_uri(
1944            "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
1945        ).unwrap();
1946        assert_eq!(config.base_url, "https://api.example.com/v1");
1947        assert_eq!(config.http_method, Some("PUT".to_string()));
1948        assert!(!config.throw_exception_on_failure);
1949        assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
1950    }
1951
1952    #[test]
1953    fn test_http_endpoint_config_auth_and_headers_options() {
1954        let config = HttpEndpointConfig::from_uri(
1955            "http://localhost/api?authMethod=Basic&authUsername=u&authPassword=p&userAgent=camel-test&bridgeEndpoint=true&connectionClose=true&skipRequestHeaders=Authorization,X-Secret&skipResponseHeaders=Set-Cookie&cookieHandling=InMemory",
1956        )
1957        .unwrap();
1958
1959        assert!(matches!(
1960            config.auth,
1961            HttpAuth::Basic { username, password } if username == "u" && password == "p"
1962        ));
1963        assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
1964        assert!(matches!(config.cookie_handling, CookieHandling::InMemory));
1965        assert!(config.bridge_endpoint);
1966        assert!(config.connection_close);
1967        assert_eq!(
1968            config.skip_request_headers,
1969            vec!["authorization".to_string(), "x-secret".to_string()]
1970        );
1971        assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
1972    }
1973
1974    #[test]
1975    fn test_http_endpoint_config_bearer_auth() {
1976        let config = HttpEndpointConfig::from_uri(
1977            "http://localhost/api?authMethod=Bearer&authBearerToken=t",
1978        )
1979        .unwrap();
1980        assert!(matches!(
1981            config.auth,
1982            HttpAuth::Bearer { token } if token == "t"
1983        ));
1984    }
1985
1986    #[test]
1987    fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
1988        let config = HttpConfig::default()
1989            .with_response_timeout_ms(999)
1990            .with_allow_private_ips(true)
1991            .with_blocked_hosts(vec!["evil.com".to_string()])
1992            .with_max_body_size(12345);
1993        let endpoint =
1994            HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
1995        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
1996        assert!(endpoint.allow_private_ips);
1997        assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
1998        assert_eq!(endpoint.max_body_size, 12345);
1999    }
2000
2001    #[test]
2002    fn test_from_uri_with_defaults_uri_overrides_config() {
2003        let config = HttpConfig::default()
2004            .with_response_timeout_ms(999)
2005            .with_allow_private_ips(true)
2006            .with_blocked_hosts(vec!["evil.com".to_string()])
2007            .with_max_body_size(12345);
2008        let endpoint = HttpEndpointConfig::from_uri_with_defaults(
2009            "http://example.com/api?responseTimeout=500&allowPrivateIps=false&blockedHosts=bad.net&maxBodySize=99",
2010            &config,
2011        )
2012        .unwrap();
2013        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
2014        assert!(!endpoint.allow_private_ips);
2015        assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
2016        assert_eq!(endpoint.max_body_size, 99);
2017    }
2018
2019    #[test]
2020    fn test_http_config_ok_status_range() {
2021        let config =
2022            HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
2023        assert_eq!(config.ok_status_code_range, (200, 204));
2024    }
2025
2026    #[test]
2027    fn test_http_config_wrong_scheme() {
2028        let result = HttpEndpointConfig::from_uri("file:/tmp");
2029        assert!(result.is_err());
2030    }
2031
2032    #[test]
2033    fn test_http_component_scheme() {
2034        let component = HttpComponent::new();
2035        assert_eq!(component.scheme(), "http");
2036    }
2037
2038    #[test]
2039    fn test_https_component_scheme() {
2040        let component = HttpsComponent::new();
2041        assert_eq!(component.scheme(), "https");
2042    }
2043
2044    #[test]
2045    fn test_http_endpoint_creates_consumer() {
2046        let component = HttpComponent::new();
2047        let ctx = NoOpComponentContext;
2048        let endpoint = component
2049            .create_endpoint("http://0.0.0.0:19100/test", &ctx)
2050            .unwrap();
2051        assert!(endpoint.create_consumer(rt()).is_ok());
2052    }
2053
2054    #[test]
2055    fn test_https_endpoint_creates_consumer() {
2056        let component = HttpsComponent::new();
2057        let ctx = NoOpComponentContext;
2058        let endpoint = component
2059            .create_endpoint("https://0.0.0.0:8443/test", &ctx)
2060            .unwrap();
2061        assert!(endpoint.create_consumer(rt()).is_ok());
2062    }
2063
2064    #[test]
2065    fn test_http_endpoint_creates_producer() {
2066        let ctx = test_producer_ctx();
2067        let component = HttpComponent::new();
2068        let endpoint_ctx = NoOpComponentContext;
2069        let endpoint = component
2070            .create_endpoint("http://localhost/api", &endpoint_ctx)
2071            .unwrap();
2072        assert!(endpoint.create_producer(rt(), &ctx).is_ok());
2073    }
2074
2075    // -----------------------------------------------------------------------
2076    // Producer tests
2077    // -----------------------------------------------------------------------
2078
2079    #[tokio::test]
2080    async fn test_producer_with_token_provider() {
2081        use camel_auth::oauth2::TokenProvider;
2082        use tower::ServiceExt;
2083
2084        let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
2085            Arc::new(std::sync::Mutex::new(None));
2086        let captured_clone = Arc::clone(&captured_auth);
2087
2088        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2089        let port = listener.local_addr().unwrap().port();
2090
2091        let _handle = tokio::spawn(async move {
2092            use tokio::io::{AsyncReadExt, AsyncWriteExt};
2093            if let Ok((mut stream, _)) = listener.accept().await {
2094                let mut buf = vec![0u8; 8192];
2095                let n = stream.read(&mut buf).await.unwrap_or(0);
2096                let request = String::from_utf8_lossy(&buf[..n]).to_string();
2097                let auth = request
2098                    .lines()
2099                    .find(|l| l.to_lowercase().starts_with("authorization:"))
2100                    .map(|l| {
2101                        l.split(':')
2102                            .nth(1)
2103                            .map(|s| s.trim().to_string())
2104                            .unwrap_or_default()
2105                    });
2106                *captured_clone.lock().unwrap() = auth;
2107                let body = r#"{"echo":"ok"}"#;
2108                let resp = format!(
2109                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
2110                    body.len(),
2111                    body
2112                );
2113                let _ = stream.write_all(resp.as_bytes()).await;
2114            }
2115        });
2116
2117        #[derive(Debug)]
2118        struct StaticProvider;
2119        #[async_trait::async_trait]
2120        impl TokenProvider for StaticProvider {
2121            async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
2122                Ok("injected-token".into())
2123            }
2124        }
2125
2126        let uri = format!("http://127.0.0.1:{}/api?allowPrivateIps=true", port);
2127        let ctx = test_producer_ctx();
2128        let component = HttpComponent::new();
2129        let endpoint_ctx = NoOpComponentContext;
2130        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
2131        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2132
2133        let exchange = Exchange::new(Message::new("hello"));
2134
2135        let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
2136        let mut layered = layer.layer(producer);
2137        let result = layered.ready().await.unwrap().call(exchange).await;
2138        assert!(result.is_ok(), "producer call failed: {:?}", result);
2139
2140        tokio::time::sleep(Duration::from_millis(100)).await;
2141        let auth = captured_auth.lock().unwrap().take();
2142        assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
2143    }
2144
2145    async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
2146        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2147        let addr = listener.local_addr().unwrap();
2148        let url = format!("http://127.0.0.1:{}", addr.port());
2149
2150        let handle = tokio::spawn(async move {
2151            loop {
2152                if let Ok((mut stream, _)) = listener.accept().await {
2153                    tokio::spawn(async move {
2154                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
2155                        let mut buf = vec![0u8; 4096];
2156                        let n = stream.read(&mut buf).await.unwrap_or(0);
2157                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
2158
2159                        let method = request.split_whitespace().next().unwrap_or("GET");
2160
2161                        let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
2162                        let response = format!(
2163                            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
2164                            body.len(),
2165                            body
2166                        );
2167                        let _ = stream.write_all(response.as_bytes()).await;
2168                    });
2169                }
2170            }
2171        });
2172
2173        (url, handle)
2174    }
2175
2176    async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
2177        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2178        let addr = listener.local_addr().unwrap();
2179        let url = format!("http://127.0.0.1:{}", addr.port());
2180
2181        let handle = tokio::spawn(async move {
2182            loop {
2183                if let Ok((mut stream, _)) = listener.accept().await {
2184                    let status = status;
2185                    tokio::spawn(async move {
2186                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
2187                        let mut buf = vec![0u8; 4096];
2188                        let _ = stream.read(&mut buf).await;
2189
2190                        let status_text = match status {
2191                            404 => "Not Found",
2192                            500 => "Internal Server Error",
2193                            _ => "Error",
2194                        };
2195                        let body = "error body";
2196                        let response = format!(
2197                            "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
2198                            status,
2199                            status_text,
2200                            body.len(),
2201                            body
2202                        );
2203                        let _ = stream.write_all(response.as_bytes()).await;
2204                    });
2205                }
2206            }
2207        });
2208
2209        (url, handle)
2210    }
2211
2212    #[tokio::test]
2213    async fn test_http_producer_get_request() {
2214        use tower::ServiceExt;
2215
2216        let (url, _handle) = start_test_server().await;
2217        let ctx = test_producer_ctx();
2218
2219        let component = HttpComponent::new();
2220        let endpoint_ctx = NoOpComponentContext;
2221        let endpoint = component
2222            .create_endpoint(
2223                &format!("{url}/api/test?allowPrivateIps=true"),
2224                &endpoint_ctx,
2225            )
2226            .unwrap();
2227        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2228
2229        let exchange = Exchange::new(Message::default());
2230        let result = producer.oneshot(exchange).await.unwrap();
2231
2232        let status = result
2233            .input
2234            .header("CamelHttpResponseCode")
2235            .and_then(|v| v.as_u64())
2236            .unwrap();
2237        assert_eq!(status, 200);
2238
2239        assert!(!result.input.body.is_empty());
2240    }
2241
2242    #[tokio::test]
2243    async fn test_http_producer_post_with_body() {
2244        use tower::ServiceExt;
2245
2246        let (url, _handle) = start_test_server().await;
2247        let ctx = test_producer_ctx();
2248
2249        let component = HttpComponent::new();
2250        let endpoint_ctx = NoOpComponentContext;
2251        let endpoint = component
2252            .create_endpoint(
2253                &format!("{url}/api/data?allowPrivateIps=true"),
2254                &endpoint_ctx,
2255            )
2256            .unwrap();
2257        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2258
2259        let exchange = Exchange::new(Message::new("request body"));
2260        let result = producer.oneshot(exchange).await.unwrap();
2261
2262        let status = result
2263            .input
2264            .header("CamelHttpResponseCode")
2265            .and_then(|v| v.as_u64())
2266            .unwrap();
2267        assert_eq!(status, 200);
2268    }
2269
2270    #[tokio::test]
2271    async fn test_http_producer_method_from_header() {
2272        use tower::ServiceExt;
2273
2274        let (url, _handle) = start_test_server().await;
2275        let ctx = test_producer_ctx();
2276
2277        let component = HttpComponent::new();
2278        let endpoint_ctx = NoOpComponentContext;
2279        let endpoint = component
2280            .create_endpoint(&format!("{url}/api?allowPrivateIps=true"), &endpoint_ctx)
2281            .unwrap();
2282        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2283
2284        let mut exchange = Exchange::new(Message::default());
2285        exchange.input.set_header(
2286            "CamelHttpMethod",
2287            serde_json::Value::String("DELETE".to_string()),
2288        );
2289
2290        let result = producer.oneshot(exchange).await.unwrap();
2291        let status = result
2292            .input
2293            .header("CamelHttpResponseCode")
2294            .and_then(|v| v.as_u64())
2295            .unwrap();
2296        assert_eq!(status, 200);
2297    }
2298
2299    #[tokio::test]
2300    async fn test_http_producer_forced_method() {
2301        use tower::ServiceExt;
2302
2303        let (url, _handle) = start_test_server().await;
2304        let ctx = test_producer_ctx();
2305
2306        let component = HttpComponent::new();
2307        let endpoint_ctx = NoOpComponentContext;
2308        let endpoint = component
2309            .create_endpoint(
2310                &format!("{url}/api?httpMethod=PUT&allowPrivateIps=true"),
2311                &endpoint_ctx,
2312            )
2313            .unwrap();
2314        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2315
2316        let exchange = Exchange::new(Message::default());
2317        let result = producer.oneshot(exchange).await.unwrap();
2318
2319        let status = result
2320            .input
2321            .header("CamelHttpResponseCode")
2322            .and_then(|v| v.as_u64())
2323            .unwrap();
2324        assert_eq!(status, 200);
2325    }
2326
2327    #[tokio::test]
2328    async fn test_http_producer_throw_exception_on_failure() {
2329        use tower::ServiceExt;
2330
2331        let (url, _handle) = start_status_server(404).await;
2332        let ctx = test_producer_ctx();
2333
2334        let component = HttpComponent::new();
2335        let endpoint_ctx = NoOpComponentContext;
2336        let endpoint = component
2337            .create_endpoint(
2338                &format!("{url}/not-found?allowPrivateIps=true"),
2339                &endpoint_ctx,
2340            )
2341            .unwrap();
2342        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2343
2344        let exchange = Exchange::new(Message::default());
2345        let result = producer.oneshot(exchange).await;
2346        assert!(result.is_err());
2347
2348        match result.unwrap_err() {
2349            CamelError::HttpOperationFailed { status_code, .. } => {
2350                assert_eq!(status_code, 404);
2351            }
2352            e => panic!("Expected HttpOperationFailed, got: {e}"),
2353        }
2354    }
2355
2356    #[tokio::test]
2357    async fn test_http_producer_no_throw_on_failure() {
2358        use tower::ServiceExt;
2359
2360        let (url, _handle) = start_status_server(500).await;
2361        let ctx = test_producer_ctx();
2362
2363        let component = HttpComponent::new();
2364        let endpoint_ctx = NoOpComponentContext;
2365        let endpoint = component
2366            .create_endpoint(
2367                &format!("{url}/error?throwExceptionOnFailure=false&allowPrivateIps=true"),
2368                &endpoint_ctx,
2369            )
2370            .unwrap();
2371        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2372
2373        let exchange = Exchange::new(Message::default());
2374        let result = producer.oneshot(exchange).await.unwrap();
2375
2376        let status = result
2377            .input
2378            .header("CamelHttpResponseCode")
2379            .and_then(|v| v.as_u64())
2380            .unwrap();
2381        assert_eq!(status, 500);
2382    }
2383
2384    #[tokio::test]
2385    async fn test_http_producer_uri_override() {
2386        use tower::ServiceExt;
2387
2388        let (url, _handle) = start_test_server().await;
2389        let ctx = test_producer_ctx();
2390
2391        let component = HttpComponent::new();
2392        let endpoint_ctx = NoOpComponentContext;
2393        let endpoint = component
2394            .create_endpoint(
2395                "http://localhost:1/does-not-exist?allowPrivateIps=true",
2396                &endpoint_ctx,
2397            )
2398            .unwrap();
2399        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2400
2401        let mut exchange = Exchange::new(Message::default());
2402        exchange.input.set_header(
2403            "CamelHttpUri",
2404            serde_json::Value::String(format!("{url}/api")),
2405        );
2406
2407        let result = producer.oneshot(exchange).await.unwrap();
2408        let status = result
2409            .input
2410            .header("CamelHttpResponseCode")
2411            .and_then(|v| v.as_u64())
2412            .unwrap();
2413        assert_eq!(status, 200);
2414    }
2415
2416    #[tokio::test]
2417    async fn test_http_producer_response_headers_mapped() {
2418        use tower::ServiceExt;
2419
2420        let (url, _handle) = start_test_server().await;
2421        let ctx = test_producer_ctx();
2422
2423        let component = HttpComponent::new();
2424        let endpoint_ctx = NoOpComponentContext;
2425        let endpoint = component
2426            .create_endpoint(&format!("{url}/api?allowPrivateIps=true"), &endpoint_ctx)
2427            .unwrap();
2428        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2429
2430        let exchange = Exchange::new(Message::default());
2431        let result = producer.oneshot(exchange).await.unwrap();
2432
2433        assert!(
2434            result.input.header("Content-Type").is_some(),
2435            "Response should have Content-Type header"
2436        );
2437        assert!(result.input.header("CamelHttpResponseText").is_some());
2438    }
2439
2440    // -----------------------------------------------------------------------
2441    // Bug fix tests: Client configuration per-endpoint
2442    // -----------------------------------------------------------------------
2443
2444    async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
2445        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2446        let addr = listener.local_addr().unwrap();
2447        let url = format!("http://127.0.0.1:{}", addr.port());
2448
2449        let handle = tokio::spawn(async move {
2450            use tokio::io::{AsyncReadExt, AsyncWriteExt};
2451            loop {
2452                if let Ok((mut stream, _)) = listener.accept().await {
2453                    tokio::spawn(async move {
2454                        let mut buf = vec![0u8; 4096];
2455                        let n = stream.read(&mut buf).await.unwrap_or(0);
2456                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
2457
2458                        // Check if this is a request to /final
2459                        if request.contains("GET /final") {
2460                            let body = r#"{"status":"final"}"#;
2461                            let response = format!(
2462                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
2463                                body.len(),
2464                                body
2465                            );
2466                            let _ = stream.write_all(response.as_bytes()).await;
2467                        } else {
2468                            // Redirect to /final
2469                            let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\n\r\n";
2470                            let _ = stream.write_all(response.as_bytes()).await;
2471                        }
2472                    });
2473                }
2474            }
2475        });
2476
2477        (url, handle)
2478    }
2479
2480    #[tokio::test]
2481    async fn test_follow_redirects_false_does_not_follow() {
2482        use tower::ServiceExt;
2483
2484        let (url, _handle) = start_redirect_server().await;
2485        let ctx = test_producer_ctx();
2486
2487        let component =
2488            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
2489        let endpoint_ctx = NoOpComponentContext;
2490        let endpoint = component
2491            .create_endpoint(
2492                &format!("{url}?throwExceptionOnFailure=false&allowPrivateIps=true"),
2493                &endpoint_ctx,
2494            )
2495            .unwrap();
2496        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2497
2498        let exchange = Exchange::new(Message::default());
2499        let result = producer.oneshot(exchange).await.unwrap();
2500
2501        // Should get 302, NOT follow redirect to 200
2502        let status = result
2503            .input
2504            .header("CamelHttpResponseCode")
2505            .and_then(|v| v.as_u64())
2506            .unwrap();
2507        assert_eq!(
2508            status, 302,
2509            "Should NOT follow redirect when followRedirects=false"
2510        );
2511    }
2512
2513    #[tokio::test]
2514    async fn test_follow_redirects_true_follows_redirect() {
2515        use tower::ServiceExt;
2516
2517        let (url, _handle) = start_redirect_server().await;
2518        let ctx = test_producer_ctx();
2519
2520        let component =
2521            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
2522        let endpoint_ctx = NoOpComponentContext;
2523        let endpoint = component
2524            .create_endpoint(&format!("{url}?allowPrivateIps=true"), &endpoint_ctx)
2525            .unwrap();
2526        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2527
2528        let exchange = Exchange::new(Message::default());
2529        let result = producer.oneshot(exchange).await.unwrap();
2530
2531        // Should follow redirect and get 200
2532        let status = result
2533            .input
2534            .header("CamelHttpResponseCode")
2535            .and_then(|v| v.as_u64())
2536            .unwrap();
2537        assert_eq!(
2538            status, 200,
2539            "Should follow redirect when followRedirects=true"
2540        );
2541    }
2542
2543    #[tokio::test]
2544    async fn test_query_params_forwarded_to_http_request() {
2545        use tower::ServiceExt;
2546
2547        let (url, _handle) = start_test_server().await;
2548        let ctx = test_producer_ctx();
2549
2550        let component = HttpComponent::new();
2551        let endpoint_ctx = NoOpComponentContext;
2552        // apiKey is NOT a Camel option, should be forwarded as query param
2553        let endpoint = component
2554            .create_endpoint(
2555                &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowPrivateIps=true"),
2556                &endpoint_ctx,
2557            )
2558            .unwrap();
2559        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2560
2561        let exchange = Exchange::new(Message::default());
2562        let result = producer.oneshot(exchange).await.unwrap();
2563
2564        // The test server returns the request info in response
2565        // We just verify it succeeds (the query param was sent)
2566        let status = result
2567            .input
2568            .header("CamelHttpResponseCode")
2569            .and_then(|v| v.as_u64())
2570            .unwrap();
2571        assert_eq!(status, 200);
2572    }
2573
2574    #[tokio::test]
2575    async fn test_non_camel_query_params_are_forwarded() {
2576        // This test verifies Bug #3 fix: non-Camel options should be forwarded
2577        // We'll test the config parsing, not the actual HTTP call
2578        let config = HttpEndpointConfig::from_uri(
2579            "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
2580        )
2581        .unwrap();
2582
2583        // apiKey and token are NOT Camel options, should be forwarded
2584        assert!(
2585            config.query_params.contains_key("apiKey"),
2586            "apiKey should be preserved"
2587        );
2588        assert!(
2589            config.query_params.contains_key("token"),
2590            "token should be preserved"
2591        );
2592        assert_eq!(config.query_params.get("apiKey").unwrap(), "secret123");
2593        assert_eq!(config.query_params.get("token").unwrap(), "abc456");
2594
2595        // httpMethod IS a Camel option, should NOT be in query_params
2596        assert!(
2597            !config.query_params.contains_key("httpMethod"),
2598            "httpMethod should not be forwarded"
2599        );
2600    }
2601
2602    #[test]
2603    fn test_query_params_are_url_encoded_when_resolving_url() {
2604        let config =
2605            HttpEndpointConfig::from_uri("http://example.com/api?q=hello world&tag=a+b").unwrap();
2606        let exchange = Exchange::new(Message::default());
2607
2608        let url = HttpProducer::resolve_url(&exchange, &config);
2609
2610        assert!(url.contains("q=hello+world"), "url was: {url}");
2611        assert!(url.contains("tag=a%2Bb"), "url was: {url}");
2612    }
2613
2614    // -----------------------------------------------------------------------
2615    // Timeout tests (HTTP-004)
2616    // -----------------------------------------------------------------------
2617
2618    async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
2619        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2620        let addr = listener.local_addr().unwrap();
2621        let url = format!("http://127.0.0.1:{}", addr.port());
2622
2623        let handle = tokio::spawn(async move {
2624            loop {
2625                if let Ok((mut stream, _)) = listener.accept().await {
2626                    let delay = delay_ms;
2627                    tokio::spawn(async move {
2628                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
2629                        let mut buf = vec![0u8; 4096];
2630                        let _ = stream.read(&mut buf).await;
2631                        // Send headers immediately (no Content-Length → chunked)
2632                        let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
2633                        let _ = stream.write_all(headers.as_bytes()).await;
2634                        // Delay before sending body chunk
2635                        tokio::time::sleep(Duration::from_millis(delay)).await;
2636                        let body = r#"{"status":"slow"}"#;
2637                        let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
2638                        let _ = stream.write_all(chunk.as_bytes()).await;
2639                    });
2640                }
2641            }
2642        });
2643
2644        (url, handle)
2645    }
2646
2647    #[tokio::test]
2648    async fn test_http_producer_timeout() {
2649        use tower::ServiceExt;
2650
2651        // Server delays 500ms, client timeout is 100ms → should timeout
2652        let (url, _handle) = start_slow_server(500).await;
2653        let ctx = test_producer_ctx();
2654
2655        let component = HttpComponent::with_config(
2656            HttpConfig::default()
2657                .with_read_timeout_ms(100)
2658                .with_response_timeout_ms(30_000), // generous response timeout
2659        );
2660        let endpoint_ctx = NoOpComponentContext;
2661        let endpoint = component
2662            .create_endpoint(&format!("{url}/slow?allowPrivateIps=true"), &endpoint_ctx)
2663            .unwrap();
2664        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2665
2666        let exchange = Exchange::new(Message::default());
2667        let result = producer.oneshot(exchange).await;
2668
2669        assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
2670        let err = result.unwrap_err().to_string();
2671        assert!(
2672            err.contains("Read timeout") || err.contains("timeout"),
2673            "Error should mention timeout, got: {}",
2674            err
2675        );
2676    }
2677
2678    #[tokio::test]
2679    async fn test_http_producer_no_timeout_when_fast() {
2680        use tower::ServiceExt;
2681
2682        let (url, _handle) = start_test_server().await;
2683        let ctx = test_producer_ctx();
2684
2685        let component =
2686            HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
2687        let endpoint_ctx = NoOpComponentContext;
2688        let endpoint = component
2689            .create_endpoint(&format!("{url}/api?allowPrivateIps=true"), &endpoint_ctx)
2690            .unwrap();
2691        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2692
2693        let exchange = Exchange::new(Message::default());
2694        let result = producer.oneshot(exchange).await.unwrap();
2695
2696        let status = result
2697            .input
2698            .header("CamelHttpResponseCode")
2699            .and_then(|v| v.as_u64())
2700            .unwrap();
2701        assert_eq!(status, 200);
2702    }
2703
2704    // -----------------------------------------------------------------------
2705    // SSRF Protection tests
2706    // -----------------------------------------------------------------------
2707
2708    #[tokio::test]
2709    async fn test_http_producer_blocks_metadata_endpoint() {
2710        use tower::ServiceExt;
2711
2712        let ctx = test_producer_ctx();
2713        let component = HttpComponent::new();
2714        let endpoint_ctx = NoOpComponentContext;
2715        let endpoint = component
2716            .create_endpoint(
2717                "http://example.com/api?allowPrivateIps=false",
2718                &endpoint_ctx,
2719            )
2720            .unwrap();
2721        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2722
2723        let mut exchange = Exchange::new(Message::default());
2724        exchange.input.set_header(
2725            "CamelHttpUri",
2726            serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
2727        );
2728
2729        let result = producer.oneshot(exchange).await;
2730        assert!(result.is_err(), "Should block AWS metadata endpoint");
2731
2732        let err = result.unwrap_err();
2733        assert!(
2734            err.to_string().contains("Private IP"),
2735            "Error should mention private IP blocking, got: {}",
2736            err
2737        );
2738    }
2739
2740    #[test]
2741    fn test_ssrf_config_defaults() {
2742        let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
2743        assert!(
2744            !config.allow_private_ips,
2745            "Private IPs should be blocked by default"
2746        );
2747        assert!(
2748            config.blocked_hosts.is_empty(),
2749            "Blocked hosts should be empty by default"
2750        );
2751    }
2752
2753    #[test]
2754    fn test_ssrf_config_allow_private_ips() {
2755        let config =
2756            HttpEndpointConfig::from_uri("http://example.com/api?allowPrivateIps=true").unwrap();
2757        assert!(
2758            config.allow_private_ips,
2759            "Private IPs should be allowed when explicitly set"
2760        );
2761    }
2762
2763    #[test]
2764    fn test_ssrf_config_blocked_hosts() {
2765        let config = HttpEndpointConfig::from_uri(
2766            "http://example.com/api?blockedHosts=evil.com,malware.net",
2767        )
2768        .unwrap();
2769        assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
2770    }
2771
2772    #[tokio::test]
2773    async fn test_http_producer_blocks_localhost() {
2774        use tower::ServiceExt;
2775
2776        let ctx = test_producer_ctx();
2777        let component = HttpComponent::new();
2778        let endpoint_ctx = NoOpComponentContext;
2779        let endpoint = component
2780            .create_endpoint("http://example.com/api", &endpoint_ctx)
2781            .unwrap();
2782        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2783
2784        let mut exchange = Exchange::new(Message::default());
2785        exchange.input.set_header(
2786            "CamelHttpUri",
2787            serde_json::Value::String("http://localhost:8080/internal".to_string()),
2788        );
2789
2790        let result = producer.oneshot(exchange).await;
2791        assert!(result.is_err(), "Should block localhost");
2792    }
2793
2794    #[tokio::test]
2795    async fn test_http_producer_blocks_loopback_ip() {
2796        use tower::ServiceExt;
2797
2798        let ctx = test_producer_ctx();
2799        let component = HttpComponent::new();
2800        let endpoint_ctx = NoOpComponentContext;
2801        let endpoint = component
2802            .create_endpoint("http://example.com/api", &endpoint_ctx)
2803            .unwrap();
2804        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2805
2806        let mut exchange = Exchange::new(Message::default());
2807        exchange.input.set_header(
2808            "CamelHttpUri",
2809            serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
2810        );
2811
2812        let result = producer.oneshot(exchange).await;
2813        assert!(result.is_err(), "Should block loopback IP");
2814    }
2815
2816    #[tokio::test]
2817    async fn test_http_producer_allows_private_ip_when_enabled() {
2818        use tower::ServiceExt;
2819
2820        let ctx = test_producer_ctx();
2821        let component = HttpComponent::new();
2822        let endpoint_ctx = NoOpComponentContext;
2823        // With allowPrivateIps=true, the validation should pass
2824        // (actual connection will fail, but that's expected)
2825        let endpoint = component
2826            .create_endpoint("http://192.168.1.1/api?allowPrivateIps=true", &endpoint_ctx)
2827            .unwrap();
2828        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2829
2830        let exchange = Exchange::new(Message::default());
2831
2832        // The request will fail because we can't connect, but it should NOT fail
2833        // due to SSRF protection
2834        let result = producer.oneshot(exchange).await;
2835        // We expect connection error, not SSRF error
2836        if let Err(ref e) = result {
2837            let err_str = e.to_string();
2838            assert!(
2839                !err_str.contains("Private IP") && !err_str.contains("not allowed"),
2840                "Should not be SSRF error, got: {}",
2841                err_str
2842            );
2843        }
2844    }
2845
2846    // -----------------------------------------------------------------------
2847    // HttpServerConfig tests
2848    // -----------------------------------------------------------------------
2849
2850    #[test]
2851    fn test_http_server_config_parse() {
2852        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
2853        assert_eq!(cfg.host, "0.0.0.0");
2854        assert_eq!(cfg.port, 8080);
2855        assert_eq!(cfg.path, "/orders");
2856        assert_eq!(cfg.max_inflight_requests, 1024);
2857    }
2858
2859    #[test]
2860    fn test_http_server_config_scheme() {
2861        // UriConfig trait method returns "http" as primary scheme
2862        assert_eq!(HttpServerConfig::scheme(), "http");
2863    }
2864
2865    #[test]
2866    fn test_http_server_config_from_components() {
2867        // Test from_components directly (trait method)
2868        let components = camel_component_api::UriComponents {
2869            scheme: "https".to_string(),
2870            path: "//0.0.0.0:8443/api".to_string(),
2871            params: std::collections::HashMap::from([
2872                ("maxRequestBody".to_string(), "5242880".to_string()),
2873                ("maxInflightRequests".to_string(), "7".to_string()),
2874            ]),
2875        };
2876        let cfg = HttpServerConfig::from_components(components).unwrap();
2877        assert_eq!(cfg.host, "0.0.0.0");
2878        assert_eq!(cfg.port, 8443);
2879        assert_eq!(cfg.path, "/api");
2880        assert_eq!(cfg.max_request_body, 5242880);
2881        assert_eq!(cfg.max_inflight_requests, 7);
2882    }
2883
2884    #[test]
2885    fn test_http_server_config_default_path() {
2886        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
2887        assert_eq!(cfg.path, "/");
2888    }
2889
2890    #[test]
2891    fn test_http_server_config_wrong_scheme() {
2892        assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
2893    }
2894
2895    #[test]
2896    fn test_http_server_config_invalid_port() {
2897        assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
2898    }
2899
2900    #[test]
2901    fn test_http_server_config_default_port_by_scheme() {
2902        // HTTP without explicit port should default to 80
2903        let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
2904        assert_eq!(cfg_http.port, 80);
2905
2906        // HTTPS without explicit port should default to 443
2907        let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
2908        assert_eq!(cfg_https.port, 443);
2909    }
2910
2911    #[test]
2912    fn test_request_envelope_and_reply_are_send() {
2913        fn assert_send<T: Send>() {}
2914        assert_send::<RequestEnvelope>();
2915        assert_send::<HttpReply>();
2916    }
2917
2918    // -----------------------------------------------------------------------
2919    // ServerRegistry tests
2920    // -----------------------------------------------------------------------
2921
2922    #[test]
2923    fn test_server_registry_global_is_singleton() {
2924        let r1 = ServerRegistry::global();
2925        let r2 = ServerRegistry::global();
2926        assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
2927    }
2928
2929    #[allow(clippy::await_holding_lock)]
2930    #[tokio::test]
2931    async fn test_concurrent_get_or_spawn_returns_same_registry() {
2932        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
2933        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2934        let port = listener.local_addr().unwrap().port();
2935        drop(listener);
2936
2937        let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
2938            Arc::new(std::sync::Mutex::new(Vec::new()));
2939
2940        let mut handles = Vec::new();
2941        for _ in 0..4 {
2942            let results = results.clone();
2943            handles.push(tokio::spawn(async move {
2944                let registry = ServerRegistry::global()
2945                    .get_or_spawn(
2946                        "127.0.0.1",
2947                        port,
2948                        2 * 1024 * 1024,
2949                        10 * 1024 * 1024,
2950                        1024,
2951                        test_rt(),
2952                        "test-route".into(),
2953                    )
2954                    .await
2955                    .unwrap();
2956                results.lock().unwrap().push(registry);
2957            }));
2958        }
2959
2960        for h in handles {
2961            h.await.unwrap();
2962        }
2963
2964        let registries = results.lock().unwrap();
2965        assert_eq!(registries.len(), 4);
2966        for i in 1..registries.len() {
2967            assert!(
2968                Arc::ptr_eq(&registries[0].inner, &registries[i].inner),
2969                "all concurrent callers should get same route registry"
2970            );
2971        }
2972    }
2973
2974    #[test]
2975    fn test_server_registry_distinguishes_host_and_port() {
2976        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
2977        let rt = tokio::runtime::Runtime::new().expect("runtime");
2978        rt.block_on(async {
2979            let registry = ServerRegistry::global();
2980            // Use two distinct host values with same configured port key.
2981            // Port 0 is acceptable here because the registry key uses the configured
2982            // tuple, not the OS-assigned ephemeral port.
2983            let d1 = registry
2984                .get_or_spawn(
2985                    "127.0.0.1",
2986                    0,
2987                    1024 * 1024,
2988                    10 * 1024 * 1024,
2989                    1024,
2990                    test_rt(),
2991                    "test-route-1".into(),
2992                )
2993                .await;
2994            let d2 = registry
2995                .get_or_spawn(
2996                    "0.0.0.0",
2997                    0,
2998                    1024 * 1024,
2999                    10 * 1024 * 1024,
3000                    1024,
3001                    test_rt(),
3002                    "test-route-2".into(),
3003                )
3004                .await;
3005            assert!(d1.is_ok());
3006            assert!(d2.is_ok());
3007            assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
3008        });
3009    }
3010
3011    #[allow(clippy::await_holding_lock)]
3012    #[tokio::test]
3013    async fn test_shared_server_max_request_body_policy_is_deterministic() {
3014        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3015        let registry = ServerRegistry::global();
3016        // First registration: maxRequestBody = 1 MB
3017        let d1 = registry
3018            .get_or_spawn(
3019                "127.0.0.1",
3020                9991,
3021                1024 * 1024,
3022                10 * 1024 * 1024,
3023                1024,
3024                test_rt(),
3025                "test-route".into(),
3026            )
3027            .await;
3028        assert!(d1.is_ok());
3029
3030        // Second registration on same (host,port): maxRequestBody = 2 MB
3031        // Expected: explicit EndpointCreationFailed about incompatible maxRequestBody
3032        let d2 = registry
3033            .get_or_spawn(
3034                "127.0.0.1",
3035                9991,
3036                2 * 1024 * 1024,
3037                10 * 1024 * 1024,
3038                1024,
3039                test_rt(),
3040                "test-route-2".into(),
3041            )
3042            .await;
3043        assert!(d2.is_err());
3044        let err = d2.unwrap_err();
3045        assert!(
3046            err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
3047            "Expected incompatible maxRequestBody error, got: {}",
3048            err
3049        );
3050    }
3051
3052    #[test]
3053    fn test_server_registry_reset_clears_entries() {
3054        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3055        let rt = tokio::runtime::Runtime::new().expect("runtime");
3056        rt.block_on(async {
3057            // Register something on a unique port
3058            let d1 = ServerRegistry::global()
3059                .get_or_spawn(
3060                    "127.0.0.1",
3061                    9992,
3062                    1024 * 1024,
3063                    10 * 1024 * 1024,
3064                    1024,
3065                    test_rt(),
3066                    "test-route".into(),
3067                )
3068                .await;
3069            assert!(d1.is_ok());
3070
3071            // Verify entry exists
3072            let guard = ServerRegistry::global().inner.lock().expect("lock");
3073            assert!(guard.contains_key(&("127.0.0.1".to_string(), 9992)));
3074            drop(guard);
3075
3076            // Reset
3077            ServerRegistry::reset();
3078
3079            // Verify cleared
3080            let guard = ServerRegistry::global().inner.lock().expect("lock");
3081            assert!(
3082                guard.is_empty(),
3083                "registry should be empty after reset, has {} entries",
3084                guard.len()
3085            );
3086        });
3087    }
3088
3089    // -----------------------------------------------------------------------
3090    // Axum dispatch handler tests
3091    // -----------------------------------------------------------------------
3092
3093    #[tokio::test]
3094    async fn test_dispatch_handler_returns_404_for_unknown_path() {
3095        let registry = HttpRouteRegistry::new();
3096        // Nothing registered in route registry
3097        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3098        let port = listener.local_addr().unwrap().port();
3099        tokio::spawn(run_axum_server(
3100            listener,
3101            registry,
3102            2 * 1024 * 1024,
3103            10 * 1024 * 1024,
3104            Arc::new(tokio::sync::Semaphore::new(1024)),
3105            test_rt(),
3106            "test-route".into(),
3107        ));
3108
3109        // Wait for server to start
3110        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3111
3112        let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
3113            .await
3114            .unwrap();
3115        assert_eq!(resp.status().as_u16(), 404);
3116    }
3117
3118    // -----------------------------------------------------------------------
3119    // HttpConsumer tests
3120    // -----------------------------------------------------------------------
3121
3122    #[tokio::test]
3123    async fn test_http_consumer_start_registers_path() {
3124        use camel_component_api::ConsumerContext;
3125
3126        // Get an OS-assigned free port
3127        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3128        let port = listener.local_addr().unwrap().port();
3129        drop(listener); // Release port — ServerRegistry will rebind it
3130
3131        let consumer_cfg = HttpServerConfig {
3132            host: "127.0.0.1".to_string(),
3133            port,
3134            path: "/ping".to_string(),
3135            max_request_body: 2 * 1024 * 1024,
3136            max_response_body: 10 * 1024 * 1024,
3137            max_inflight_requests: 1024,
3138        };
3139        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
3140
3141        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
3142        let token = tokio_util::sync::CancellationToken::new();
3143        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3144
3145        tokio::spawn(async move {
3146            consumer.start(ctx).await.unwrap();
3147        });
3148
3149        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3150
3151        let client = reqwest::Client::new();
3152        let resp_future = client
3153            .post(format!("http://127.0.0.1:{port}/ping"))
3154            .body("hello world")
3155            .send();
3156
3157        let (http_result, _) = tokio::join!(resp_future, async {
3158            if let Some(mut envelope) = rx.recv().await {
3159                // Set a custom status code
3160                envelope.exchange.input.set_header(
3161                    "CamelHttpResponseCode",
3162                    serde_json::Value::Number(201.into()),
3163                );
3164                if let Some(reply_tx) = envelope.reply_tx {
3165                    let _ = reply_tx.send(Ok(envelope.exchange));
3166                }
3167            }
3168        });
3169
3170        let resp = http_result.unwrap();
3171        assert_eq!(resp.status().as_u16(), 201);
3172
3173        token.cancel();
3174    }
3175
3176    #[tokio::test]
3177    async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
3178        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
3179
3180        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3181        let port = listener.local_addr().unwrap().port();
3182        drop(listener);
3183
3184        let consumer_cfg = HttpServerConfig {
3185            host: "127.0.0.1".to_string(),
3186            port,
3187            path: "/saturation".to_string(),
3188            max_request_body: 2 * 1024 * 1024,
3189            max_response_body: 10 * 1024 * 1024,
3190            max_inflight_requests: 1,
3191        };
3192        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
3193
3194        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3195        let token = tokio_util::sync::CancellationToken::new();
3196        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3197        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
3198        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3199
3200        let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
3201        let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
3202
3203        tokio::spawn(async move {
3204            let mut first_seen_tx = Some(first_seen_tx);
3205            let mut unblock_first_rx = Some(unblock_first_rx);
3206
3207            while let Some(envelope) = rx.recv().await {
3208                if let Some(tx) = first_seen_tx.take() {
3209                    let _ = tx.send(());
3210                    if let Some(rx_unblock) = unblock_first_rx.take() {
3211                        let _ = rx_unblock.await;
3212                    }
3213                }
3214
3215                if let Some(reply_tx) = envelope.reply_tx {
3216                    let _ = reply_tx.send(Ok(envelope.exchange));
3217                }
3218            }
3219        });
3220
3221        let client = reqwest::Client::new();
3222        let first_req = {
3223            let client = client.clone();
3224            async move {
3225                client
3226                    .get(format!("http://127.0.0.1:{port}/saturation"))
3227                    .send()
3228                    .await
3229                    .unwrap()
3230            }
3231        };
3232
3233        let first_handle = tokio::spawn(first_req);
3234        first_seen_rx.await.unwrap();
3235
3236        let second_resp = client
3237            .get(format!("http://127.0.0.1:{port}/saturation"))
3238            .send()
3239            .await
3240            .unwrap();
3241
3242        assert_eq!(second_resp.status().as_u16(), 503);
3243
3244        let _ = unblock_first_tx.send(());
3245        let first_resp = first_handle.await.unwrap();
3246        assert_eq!(first_resp.status().as_u16(), 200);
3247
3248        token.cancel();
3249    }
3250
3251    #[tokio::test]
3252    async fn test_http_consumer_enforces_max_response_body_for_bytes() {
3253        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
3254
3255        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3256
3257        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3258        let port = listener.local_addr().unwrap().port();
3259        drop(listener);
3260
3261        let consumer_cfg = HttpServerConfig {
3262            host: "127.0.0.1".to_string(),
3263            port,
3264            path: "/limit-bytes".to_string(),
3265            max_request_body: 2 * 1024 * 1024,
3266            max_response_body: 16,
3267            max_inflight_requests: 1024,
3268        };
3269        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
3270
3271        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3272        let token = tokio_util::sync::CancellationToken::new();
3273        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3274        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
3275        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3276
3277        let client = reqwest::Client::new();
3278        let send_fut = client
3279            .get(format!("http://127.0.0.1:{port}/limit-bytes"))
3280            .send();
3281
3282        let (http_result, _) = tokio::join!(send_fut, async {
3283            if let Some(mut envelope) = rx.recv().await {
3284                envelope.exchange.input.body =
3285                    camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
3286                if let Some(reply_tx) = envelope.reply_tx {
3287                    let _ = reply_tx.send(Ok(envelope.exchange));
3288                }
3289            }
3290        });
3291
3292        let resp = http_result.unwrap();
3293        assert_eq!(resp.status().as_u16(), 500);
3294        let body = resp.text().await.unwrap();
3295        assert_eq!(body, "Response body exceeds configured limit");
3296        token.cancel();
3297    }
3298
3299    #[tokio::test]
3300    async fn test_http_consumer_enforces_max_response_body_for_json() {
3301        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
3302
3303        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3304
3305        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3306        let port = listener.local_addr().unwrap().port();
3307        drop(listener);
3308
3309        let consumer_cfg = HttpServerConfig {
3310            host: "127.0.0.1".to_string(),
3311            port,
3312            path: "/limit-json".to_string(),
3313            max_request_body: 2 * 1024 * 1024,
3314            max_response_body: 16,
3315            max_inflight_requests: 1024,
3316        };
3317        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
3318
3319        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3320        let token = tokio_util::sync::CancellationToken::new();
3321        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3322        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
3323        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3324
3325        let client = reqwest::Client::new();
3326        let send_fut = client
3327            .get(format!("http://127.0.0.1:{port}/limit-json"))
3328            .send();
3329
3330        let (http_result, _) = tokio::join!(send_fut, async {
3331            if let Some(mut envelope) = rx.recv().await {
3332                envelope.exchange.input.body = camel_component_api::Body::Json(
3333                    serde_json::json!({"message":"this response is bigger than sixteen"}),
3334                );
3335                if let Some(reply_tx) = envelope.reply_tx {
3336                    let _ = reply_tx.send(Ok(envelope.exchange));
3337                }
3338            }
3339        });
3340
3341        let resp = http_result.unwrap();
3342        assert_eq!(resp.status().as_u16(), 500);
3343        let body = resp.text().await.unwrap();
3344        assert_eq!(body, "Response body exceeds configured limit");
3345        token.cancel();
3346    }
3347
3348    #[tokio::test]
3349    async fn test_http_consumer_enforces_max_response_body_for_xml() {
3350        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
3351
3352        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3353
3354        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3355        let port = listener.local_addr().unwrap().port();
3356        drop(listener);
3357
3358        let consumer_cfg = HttpServerConfig {
3359            host: "127.0.0.1".to_string(),
3360            port,
3361            path: "/limit-xml".to_string(),
3362            max_request_body: 2 * 1024 * 1024,
3363            max_response_body: 16,
3364            max_inflight_requests: 1024,
3365        };
3366        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
3367
3368        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3369        let token = tokio_util::sync::CancellationToken::new();
3370        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3371        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
3372        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3373
3374        let client = reqwest::Client::new();
3375        let send_fut = client
3376            .get(format!("http://127.0.0.1:{port}/limit-xml"))
3377            .send();
3378
3379        let (http_result, _) = tokio::join!(send_fut, async {
3380            if let Some(mut envelope) = rx.recv().await {
3381                envelope.exchange.input.body = camel_component_api::Body::Xml(
3382                    "<root><value>way-too-large</value></root>".into(),
3383                );
3384                if let Some(reply_tx) = envelope.reply_tx {
3385                    let _ = reply_tx.send(Ok(envelope.exchange));
3386                }
3387            }
3388        });
3389
3390        let resp = http_result.unwrap();
3391        assert_eq!(resp.status().as_u16(), 500);
3392        let body = resp.text().await.unwrap();
3393        assert_eq!(body, "Response body exceeds configured limit");
3394        token.cancel();
3395    }
3396
3397    #[tokio::test]
3398    async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
3399        use camel_component_api::{
3400            CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
3401        };
3402        use futures::stream;
3403
3404        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3405
3406        let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
3407        let port = listener.local_addr().unwrap().port();
3408        drop(listener);
3409
3410        let consumer_cfg = HttpServerConfig {
3411            host: "0.0.0.0".to_string(),
3412            port,
3413            path: "/limit-stream".to_string(),
3414            max_request_body: 2 * 1024 * 1024,
3415            max_response_body: 16,
3416            max_inflight_requests: 1024,
3417        };
3418        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
3419
3420        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3421        let token = tokio_util::sync::CancellationToken::new();
3422        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3423        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
3424        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3425
3426        let client = reqwest::Client::new();
3427        let send_fut = client
3428            .get(format!("http://127.0.0.1:{port}/limit-stream"))
3429            .send();
3430
3431        let (http_result, _) = tokio::join!(send_fut, async {
3432            if let Some(mut envelope) = rx.recv().await {
3433                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
3434                    vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
3435                let stream = Box::pin(stream::iter(chunks));
3436                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
3437                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
3438                    metadata: StreamMetadata {
3439                        size_hint: Some(32),
3440                        content_type: Some("application/octet-stream".into()),
3441                        origin: None,
3442                    },
3443                });
3444                if let Some(reply_tx) = envelope.reply_tx {
3445                    let _ = reply_tx.send(Ok(envelope.exchange));
3446                }
3447            }
3448        });
3449
3450        let resp = http_result.unwrap();
3451        assert_eq!(resp.status().as_u16(), 200);
3452        let body = resp.bytes().await.unwrap();
3453        assert_eq!(body.len(), 32);
3454        token.cancel();
3455    }
3456
3457    // -----------------------------------------------------------------------
3458    // Integration tests
3459    // -----------------------------------------------------------------------
3460
3461    #[tokio::test]
3462    async fn test_integration_single_consumer_round_trip() {
3463        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
3464
3465        // Get an OS-assigned free port (ephemeral)
3466        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3467        let port = listener.local_addr().unwrap().port();
3468        drop(listener); // Release — ServerRegistry will rebind
3469
3470        let component = HttpComponent::new();
3471        let endpoint_ctx = NoOpComponentContext;
3472        let endpoint = component
3473            .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
3474            .unwrap();
3475        let mut consumer = endpoint.create_consumer(rt()).unwrap();
3476
3477        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3478        let token = tokio_util::sync::CancellationToken::new();
3479        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3480
3481        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
3482        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3483
3484        let client = reqwest::Client::new();
3485        let send_fut = client
3486            .post(format!("http://127.0.0.1:{port}/echo"))
3487            .header("Content-Type", "text/plain")
3488            .body("ping")
3489            .send();
3490
3491        let (http_result, _) = tokio::join!(send_fut, async {
3492            if let Some(mut envelope) = rx.recv().await {
3493                assert_eq!(
3494                    envelope.exchange.input.header("CamelHttpMethod"),
3495                    Some(&serde_json::Value::String("POST".into()))
3496                );
3497                assert_eq!(
3498                    envelope.exchange.input.header("CamelHttpPath"),
3499                    Some(&serde_json::Value::String("/echo".into()))
3500                );
3501                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
3502                if let Some(reply_tx) = envelope.reply_tx {
3503                    let _ = reply_tx.send(Ok(envelope.exchange));
3504                }
3505            }
3506        });
3507
3508        let resp = http_result.unwrap();
3509        assert_eq!(resp.status().as_u16(), 200);
3510        let body = resp.text().await.unwrap();
3511        assert_eq!(body, "pong");
3512
3513        token.cancel();
3514    }
3515
3516    #[tokio::test]
3517    async fn test_integration_two_consumers_shared_port() {
3518        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
3519
3520        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3521
3522        // Get an OS-assigned free port (ephemeral)
3523        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3524        let port = listener.local_addr().unwrap().port();
3525        drop(listener);
3526
3527        let component = HttpComponent::new();
3528        let endpoint_ctx = NoOpComponentContext;
3529
3530        // Consumer A: /hello
3531        let endpoint_a = component
3532            .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
3533            .unwrap();
3534        let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
3535
3536        // Consumer B: /world
3537        let endpoint_b = component
3538            .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
3539            .unwrap();
3540        let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
3541
3542        let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3543        let token_a = tokio_util::sync::CancellationToken::new();
3544        let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
3545
3546        let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3547        let token_b = tokio_util::sync::CancellationToken::new();
3548        let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
3549
3550        tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
3551        tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
3552        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3553
3554        let client = reqwest::Client::new();
3555
3556        // Request to /hello
3557        let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
3558        let (resp_hello, _) = tokio::join!(fut_hello, async {
3559            if let Some(mut envelope) = rx_a.recv().await {
3560                envelope.exchange.input.body =
3561                    camel_component_api::Body::Text("hello-response".to_string());
3562                if let Some(reply_tx) = envelope.reply_tx {
3563                    let _ = reply_tx.send(Ok(envelope.exchange));
3564                }
3565            }
3566        });
3567
3568        // Request to /world
3569        let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
3570        let (resp_world, _) = tokio::join!(fut_world, async {
3571            if let Some(mut envelope) = rx_b.recv().await {
3572                envelope.exchange.input.body =
3573                    camel_component_api::Body::Text("world-response".to_string());
3574                if let Some(reply_tx) = envelope.reply_tx {
3575                    let _ = reply_tx.send(Ok(envelope.exchange));
3576                }
3577            }
3578        });
3579
3580        let body_a = resp_hello.unwrap().text().await.unwrap();
3581        let body_b = resp_world.unwrap().text().await.unwrap();
3582
3583        assert_eq!(body_a, "hello-response");
3584        assert_eq!(body_b, "world-response");
3585
3586        token_a.cancel();
3587        token_b.cancel();
3588    }
3589
3590    #[tokio::test]
3591    async fn test_integration_unregistered_path_returns_404() {
3592        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
3593
3594        // Get an OS-assigned free port (ephemeral)
3595        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3596        let port = listener.local_addr().unwrap().port();
3597        drop(listener);
3598
3599        let component = HttpComponent::new();
3600        let endpoint_ctx = NoOpComponentContext;
3601        let endpoint = component
3602            .create_endpoint(
3603                &format!("http://127.0.0.1:{port}/registered"),
3604                &endpoint_ctx,
3605            )
3606            .unwrap();
3607        let mut consumer = endpoint.create_consumer(rt()).unwrap();
3608
3609        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3610        let token = tokio_util::sync::CancellationToken::new();
3611        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3612
3613        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
3614
3615        // Wait until the server is actually accepting connections (CI runners can be slow).
3616        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
3617        loop {
3618            if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
3619                .await
3620                .is_ok()
3621            {
3622                break;
3623            }
3624            if std::time::Instant::now() >= deadline {
3625                panic!("HTTP server did not start within 5s on port {port}");
3626            }
3627            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3628        }
3629
3630        let client = reqwest::Client::new();
3631        let resp = client
3632            .get(format!("http://127.0.0.1:{port}/not-there"))
3633            .send()
3634            .await
3635            .unwrap();
3636        assert_eq!(resp.status().as_u16(), 404);
3637
3638        token.cancel();
3639    }
3640
3641    #[test]
3642    fn test_http_consumer_declares_concurrent() {
3643        use camel_component_api::ConcurrencyModel;
3644
3645        let config = HttpServerConfig {
3646            host: "127.0.0.1".to_string(),
3647            port: 19999,
3648            path: "/test".to_string(),
3649            max_request_body: 2 * 1024 * 1024,
3650            max_response_body: 10 * 1024 * 1024,
3651            max_inflight_requests: 1024,
3652        };
3653        let consumer = HttpConsumer::new(config, test_rt());
3654        assert_eq!(
3655            consumer.concurrency_model(),
3656            ConcurrencyModel::Concurrent { max: None }
3657        );
3658    }
3659
3660    // -----------------------------------------------------------------------
3661    // HttpReplyBody streaming tests
3662    // -----------------------------------------------------------------------
3663
3664    #[tokio::test]
3665    async fn test_http_reply_body_stream_variant_exists() {
3666        use bytes::Bytes;
3667        use camel_component_api::CamelError;
3668        use futures::stream;
3669
3670        let chunks: Vec<Result<Bytes, CamelError>> =
3671            vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
3672        let stream = Box::pin(stream::iter(chunks));
3673        let reply_body = HttpReplyBody::Stream(stream);
3674        // Si compila y el match funciona, el test pasa
3675        match reply_body {
3676            HttpReplyBody::Stream(_) => {}
3677            HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
3678        }
3679    }
3680
3681    // -----------------------------------------------------------------------
3682    // OpenTelemetry propagation tests (only compiled with "otel" feature)
3683    // -----------------------------------------------------------------------
3684
3685    #[cfg(feature = "otel")]
3686    mod otel_tests {
3687        use super::*;
3688        use camel_component_api::Message;
3689        use tower::ServiceExt;
3690
3691        #[tokio::test]
3692        async fn test_producer_injects_traceparent_header() {
3693            let (url, _handle) = start_test_server_with_header_capture().await;
3694            let ctx = test_producer_ctx();
3695
3696            let component = HttpComponent::new();
3697            let endpoint_ctx = NoOpComponentContext;
3698            let endpoint = component
3699                .create_endpoint(&format!("{url}/api?allowPrivateIps=true"), &endpoint_ctx)
3700                .unwrap();
3701            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3702
3703            // Create exchange with an OTel context by extracting from a traceparent header
3704            let mut exchange = Exchange::new(Message::default());
3705            let mut headers = std::collections::HashMap::new();
3706            headers.insert(
3707                "traceparent".to_string(),
3708                "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
3709            );
3710            camel_otel::extract_into_exchange(&mut exchange, &headers);
3711
3712            let result = producer.oneshot(exchange).await.unwrap();
3713
3714            // Verify request succeeded
3715            let status = result
3716                .input
3717                .header("CamelHttpResponseCode")
3718                .and_then(|v| v.as_u64())
3719                .unwrap();
3720            assert_eq!(status, 200);
3721
3722            // The test server echoes back the received traceparent header
3723            let traceparent = result.input.header("X-Received-Traceparent");
3724            assert!(
3725                traceparent.is_some(),
3726                "traceparent header should have been sent"
3727            );
3728
3729            let traceparent_str = traceparent.unwrap().as_str().unwrap();
3730            // Verify format: version-traceid-spanid-flags
3731            let parts: Vec<&str> = traceparent_str.split('-').collect();
3732            assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
3733            assert_eq!(parts[0], "00", "version should be 00");
3734            assert_eq!(
3735                parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
3736                "trace-id should match"
3737            );
3738            assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
3739            assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
3740        }
3741
3742        #[tokio::test]
3743        async fn test_consumer_extracts_traceparent_header() {
3744            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
3745
3746            // Get an OS-assigned free port
3747            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3748            let port = listener.local_addr().unwrap().port();
3749            drop(listener);
3750
3751            let component = HttpComponent::new();
3752            let endpoint_ctx = NoOpComponentContext;
3753            let endpoint = component
3754                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
3755                .unwrap();
3756            let mut consumer = endpoint.create_consumer(rt()).unwrap();
3757
3758            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3759            let token = tokio_util::sync::CancellationToken::new();
3760            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3761
3762            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
3763            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3764
3765            // Send request with traceparent header
3766            let client = reqwest::Client::new();
3767            let send_fut = client
3768                .post(format!("http://127.0.0.1:{port}/trace"))
3769                .header(
3770                    "traceparent",
3771                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
3772                )
3773                .body("test")
3774                .send();
3775
3776            let (http_result, _) = tokio::join!(send_fut, async {
3777                if let Some(envelope) = rx.recv().await {
3778                    // Verify the exchange has a valid OTel context by re-injecting it
3779                    // and checking the traceparent matches
3780                    let mut injected_headers = std::collections::HashMap::new();
3781                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
3782
3783                    assert!(
3784                        injected_headers.contains_key("traceparent"),
3785                        "Exchange should have traceparent after extraction"
3786                    );
3787
3788                    let traceparent = injected_headers.get("traceparent").unwrap();
3789                    let parts: Vec<&str> = traceparent.split('-').collect();
3790                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
3791                    assert_eq!(
3792                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
3793                        "Trace ID should match the original traceparent header"
3794                    );
3795
3796                    if let Some(reply_tx) = envelope.reply_tx {
3797                        let _ = reply_tx.send(Ok(envelope.exchange));
3798                    }
3799                }
3800            });
3801
3802            let resp = http_result.unwrap();
3803            assert_eq!(resp.status().as_u16(), 200);
3804
3805            token.cancel();
3806        }
3807
3808        #[tokio::test]
3809        async fn test_consumer_extracts_mixed_case_traceparent_header() {
3810            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
3811
3812            // Get an OS-assigned free port
3813            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3814            let port = listener.local_addr().unwrap().port();
3815            drop(listener);
3816
3817            let component = HttpComponent::new();
3818            let endpoint_ctx = NoOpComponentContext;
3819            let endpoint = component
3820                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
3821                .unwrap();
3822            let mut consumer = endpoint.create_consumer(rt()).unwrap();
3823
3824            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3825            let token = tokio_util::sync::CancellationToken::new();
3826            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3827
3828            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
3829            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3830
3831            // Send request with MIXED-CASE TraceParent header (not lowercase)
3832            let client = reqwest::Client::new();
3833            let send_fut = client
3834                .post(format!("http://127.0.0.1:{port}/trace"))
3835                .header(
3836                    "TraceParent",
3837                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
3838                )
3839                .body("test")
3840                .send();
3841
3842            let (http_result, _) = tokio::join!(send_fut, async {
3843                if let Some(envelope) = rx.recv().await {
3844                    // Verify the exchange has a valid OTel context by re-injecting it
3845                    // and checking the traceparent matches
3846                    let mut injected_headers = HashMap::new();
3847                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
3848
3849                    assert!(
3850                        injected_headers.contains_key("traceparent"),
3851                        "Exchange should have traceparent after extraction from mixed-case header"
3852                    );
3853
3854                    let traceparent = injected_headers.get("traceparent").unwrap();
3855                    let parts: Vec<&str> = traceparent.split('-').collect();
3856                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
3857                    assert_eq!(
3858                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
3859                        "Trace ID should match the original mixed-case TraceParent header"
3860                    );
3861
3862                    if let Some(reply_tx) = envelope.reply_tx {
3863                        let _ = reply_tx.send(Ok(envelope.exchange));
3864                    }
3865                }
3866            });
3867
3868            let resp = http_result.unwrap();
3869            assert_eq!(resp.status().as_u16(), 200);
3870
3871            token.cancel();
3872        }
3873
3874        #[tokio::test]
3875        async fn test_producer_no_trace_context_no_crash() {
3876            let (url, _handle) = start_test_server().await;
3877            let ctx = test_producer_ctx();
3878
3879            let component = HttpComponent::new();
3880            let endpoint_ctx = NoOpComponentContext;
3881            let endpoint = component
3882                .create_endpoint(&format!("{url}/api?allowPrivateIps=true"), &endpoint_ctx)
3883                .unwrap();
3884            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3885
3886            // Create exchange with default (empty) otel_context - no trace context
3887            let exchange = Exchange::new(Message::default());
3888
3889            // Should succeed without panic
3890            let result = producer.oneshot(exchange).await.unwrap();
3891
3892            // Verify request succeeded
3893            let status = result
3894                .input
3895                .header("CamelHttpResponseCode")
3896                .and_then(|v| v.as_u64())
3897                .unwrap();
3898            assert_eq!(status, 200);
3899        }
3900
3901        /// Test server that captures and echoes back the traceparent header
3902        async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
3903            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3904            let addr = listener.local_addr().unwrap();
3905            let url = format!("http://127.0.0.1:{}", addr.port());
3906
3907            let handle = tokio::spawn(async move {
3908                loop {
3909                    if let Ok((mut stream, _)) = listener.accept().await {
3910                        tokio::spawn(async move {
3911                            use tokio::io::{AsyncReadExt, AsyncWriteExt};
3912                            let mut buf = vec![0u8; 8192];
3913                            let n = stream.read(&mut buf).await.unwrap_or(0);
3914                            let request = String::from_utf8_lossy(&buf[..n]).to_string();
3915
3916                            // Extract traceparent header from request
3917                            let traceparent = request
3918                                .lines()
3919                                .find(|line| line.to_lowercase().starts_with("traceparent:"))
3920                                .map(|line| {
3921                                    line.split(':')
3922                                        .nth(1)
3923                                        .map(|s| s.trim().to_string())
3924                                        .unwrap_or_default()
3925                                })
3926                                .unwrap_or_default();
3927
3928                            let body =
3929                                format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
3930                            let response = format!(
3931                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
3932                                body.len(),
3933                                traceparent,
3934                                body
3935                            );
3936                            let _ = stream.write_all(response.as_bytes()).await;
3937                        });
3938                    }
3939                }
3940            });
3941
3942            (url, handle)
3943        }
3944    }
3945
3946    // -----------------------------------------------------------------------
3947    // Response streaming tests (Eje A - Task 2)
3948    // -----------------------------------------------------------------------
3949
3950    // -----------------------------------------------------------------------
3951    // Request streaming tests (Eje B - Task 3)
3952    // -----------------------------------------------------------------------
3953
3954    #[tokio::test]
3955    async fn test_request_body_arrives_as_stream() {
3956        use camel_component_api::Body;
3957        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
3958
3959        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3960        let port = listener.local_addr().unwrap().port();
3961        drop(listener);
3962
3963        let component = HttpComponent::new();
3964        let endpoint_ctx = NoOpComponentContext;
3965        let endpoint = component
3966            .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
3967            .unwrap();
3968        let mut consumer = endpoint.create_consumer(rt()).unwrap();
3969
3970        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
3971        let token = tokio_util::sync::CancellationToken::new();
3972        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3973
3974        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
3975        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3976
3977        let client = reqwest::Client::new();
3978        let send_fut = client
3979            .post(format!("http://127.0.0.1:{port}/upload"))
3980            .body("hello streaming world")
3981            .send();
3982
3983        let (http_result, _) = tokio::join!(send_fut, async {
3984            if let Some(mut envelope) = rx.recv().await {
3985                // Body must be Body::Stream, not Body::Text or Body::Bytes
3986                assert!(
3987                    matches!(envelope.exchange.input.body, Body::Stream(_)),
3988                    "expected Body::Stream, got discriminant {:?}",
3989                    std::mem::discriminant(&envelope.exchange.input.body)
3990                );
3991                // Materialize to verify content
3992                let bytes = envelope
3993                    .exchange
3994                    .input
3995                    .body
3996                    .into_bytes(1024 * 1024)
3997                    .await
3998                    .unwrap();
3999                assert_eq!(&bytes[..], b"hello streaming world");
4000
4001                envelope.exchange.input.body = camel_component_api::Body::Empty;
4002                if let Some(reply_tx) = envelope.reply_tx {
4003                    let _ = reply_tx.send(Ok(envelope.exchange));
4004                }
4005            }
4006        });
4007
4008        let resp = http_result.unwrap();
4009        assert_eq!(resp.status().as_u16(), 200);
4010
4011        token.cancel();
4012    }
4013
4014    // -----------------------------------------------------------------------
4015    // Response streaming tests (Eje A - Task 2)
4016    // -----------------------------------------------------------------------
4017
4018    #[tokio::test]
4019    async fn test_streaming_response_chunked() {
4020        use bytes::Bytes;
4021        use camel_component_api::Body;
4022        use camel_component_api::CamelError;
4023        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4024        use camel_component_api::{StreamBody, StreamMetadata};
4025        use futures::stream;
4026        use std::sync::Arc;
4027        use tokio::sync::Mutex;
4028
4029        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4030        let port = listener.local_addr().unwrap().port();
4031        drop(listener);
4032
4033        let component = HttpComponent::new();
4034        let endpoint_ctx = NoOpComponentContext;
4035        let endpoint = component
4036            .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
4037            .unwrap();
4038        let mut consumer = endpoint.create_consumer(rt()).unwrap();
4039
4040        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4041        let token = tokio_util::sync::CancellationToken::new();
4042        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4043
4044        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4045        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4046
4047        let client = reqwest::Client::new();
4048        let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
4049
4050        let (http_result, _) = tokio::join!(send_fut, async {
4051            if let Some(mut envelope) = rx.recv().await {
4052                // Respond with Body::Stream
4053                let chunks: Vec<Result<Bytes, CamelError>> =
4054                    vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
4055                let stream = Box::pin(stream::iter(chunks));
4056                envelope.exchange.input.body = Body::Stream(StreamBody {
4057                    stream: Arc::new(Mutex::new(Some(stream))),
4058                    metadata: StreamMetadata::default(),
4059                });
4060                if let Some(reply_tx) = envelope.reply_tx {
4061                    let _ = reply_tx.send(Ok(envelope.exchange));
4062                }
4063            }
4064        });
4065
4066        let resp = http_result.unwrap();
4067        assert_eq!(resp.status().as_u16(), 200);
4068        let body = resp.text().await.unwrap();
4069        assert_eq!(body, "chunk1chunk2");
4070
4071        token.cancel();
4072    }
4073
4074    // -----------------------------------------------------------------------
4075    // 413 Content-Length limit test (Task 4)
4076    // -----------------------------------------------------------------------
4077
4078    #[tokio::test]
4079    async fn test_413_when_content_length_exceeds_limit() {
4080        use camel_component_api::ConsumerContext;
4081
4082        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4083        let port = listener.local_addr().unwrap().port();
4084        drop(listener);
4085
4086        // maxRequestBody=100 — any request declaring more than 100 bytes must get 413
4087        let component = HttpComponent::new();
4088        let endpoint_ctx = NoOpComponentContext;
4089        let endpoint = component
4090            .create_endpoint(
4091                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
4092                &endpoint_ctx,
4093            )
4094            .unwrap();
4095        let mut consumer = endpoint.create_consumer(rt()).unwrap();
4096
4097        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4098        let token = tokio_util::sync::CancellationToken::new();
4099        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4100
4101        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4102        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4103
4104        let client = reqwest::Client::new();
4105        let resp = client
4106            .post(format!("http://127.0.0.1:{port}/upload"))
4107            .header("Content-Length", "1000") // declares 1000 bytes, limit is 100
4108            .body("x".repeat(1000))
4109            .send()
4110            .await
4111            .unwrap();
4112
4113        assert_eq!(resp.status().as_u16(), 413);
4114
4115        token.cancel();
4116    }
4117
4118    /// Chunked upload without Content-Length header must NOT be rejected by maxRequestBody.
4119    /// The spec says: "If there is no Content-Length, the limit does not apply at the
4120    /// consumer level — the route is responsible."
4121    #[tokio::test]
4122    async fn test_chunked_upload_without_content_length_bypasses_limit() {
4123        use bytes::Bytes;
4124        use camel_component_api::Body;
4125        use camel_component_api::ConsumerContext;
4126        use futures::stream;
4127
4128        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4129        let port = listener.local_addr().unwrap().port();
4130        drop(listener);
4131
4132        // maxRequestBody=10 — very small limit; chunked uploads have no Content-Length
4133        let component = HttpComponent::new();
4134        let endpoint_ctx = NoOpComponentContext;
4135        let endpoint = component
4136            .create_endpoint(
4137                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
4138                &endpoint_ctx,
4139            )
4140            .unwrap();
4141        let mut consumer = endpoint.create_consumer(rt()).unwrap();
4142
4143        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4144        let token = tokio_util::sync::CancellationToken::new();
4145        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4146
4147        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4148        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4149
4150        let client = reqwest::Client::new();
4151
4152        // Use wrap_stream so reqwest sends chunked transfer encoding WITHOUT a
4153        // Content-Length header. 100 bytes exceeds the 10-byte maxRequestBody limit,
4154        // but since there's no Content-Length the 413 check must NOT fire.
4155        let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
4156            Ok(Bytes::from("y".repeat(50))),
4157            Ok(Bytes::from("y".repeat(50))),
4158        ];
4159        let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
4160        let send_fut = client
4161            .post(format!("http://127.0.0.1:{port}/upload"))
4162            .body(stream_body)
4163            .send();
4164
4165        let consumer_fut = async {
4166            // Use timeout to avoid deadlock if the handler rejects before enqueueing
4167            match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
4168                Ok(Some(mut envelope)) => {
4169                    assert!(
4170                        matches!(envelope.exchange.input.body, Body::Stream(_)),
4171                        "expected Body::Stream"
4172                    );
4173                    envelope.exchange.input.body = camel_component_api::Body::Empty;
4174                    if let Some(reply_tx) = envelope.reply_tx {
4175                        let _ = reply_tx.send(Ok(envelope.exchange));
4176                    }
4177                }
4178                Ok(None) => panic!("consumer channel closed unexpectedly"),
4179                Err(_) => {
4180                    // Timeout: the request was rejected before reaching the consumer.
4181                    // The HTTP response will carry the real status code (we check below).
4182                }
4183            }
4184        };
4185
4186        let (http_result, _) = tokio::join!(send_fut, consumer_fut);
4187
4188        let resp = http_result.unwrap();
4189        // Must NOT be 413; chunked uploads without Content-Length bypass the limit.
4190        assert_ne!(
4191            resp.status().as_u16(),
4192            413,
4193            "chunked upload must not be rejected by maxRequestBody"
4194        );
4195        assert_eq!(resp.status().as_u16(), 200);
4196
4197        token.cancel();
4198    }
4199
4200    #[test]
4201    fn test_validate_url_for_ssrf_blocks_and_allows_hosts() {
4202        let mut cfg = HttpEndpointConfig::from_uri("http://example.com").unwrap();
4203        cfg.blocked_hosts = vec!["blocked.local".to_string()];
4204        cfg.allow_private_ips = false;
4205
4206        let blocked = validate_url_for_ssrf("http://blocked.local/api", &cfg);
4207        assert!(blocked.is_err());
4208
4209        let private_ip = validate_url_for_ssrf("http://127.0.0.1/api", &cfg);
4210        assert!(private_ip.is_err());
4211
4212        cfg.allow_private_ips = true;
4213        let allowed = validate_url_for_ssrf("http://127.0.0.1/api", &cfg);
4214        assert!(allowed.is_ok());
4215    }
4216
4217    #[test]
4218    fn test_is_private_ip_ranges() {
4219        assert!(is_private_ip(&"10.0.0.1".parse().unwrap())); // allow-unwrap
4220        assert!(is_private_ip(&"172.16.1.10".parse().unwrap())); // allow-unwrap
4221        assert!(is_private_ip(&"192.168.1.1".parse().unwrap())); // allow-unwrap
4222        assert!(is_private_ip(&"127.0.0.1".parse().unwrap())); // allow-unwrap
4223        assert!(is_private_ip(&"169.254.1.1".parse().unwrap())); // allow-unwrap
4224        assert!(is_private_ip(&"0.1.2.3".parse().unwrap())); // allow-unwrap
4225
4226        assert!(is_private_ip(&"::1".parse().unwrap())); // allow-unwrap
4227        assert!(is_private_ip(&"fc00::1".parse().unwrap())); // allow-unwrap
4228        assert!(is_private_ip(&"fd12::1".parse().unwrap())); // allow-unwrap
4229        assert!(is_private_ip(&"fe80::1".parse().unwrap())); // allow-unwrap
4230        // ::ffff:0:0/96 (IPv4-mapped): only private if the mapped IPv4 is private
4231        assert!(is_private_ip(&"::ffff:10.0.0.1".parse().unwrap())); // allow-unwrap
4232        assert!(is_private_ip(&"::ffff:192.168.1.1".parse().unwrap())); // allow-unwrap
4233        assert!(is_private_ip(&"::ffff:127.0.0.1".parse().unwrap())); // allow-unwrap
4234
4235        assert!(!is_private_ip(&"8.8.8.8".parse().unwrap())); // allow-unwrap
4236        assert!(!is_private_ip(&"::ffff:8.8.8.8".parse().unwrap())); // allow-unwrap — public IPv4-mapped
4237        assert!(!is_private_ip(&"2001:4860:4860::8888".parse().unwrap())); // allow-unwrap
4238    }
4239
4240    #[tokio::test]
4241    async fn test_validate_resolved_host_for_ssrf_blocks_resolved_private_ip() {
4242        let mut cfg = HttpEndpointConfig::from_uri("http://example.com").unwrap(); // allow-unwrap
4243        cfg.allow_private_ips = false;
4244
4245        let err = validate_resolved_host_for_ssrf("http://localhost", &cfg)
4246            .await
4247            .expect_err("localhost must resolve to loopback and be blocked");
4248
4249        let msg = err.to_string();
4250        assert!(msg.contains("Target resolved to private IP"));
4251    }
4252
4253    #[test]
4254    fn test_title_case_header() {
4255        assert_eq!(title_case_header("content-type"), "Content-Type");
4256        assert_eq!(title_case_header("authorization"), "Authorization");
4257        assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
4258        assert_eq!(title_case_header("host"), "Host");
4259        assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
4260        assert_eq!(title_case_header("single"), "Single");
4261        assert_eq!(title_case_header(""), "");
4262    }
4263
4264    #[test]
4265    fn test_resolve_url_combines_path_and_query_sources() {
4266        let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
4267        let mut exchange = Exchange::new(Message::default());
4268        exchange.input.set_header(
4269            "CamelHttpPath",
4270            serde_json::Value::String("next".to_string()),
4271        );
4272        let url = HttpProducer::resolve_url(&exchange, &cfg);
4273        assert!(url.starts_with("http://example.com/base/next?"));
4274        assert!(url.contains("foo=bar"));
4275
4276        exchange.input.set_header(
4277            "CamelHttpUri",
4278            serde_json::Value::String("http://other.test/root".to_string()),
4279        );
4280        exchange.input.set_header(
4281            "CamelHttpQuery",
4282            serde_json::Value::String("a=1&b=2".to_string()),
4283        );
4284
4285        let override_url = HttpProducer::resolve_url(&exchange, &cfg);
4286        assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
4287    }
4288
4289    #[test]
4290    fn test_http_producer_helpers_status_and_size_boundaries() {
4291        assert!(HttpProducer::is_ok_status(200, (200, 299)));
4292        assert!(HttpProducer::is_ok_status(299, (200, 299)));
4293        assert!(!HttpProducer::is_ok_status(199, (200, 299)));
4294        assert!(!HttpProducer::is_ok_status(300, (200, 299)));
4295
4296        assert!(!exceeds_max_response_body(10, 10));
4297        assert!(exceeds_max_response_body(11, 10));
4298    }
4299
4300    // -----------------------------------------------------------------------
4301    // Content-Type inference tests
4302    // -----------------------------------------------------------------------
4303
4304    async fn setup_consumer_on_free_port(
4305        path: &str,
4306    ) -> (
4307        u16,
4308        tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
4309        tokio_util::sync::CancellationToken,
4310    ) {
4311        use camel_component_api::ConsumerContext;
4312
4313        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4314        let port = listener.local_addr().unwrap().port();
4315        drop(listener);
4316
4317        let consumer_cfg = HttpServerConfig {
4318            host: "127.0.0.1".to_string(),
4319            port,
4320            path: path.to_string(),
4321            max_request_body: 2 * 1024 * 1024,
4322            max_response_body: 10 * 1024 * 1024,
4323            max_inflight_requests: 1024,
4324        };
4325        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4326
4327        let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4328        let token = tokio_util::sync::CancellationToken::new();
4329        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4330
4331        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4332        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4333
4334        (port, rx, token)
4335    }
4336
4337    #[tokio::test]
4338    async fn test_content_type_inferred_for_json_body() {
4339        let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
4340
4341        let client = reqwest::Client::new();
4342        let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
4343
4344        let (http_result, _) = tokio::join!(send_fut, async {
4345            if let Some(mut envelope) = rx.recv().await {
4346                envelope.exchange.input.body =
4347                    camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
4348                if let Some(reply_tx) = envelope.reply_tx {
4349                    let _ = reply_tx.send(Ok(envelope.exchange));
4350                }
4351            }
4352        });
4353
4354        let resp = http_result.unwrap();
4355        assert_eq!(resp.status().as_u16(), 200);
4356        let ct = resp
4357            .headers()
4358            .get("content-type")
4359            .expect("Content-Type header should be present");
4360        assert_eq!(ct, "application/json");
4361        let body = resp.text().await.unwrap();
4362        assert_eq!(body, r#"{"message":"hello"}"#);
4363
4364        token.cancel();
4365    }
4366
4367    #[tokio::test]
4368    async fn test_content_type_inferred_for_text_body() {
4369        let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
4370
4371        let client = reqwest::Client::new();
4372        let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
4373
4374        let (http_result, _) = tokio::join!(send_fut, async {
4375            if let Some(mut envelope) = rx.recv().await {
4376                envelope.exchange.input.body =
4377                    camel_component_api::Body::Text("plain text response".to_string());
4378                if let Some(reply_tx) = envelope.reply_tx {
4379                    let _ = reply_tx.send(Ok(envelope.exchange));
4380                }
4381            }
4382        });
4383
4384        let resp = http_result.unwrap();
4385        assert_eq!(resp.status().as_u16(), 200);
4386        let ct = resp
4387            .headers()
4388            .get("content-type")
4389            .expect("Content-Type header should be present");
4390        assert_eq!(ct, "text/plain; charset=utf-8");
4391        let body = resp.text().await.unwrap();
4392        assert_eq!(body, "plain text response");
4393
4394        token.cancel();
4395    }
4396
4397    #[tokio::test]
4398    async fn test_content_type_inferred_for_xml_body() {
4399        let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
4400
4401        let client = reqwest::Client::new();
4402        let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
4403
4404        let (http_result, _) = tokio::join!(send_fut, async {
4405            if let Some(mut envelope) = rx.recv().await {
4406                envelope.exchange.input.body =
4407                    camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
4408                if let Some(reply_tx) = envelope.reply_tx {
4409                    let _ = reply_tx.send(Ok(envelope.exchange));
4410                }
4411            }
4412        });
4413
4414        let resp = http_result.unwrap();
4415        assert_eq!(resp.status().as_u16(), 200);
4416        let ct = resp
4417            .headers()
4418            .get("content-type")
4419            .expect("Content-Type header should be present");
4420        assert_eq!(ct, "application/xml");
4421        let body = resp.text().await.unwrap();
4422        assert_eq!(body, "<root><item>value</item></root>");
4423
4424        token.cancel();
4425    }
4426
4427    #[tokio::test]
4428    async fn test_no_content_type_for_empty_body() {
4429        let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
4430
4431        let client = reqwest::Client::new();
4432        let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
4433
4434        let (http_result, _) = tokio::join!(send_fut, async {
4435            if let Some(mut envelope) = rx.recv().await {
4436                envelope.exchange.input.body = camel_component_api::Body::Empty;
4437                if let Some(reply_tx) = envelope.reply_tx {
4438                    let _ = reply_tx.send(Ok(envelope.exchange));
4439                }
4440            }
4441        });
4442
4443        let resp = http_result.unwrap();
4444        assert_eq!(resp.status().as_u16(), 200);
4445        assert!(
4446            resp.headers().get("content-type").is_none(),
4447            "Empty body should not set Content-Type"
4448        );
4449
4450        token.cancel();
4451    }
4452
4453    #[tokio::test]
4454    async fn test_no_content_type_for_raw_bytes_body() {
4455        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
4456
4457        let client = reqwest::Client::new();
4458        let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
4459
4460        let (http_result, _) = tokio::join!(send_fut, async {
4461            if let Some(mut envelope) = rx.recv().await {
4462                envelope.exchange.input.body =
4463                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
4464                if let Some(reply_tx) = envelope.reply_tx {
4465                    let _ = reply_tx.send(Ok(envelope.exchange));
4466                }
4467            }
4468        });
4469
4470        let resp = http_result.unwrap();
4471        assert_eq!(resp.status().as_u16(), 200);
4472        assert!(
4473            resp.headers().get("content-type").is_none(),
4474            "Raw Bytes body should not set Content-Type"
4475        );
4476
4477        token.cancel();
4478    }
4479
4480    #[tokio::test]
4481    async fn test_content_type_from_stream_metadata() {
4482        use camel_component_api::{StreamBody, StreamMetadata};
4483        use futures::stream;
4484
4485        let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
4486
4487        let client = reqwest::Client::new();
4488        let send_fut = client
4489            .get(format!("http://127.0.0.1:{port}/stream-ct"))
4490            .send();
4491
4492        let (http_result, _) = tokio::join!(send_fut, async {
4493            if let Some(mut envelope) = rx.recv().await {
4494                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
4495                    vec![Ok(bytes::Bytes::from("audio data"))];
4496                let stream = Box::pin(stream::iter(chunks));
4497                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
4498                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
4499                    metadata: StreamMetadata {
4500                        size_hint: None,
4501                        content_type: Some("audio/mpeg".to_string()),
4502                        origin: None,
4503                    },
4504                });
4505                if let Some(reply_tx) = envelope.reply_tx {
4506                    let _ = reply_tx.send(Ok(envelope.exchange));
4507                }
4508            }
4509        });
4510
4511        let resp = http_result.unwrap();
4512        assert_eq!(resp.status().as_u16(), 200);
4513        let ct = resp
4514            .headers()
4515            .get("content-type")
4516            .expect("Content-Type header should be present");
4517        assert_eq!(ct, "audio/mpeg");
4518        let body = resp.text().await.unwrap();
4519        assert_eq!(body, "audio data");
4520
4521        token.cancel();
4522    }
4523
4524    #[tokio::test]
4525    async fn test_user_content_type_overrides_inferred() {
4526        let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
4527
4528        let client = reqwest::Client::new();
4529        let send_fut = client
4530            .get(format!("http://127.0.0.1:{port}/override-ct"))
4531            .send();
4532
4533        let (http_result, _) = tokio::join!(send_fut, async {
4534            if let Some(mut envelope) = rx.recv().await {
4535                envelope.exchange.input.body =
4536                    camel_component_api::Body::Json(serde_json::json!({"ok": true}));
4537                envelope.exchange.input.set_header(
4538                    "Content-Type",
4539                    serde_json::Value::String("text/html".to_string()),
4540                );
4541                if let Some(reply_tx) = envelope.reply_tx {
4542                    let _ = reply_tx.send(Ok(envelope.exchange));
4543                }
4544            }
4545        });
4546
4547        let resp = http_result.unwrap();
4548        assert_eq!(resp.status().as_u16(), 200);
4549        let ct = resp
4550            .headers()
4551            .get("content-type")
4552            .expect("Content-Type header should be present");
4553        assert_eq!(
4554            ct, "text/html",
4555            "User-set Content-Type should take precedence over inferred type"
4556        );
4557
4558        token.cancel();
4559    }
4560
4561    #[tokio::test]
4562    async fn test_user_content_type_with_bytes_body() {
4563        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
4564
4565        let client = reqwest::Client::new();
4566        let send_fut = client
4567            .get(format!("http://127.0.0.1:{port}/bytes-ct"))
4568            .send();
4569
4570        let (http_result, _) = tokio::join!(send_fut, async {
4571            if let Some(mut envelope) = rx.recv().await {
4572                envelope.exchange.input.body =
4573                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
4574                envelope.exchange.input.set_header(
4575                    "Content-Type",
4576                    serde_json::Value::String("application/json".to_string()),
4577                );
4578                if let Some(reply_tx) = envelope.reply_tx {
4579                    let _ = reply_tx.send(Ok(envelope.exchange));
4580                }
4581            }
4582        });
4583
4584        let resp = http_result.unwrap();
4585        assert_eq!(resp.status().as_u16(), 200);
4586        let ct = resp
4587            .headers()
4588            .get("content-type")
4589            .expect("Content-Type header should be present for Bytes body with user header");
4590        assert_eq!(
4591            ct, "application/json",
4592            "User Content-Type should be sent for Bytes body"
4593        );
4594
4595        token.cancel();
4596    }
4597
4598    // -----------------------------------------------------------------------
4599    // Server monitor tests (GRL-005)
4600    // -----------------------------------------------------------------------
4601
4602    #[tokio::test]
4603    async fn monitor_task_silent_on_clean_exit() {
4604        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
4605        // Clean exit should complete without panicking or logging errors
4606        monitor_axum_task(
4607            handle,
4608            "127.0.0.1:0".to_string(),
4609            noop_rt(),
4610            "test-monitor".into(),
4611        )
4612        .await;
4613    }
4614
4615    #[tokio::test]
4616    async fn monitor_task_handles_panicked_task() {
4617        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
4618            panic!("simulated server crash");
4619        });
4620        // Should complete without panicking even though the inner task panicked
4621        monitor_axum_task(
4622            handle,
4623            "127.0.0.1:9999".to_string(),
4624            noop_rt(),
4625            "test-monitor".into(),
4626        )
4627        .await;
4628    }
4629
4630    // -----------------------------------------------------------------------
4631    // Credential redaction tests
4632    // -----------------------------------------------------------------------
4633
4634    #[test]
4635    fn http_auth_basic_debug_redacts_password() {
4636        let auth = HttpAuth::Basic {
4637            username: "admin".to_string(),
4638            password: "hunter2".to_string(),
4639        };
4640        let debug = format!("{:?}", auth);
4641        assert!(
4642            !debug.contains("hunter2"),
4643            "password must be redacted: {debug}"
4644        );
4645        assert!(debug.contains("admin"), "username should appear: {debug}");
4646    }
4647
4648    #[test]
4649    fn http_auth_bearer_debug_redacts_token() {
4650        let auth = HttpAuth::Bearer {
4651            token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
4652        };
4653        let debug = format!("{:?}", auth);
4654        assert!(
4655            !debug.contains("eyJhbGci"),
4656            "token must be redacted: {debug}"
4657        );
4658    }
4659
4660    #[test]
4661    fn http_auth_none_debug_shows_variant() {
4662        let debug = format!("{:?}", HttpAuth::None);
4663        assert!(
4664            debug.contains("None"),
4665            "None variant should appear: {debug}"
4666        );
4667    }
4668
4669    #[test]
4670    fn http_endpoint_config_debug_redacts_auth_credentials() {
4671        let config = HttpEndpointConfig::from_uri(
4672            "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
4673        )
4674        .unwrap();
4675        let debug = format!("{:?}", config);
4676        assert!(
4677            !debug.contains("secret123"),
4678            "password must be redacted in HttpEndpointConfig debug: {debug}"
4679        );
4680    }
4681
4682    // -----------------------------------------------------------------------
4683    // Static file serving tests (Task 5)
4684    // -----------------------------------------------------------------------
4685
4686    use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
4687    use tower_http::services::ServeDir;
4688
4689    fn make_test_registry() -> HttpRouteRegistry {
4690        HttpRouteRegistry::new()
4691    }
4692
4693    fn make_test_state(registry: HttpRouteRegistry) -> AppState {
4694        AppState {
4695            registry,
4696            max_request_body: 2 * 1024 * 1024,
4697            max_response_body: 10 * 1024 * 1024,
4698            inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
4699        }
4700    }
4701
4702    #[allow(clippy::await_holding_lock)]
4703    #[tokio::test]
4704    async fn test_static_file_serving_serves_file_contents() {
4705        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4706        ServerRegistry::reset();
4707
4708        // Create temp dir with test files
4709        let temp_dir =
4710            std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
4711        std::fs::create_dir_all(&temp_dir).unwrap();
4712        std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
4713        std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
4714
4715        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
4716
4717        let registry = make_test_registry();
4718        let serve_dir = ServeDir::new(&canonical_dir)
4719            .precompressed_gzip()
4720            .precompressed_br()
4721            .append_index_html_on_directories(true);
4722
4723        let mount = StaticMount {
4724            mount_path: "/".to_string(),
4725            mode: MountMode::Static,
4726            dir: canonical_dir.clone(),
4727            cache_control: "public, max-age=3600".to_string(),
4728            error_pages: std::collections::HashMap::new(),
4729            serve_dir,
4730        };
4731        registry.register_static_mount(mount).await.unwrap();
4732
4733        let state = make_test_state(registry);
4734
4735        // Test serving hello.txt
4736        let req = Request::builder()
4737            .uri("/hello.txt")
4738            .body(AxumBody::empty())
4739            .unwrap();
4740        let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
4741        assert_eq!(resp.status(), StatusCode::OK);
4742        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
4743            .await
4744            .unwrap();
4745        assert_eq!(&body[..], b"Hello, static world!");
4746
4747        // Test serving style.css
4748        let req = Request::builder()
4749            .uri("/style.css")
4750            .body(AxumBody::empty())
4751            .unwrap();
4752        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
4753        assert_eq!(resp.status(), StatusCode::OK);
4754        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
4755            .await
4756            .unwrap();
4757        assert_eq!(&body[..], b"body { color: red; }");
4758
4759        // Test 404 for non-existent file
4760        let req = Request::builder()
4761            .uri("/missing.txt")
4762            .body(AxumBody::empty())
4763            .unwrap();
4764        let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
4765        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
4766
4767        // Cleanup
4768        std::fs::remove_dir_all(&temp_dir).ok();
4769    }
4770
4771    #[allow(clippy::await_holding_lock)]
4772    #[tokio::test]
4773    async fn test_spa_fallback_serves_index_for_unknown_paths() {
4774        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4775        ServerRegistry::reset();
4776
4777        let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
4778        std::fs::create_dir_all(&temp_dir).unwrap();
4779        std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
4780        std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
4781
4782        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
4783
4784        let registry = make_test_registry();
4785        let serve_dir = ServeDir::new(&canonical_dir)
4786            .precompressed_gzip()
4787            .precompressed_br()
4788            .append_index_html_on_directories(true);
4789
4790        let mount = StaticMount {
4791            mount_path: "/".to_string(),
4792            mode: MountMode::Spa,
4793            dir: canonical_dir.clone(),
4794            cache_control: "public, max-age=0".to_string(),
4795            error_pages: std::collections::HashMap::new(),
4796            serve_dir,
4797        };
4798        // Register as SPA mount
4799        registry.register_static_mount(mount).await.unwrap();
4800
4801        let state = make_test_state(registry);
4802
4803        // SPA fallback: GET /dashboard with Accept: text/html → index.html
4804        let req = Request::builder()
4805            .method("GET")
4806            .uri("/dashboard")
4807            .header("Accept", "text/html")
4808            .body(AxumBody::empty())
4809            .unwrap();
4810        let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
4811        assert_eq!(resp.status(), StatusCode::OK);
4812        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
4813            .await
4814            .unwrap();
4815        assert_eq!(&body[..], b"<h1>SPA App</h1>");
4816
4817        // Static file still works: GET /app.js
4818        let req = Request::builder()
4819            .method("GET")
4820            .uri("/app.js")
4821            .body(AxumBody::empty())
4822            .unwrap();
4823        let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
4824        assert_eq!(resp.status(), StatusCode::OK);
4825        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
4826            .await
4827            .unwrap();
4828        assert_eq!(&body[..], b"console.log('app')");
4829
4830        // No SPA fallback for JSON accept → 404
4831        let req = Request::builder()
4832            .method("GET")
4833            .uri("/api/data")
4834            .header("Accept", "application/json")
4835            .body(AxumBody::empty())
4836            .unwrap();
4837        let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
4838        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
4839
4840        // No SPA fallback for file extensions → 404
4841        let req = Request::builder()
4842            .method("GET")
4843            .uri("/style.css")
4844            .header("Accept", "text/html")
4845            .body(AxumBody::empty())
4846            .unwrap();
4847        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
4848        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
4849
4850        // Cleanup
4851        std::fs::remove_dir_all(&temp_dir).ok();
4852    }
4853
4854    #[allow(clippy::await_holding_lock)]
4855    #[tokio::test]
4856    async fn test_error_page_mapping_serves_custom_404() {
4857        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4858        ServerRegistry::reset();
4859
4860        let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
4861        let errors_dir = temp_dir.join("errors");
4862        std::fs::create_dir_all(&errors_dir).unwrap();
4863        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
4864        std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
4865
4866        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
4867        let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
4868
4869        let registry = make_test_registry();
4870        let serve_dir = ServeDir::new(&canonical_dir)
4871            .precompressed_gzip()
4872            .precompressed_br()
4873            .append_index_html_on_directories(true);
4874
4875        let mut error_pages = std::collections::HashMap::new();
4876        error_pages.insert(404, canonical_404);
4877
4878        let mount = StaticMount {
4879            mount_path: "/".to_string(),
4880            mode: MountMode::Static,
4881            dir: canonical_dir.clone(),
4882            cache_control: "public, max-age=0".to_string(),
4883            error_pages,
4884            serve_dir,
4885        };
4886        registry.register_static_mount(mount).await.unwrap();
4887
4888        let state = make_test_state(registry);
4889
4890        // Request non-existent file → custom 404 page
4891        let req = Request::builder()
4892            .method("GET")
4893            .uri("/missing.html")
4894            .body(AxumBody::empty())
4895            .unwrap();
4896        let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
4897        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
4898        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
4899            .await
4900            .unwrap();
4901        assert_eq!(&body[..], b"<h1>Custom 404</h1>");
4902
4903        // Existing file still works
4904        let req = Request::builder()
4905            .method("GET")
4906            .uri("/index.html")
4907            .body(AxumBody::empty())
4908            .unwrap();
4909        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
4910        assert_eq!(resp.status(), StatusCode::OK);
4911        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
4912            .await
4913            .unwrap();
4914        assert_eq!(&body[..], b"<h1>Home</h1>");
4915
4916        // Cleanup
4917        std::fs::remove_dir_all(&temp_dir).ok();
4918    }
4919
4920    #[tokio::test]
4921    async fn http_consumer_returns_body_and_code_on_stop() {
4922        use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
4923        use camel_core::route::{CompiledStep, compose_pipeline_with_handler};
4924        use tower::ServiceExt;
4925
4926        // Pipeline: set_body("nope") + set CamelHttpResponseCode=409 + Stop.
4927        let set_body_step = CompiledStep::Process {
4928            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
4929                ex.input.body = Body::Text("nope".into());
4930                Box::pin(async move { Ok(ex) })
4931            }),
4932            body_contract: None,
4933            lifecycle: None,
4934        };
4935        let set_status_step = CompiledStep::Process {
4936            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
4937                ex.input.set_header(
4938                    "CamelHttpResponseCode",
4939                    serde_json::Value::Number(409.into()),
4940                );
4941                Box::pin(async move { Ok(ex) })
4942            }),
4943            body_contract: None,
4944            lifecycle: None,
4945        };
4946        let pipeline = compose_pipeline_with_handler(
4947            vec![set_body_step, set_status_step, CompiledStep::Stop],
4948            None,
4949        );
4950
4951        let ex = Exchange::new(Message::default());
4952        let result = pipeline.oneshot(ex).await;
4953        assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
4954        let returned = result.unwrap();
4955        assert_eq!(returned.input.body.as_text(), Some("nope"));
4956        assert_eq!(
4957            returned
4958                .input
4959                .header("CamelHttpResponseCode")
4960                .and_then(|v| v.as_u64()),
4961            Some(409)
4962        );
4963    }
4964
4965    #[tokio::test]
4966    async fn http_consumer_returns_200_when_body_empty_on_stop() {
4967        // After ADR-0024: Stop with no body + no status header produces 200 (same as
4968        // a normal completion with no body). The 204 default is gone — users who
4969        // want 204 set CamelHttpResponseCode=204 explicitly.
4970        //
4971        // This test stays at the pipeline level (consistent with the test above).
4972        // E2E coverage of the full HTTP dispatch path is in
4973        // crates/camel-test/tests/integration_test.rs.
4974        use camel_api::{Exchange, Message};
4975        use camel_core::route::{CompiledStep, compose_pipeline_with_handler};
4976        use tower::ServiceExt;
4977
4978        let pipeline = compose_pipeline_with_handler(vec![CompiledStep::Stop], None);
4979        let ex = Exchange::new(Message::default());
4980        let result = pipeline.oneshot(ex).await;
4981        assert!(result.is_ok(), "Stop with empty body arrives as Ok");
4982        // Body is default (empty); no CamelHttpResponseCode header was set.
4983        // The HTTP reply finaliser (tested at E2E) maps this to status=200 + empty body.
4984    }
4985}