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(crate) mod rest_match;
7pub(crate) mod ssrf;
8pub mod static_config;
9pub mod static_dispatch;
10pub mod static_endpoint;
11pub(crate) mod tls_reload;
12use crate::config::parse_ok_status_code_range;
13pub use bundle::HttpBundle;
14pub use bundle::HttpStaticBundle;
15pub use config::HttpConfig;
16pub use health::HttpHealthCheck;
17pub use registry::HttpRouteRegistry;
18pub use static_config::HttpStaticConfig;
19pub use static_endpoint::{HttpStaticComponent, HttpStaticConsumer, HttpStaticEndpoint};
20
21use std::collections::HashMap;
22use std::future::Future;
23use std::pin::Pin;
24
25use std::sync::{Arc, Mutex, OnceLock};
26use std::task::{Context, Poll};
27use std::time::Duration;
28
29use tokio::sync::OnceCell;
30use tower::Layer;
31use tower::Service;
32use tracing::debug;
33
34use axum::body::BodyDataStream;
35use camel_api::component_metadata::{
36    ComponentCapabilities, ComponentMetadata, OptionKind, UriOption,
37};
38use camel_auth::bearer_token_layer::BearerTokenLayer;
39use camel_auth::oauth2::TokenProvider;
40use camel_component_api::tls_source::ServerTlsSource;
41use camel_component_api::{Body, BoxProcessor, CamelError, Exchange, StreamBody, StreamMetadata};
42use camel_component_api::{Component, Consumer, Endpoint, ProducerContext, RuntimeObservability};
43use camel_component_api::{UriComponents, UriConfig, parse_uri};
44use futures::TryStreamExt;
45use futures::stream::BoxStream;
46
47// ---------------------------------------------------------------------------
48// HttpEndpointConfig
49// ---------------------------------------------------------------------------
50
51/// Configuration for an HTTP client (producer) endpoint.
52///
53/// # Memory Limits
54///
55/// HTTP operations enforce conservative memory limits to prevent denial-of-service
56/// attacks from untrusted network sources. These limits are significantly lower than
57/// file component limits (100MB) because HTTP typically handles API responses rather
58/// than large file transfers, and clients may be untrusted.
59///
60/// ## Default Limits
61///
62/// - **HTTP client body**: 10MB (typical API responses)
63/// - **HTTP server request**: 2MB (untrusted network input - see `HttpServerConfig`)
64/// - **HTTP server response**: 10MB (same as client - see `HttpServerConfig`)
65///
66/// ## Rationale
67///
68/// The 10MB limit for HTTP client responses is appropriate for most API interactions
69/// while providing protection against:
70/// - Malicious servers sending oversized responses
71/// - Runaway processes generating unexpectedly large payloads
72/// - Memory exhaustion attacks
73///
74/// The 2MB server request limit is even more conservative because it handles input
75/// from potentially untrusted clients on the public internet.
76///
77/// ## Overriding Limits
78///
79/// Override the default client body limit using the `maxBodySize` URI parameter:
80///
81/// ```text
82/// http://api.example.com/large-data?maxBodySize=52428800
83/// ```
84///
85/// For server endpoints, use `maxRequestBody` and `maxResponseBody` parameters:
86///
87/// ```text
88/// http://0.0.0.0:8080/upload?maxRequestBody=52428800
89/// ```
90///
91/// ## Behavior When Exceeded
92///
93/// When a body exceeds the configured limit:
94/// - An error is returned immediately
95/// - No memory is exhausted - the limit is checked before allocation
96/// - The HTTP connection is terminated cleanly
97///
98/// ## Security Considerations
99///
100/// HTTP endpoints should be treated with more caution than file endpoints because:
101/// - Clients may be unknown and untrusted
102/// - Network traffic can be spoofed or malicious
103/// - DoS attacks often exploit unbounded resource consumption
104///
105/// Only increase limits when you control both ends of the connection or when
106/// business requirements demand larger payloads.
107#[derive(Debug, Clone)]
108pub struct HttpEndpointConfig {
109    pub base_url: String,
110    pub http_method: Option<String>,
111    pub throw_exception_on_failure: bool,
112    pub ok_status_code_range: (u16, u16),
113    pub response_timeout: Option<Duration>,
114    pub query_params: HashMap<String, String>,
115    pub allow_internal: bool,
116    pub blocked_hosts: Vec<String>,
117    pub max_body_size: usize,
118    pub read_timeout_ms: u64,
119    pub max_response_bytes: usize,
120    pub auth: HttpAuth,
121    pub token_provider: Option<Arc<dyn TokenProvider>>,
122    pub user_agent: Option<String>,
123    pub cookie_handling: CookieHandling,
124    pub bridge_endpoint: bool,
125    pub connection_close: bool,
126    pub skip_request_headers: Vec<String>,
127    pub skip_response_headers: Vec<String>,
128    pub follow_redirects: bool,
129    pub max_redirects: usize,
130}
131
132#[derive(Clone, PartialEq)]
133pub enum HttpAuth {
134    None,
135    Basic { username: String, password: String },
136    Bearer { token: String },
137}
138
139impl std::fmt::Debug for HttpAuth {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        match self {
142            HttpAuth::None => f.write_str("None"),
143            HttpAuth::Basic { username, .. } => f
144                .debug_struct("Basic")
145                .field("username", username)
146                .field("password", &"***")
147                .finish(),
148            HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
149        }
150    }
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum CookieHandling {
155    Disabled,
156    InMemory,
157}
158
159/// Camel options that should NOT be forwarded as HTTP query params
160const HTTP_CAMEL_OPTIONS: &[&str] = &[
161    "httpMethod",
162    "throwExceptionOnFailure",
163    "okStatusCodeRange",
164    "followRedirects",
165    "maxRedirects",
166    "connectTimeout",
167    "responseTimeout",
168    "allowInternal",
169    "blockedHosts",
170    "maxBodySize",
171    "readTimeout",
172    "maxResponseBytes",
173    "authMethod",
174    "authUsername",
175    "authPassword",
176    "authBearerToken",
177    "userAgent",
178    "cookieHandling",
179    "bridgeEndpoint",
180    "connectionClose",
181    "skipRequestHeaders",
182    "skipResponseHeaders",
183];
184
185impl UriConfig for HttpEndpointConfig {
186    /// Returns "http" as the primary scheme (also accepts "https")
187    fn scheme() -> &'static str {
188        "http"
189    }
190
191    fn from_uri(uri: &str) -> Result<Self, CamelError> {
192        let parts = parse_uri(uri)?;
193        Self::from_components(parts)
194    }
195
196    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
197        // Validate scheme - accept both http and https
198        if parts.scheme != "http" && parts.scheme != "https" {
199            return Err(CamelError::InvalidUri(format!(
200                "expected scheme 'http' or 'https', got '{}'",
201                parts.scheme
202            )));
203        }
204
205        // Construct base_url from scheme + path
206        // e.g., "http://localhost:8080/api" from scheme "http" and path "//localhost:8080/api"
207        let base_url = format!("{}:{}", parts.scheme, parts.path);
208
209        let http_method = parts.params.get("httpMethod").cloned();
210
211        let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
212            Some(v) => parse_bool_param_http(v).map_err(|e| {
213                CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
214            })?,
215            None => true,
216        };
217
218        // Parse status code range from "start-end" format (e.g., "200-299")
219        let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
220            Some(v) => parse_ok_status_code_range(v)?,
221            None => (200, 299),
222        };
223
224        let response_timeout = match parts.params.get("responseTimeout") {
225            Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
226                CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
227            })?),
228            None => None,
229        };
230
231        // SSRF protection settings
232        let allow_internal = match parts.params.get("allowInternal") {
233            Some(v) => parse_bool_param_http(v).map_err(|e| {
234                CamelError::InvalidUri(format!("invalid value for allowInternal: {e}"))
235            })?,
236            None => false, // Default: block private IPs
237        };
238
239        // Parse comma-separated blocked hosts
240        let blocked_hosts = parts
241            .params
242            .get("blockedHosts")
243            .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
244            .unwrap_or_default();
245
246        let max_body_size = match parts.params.get("maxBodySize") {
247            Some(v) => v.parse::<usize>().map_err(|e| {
248                CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
249            })?,
250            None => 10 * 1024 * 1024, // Default: 10MB
251        };
252
253        let read_timeout_ms = match parts.params.get("readTimeout") {
254            Some(v) => v.parse::<u64>().map_err(|e| {
255                CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
256            })?,
257            None => 30_000, // Default: 30s
258        };
259
260        let max_response_bytes = match parts.params.get("maxResponseBytes") {
261            Some(v) => v.parse::<usize>().map_err(|e| {
262                CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
263            })?,
264            None => 10 * 1024 * 1024, // Default: 10MB
265        };
266
267        let auth = parse_auth_from_params(&parts.params)?;
268
269        let user_agent = parts.params.get("userAgent").cloned();
270
271        let cookie_handling = match parts.params.get("cookieHandling") {
272            Some(v) if v.eq_ignore_ascii_case("inmemory") => CookieHandling::InMemory,
273            Some(v) if v.eq_ignore_ascii_case("disabled") => CookieHandling::Disabled,
274            Some(v) => {
275                return Err(CamelError::InvalidUri(format!(
276                    "invalid value for cookieHandling: {v} (expected Disabled or InMemory)"
277                )));
278            }
279            None => CookieHandling::Disabled,
280        };
281
282        let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
283            Some(v) => parse_bool_param_http(v).map_err(|e| {
284                CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
285            })?,
286            None => false,
287        };
288
289        let connection_close = match parts.params.get("connectionClose") {
290            Some(v) => parse_bool_param_http(v).map_err(|e| {
291                CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
292            })?,
293            None => false,
294        };
295
296        let skip_request_headers = parts
297            .params
298            .get("skipRequestHeaders")
299            .map(|v| {
300                v.split(',')
301                    .map(str::trim)
302                    .filter(|s| !s.is_empty())
303                    .map(|s| s.to_ascii_lowercase())
304                    .collect::<Vec<_>>()
305            })
306            .unwrap_or_default();
307
308        let skip_response_headers = parts
309            .params
310            .get("skipResponseHeaders")
311            .map(|v| {
312                v.split(',')
313                    .map(str::trim)
314                    .filter(|s| !s.is_empty())
315                    .map(|s| s.to_ascii_lowercase())
316                    .collect::<Vec<_>>()
317            })
318            .unwrap_or_default();
319
320        let follow_redirects = match parts.params.get("followRedirects") {
321            Some(v) => parse_bool_param_http(v).map_err(|e| {
322                CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
323            })?,
324            None => false,
325        };
326
327        let max_redirects = match parts.params.get("maxRedirects") {
328            Some(v) => v.parse::<usize>().map_err(|e| {
329                CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
330            })?,
331            None => 10,
332        };
333
334        // Collect remaining params (not Camel options) as query params
335        let query_params: HashMap<String, String> = parts
336            .params
337            .into_iter()
338            .filter(|(k, _)| !HTTP_CAMEL_OPTIONS.contains(&k.as_str()))
339            .collect();
340
341        Ok(Self {
342            base_url,
343            http_method,
344            throw_exception_on_failure,
345            ok_status_code_range,
346            response_timeout,
347            query_params,
348            allow_internal,
349            blocked_hosts,
350            max_body_size,
351            read_timeout_ms,
352            max_response_bytes,
353            auth,
354            token_provider: None,
355            user_agent,
356            cookie_handling,
357            bridge_endpoint,
358            connection_close,
359            skip_request_headers,
360            skip_response_headers,
361            follow_redirects,
362            max_redirects,
363        })
364    }
365}
366
367fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
368    let Some(method) = params.get("authMethod") else {
369        return Ok(HttpAuth::None);
370    };
371
372    if method.eq_ignore_ascii_case("none") {
373        return Ok(HttpAuth::None);
374    }
375
376    if method.eq_ignore_ascii_case("basic") {
377        let username = params.get("authUsername").cloned().ok_or_else(|| {
378            CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
379        })?;
380        let password = params.get("authPassword").cloned().ok_or_else(|| {
381            CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
382        })?;
383        return Ok(HttpAuth::Basic { username, password });
384    }
385
386    if method.eq_ignore_ascii_case("bearer") {
387        let token = params.get("authBearerToken").cloned().ok_or_else(|| {
388            CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
389        })?;
390        return Ok(HttpAuth::Bearer { token });
391    }
392
393    Err(CamelError::InvalidUri(format!(
394        "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
395    )))
396}
397
398fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
399    match value.to_ascii_lowercase().as_str() {
400        "true" | "1" | "yes" => Ok(true),
401        "false" | "0" | "no" => Ok(false),
402        _ => Err(CamelError::InvalidUri(format!(
403            "invalid boolean value: '{value}'"
404        ))),
405    }
406}
407
408impl HttpEndpointConfig {
409    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
410        let parts = parse_uri(uri)?;
411        let mut endpoint = Self::from_components(parts.clone())?;
412        if endpoint.response_timeout.is_none() {
413            endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
414        }
415        if !parts.params.contains_key("allowInternal") {
416            endpoint.allow_internal = config.allow_internal;
417        }
418        if !parts.params.contains_key("blockedHosts") {
419            endpoint.blocked_hosts = config.blocked_hosts.clone();
420        }
421        if !parts.params.contains_key("maxBodySize") {
422            endpoint.max_body_size = config.max_body_size;
423        }
424        if !parts.params.contains_key("readTimeout") {
425            endpoint.read_timeout_ms = config.read_timeout_ms;
426        }
427        if !parts.params.contains_key("maxResponseBytes") {
428            endpoint.max_response_bytes = config.max_response_bytes;
429        }
430        if !parts.params.contains_key("okStatusCodeRange")
431            && let Some(range) = &config.ok_status_code_range
432        {
433            endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
434        }
435        if !parts.params.contains_key("followRedirects") {
436            endpoint.follow_redirects = config.follow_redirects;
437        }
438        if !parts.params.contains_key("maxRedirects") {
439            endpoint.max_redirects = config.max_redirects.unwrap_or(10);
440        }
441
442        Ok(endpoint)
443    }
444}
445
446// ---------------------------------------------------------------------------
447// HttpServerConfig
448// ---------------------------------------------------------------------------
449
450/// Configuration for an HTTP server (consumer) endpoint.
451#[derive(Debug, Clone)]
452pub struct HttpServerConfig {
453    /// URI scheme ("http" or "https") parsed from the endpoint URI.
454    pub scheme: String,
455    /// Bind address, e.g. "0.0.0.0" or "127.0.0.1".
456    pub host: String,
457    /// TCP port to listen on.
458    pub port: u16,
459    /// URL path this consumer handles, e.g. "/orders".
460    pub path: String,
461    /// Maximum request body size in bytes.
462    pub max_request_body: usize,
463    /// Maximum response body size for materializing streams in bytes.
464    pub max_response_body: usize,
465    /// Maximum number of in-flight requests handled concurrently by this server.
466    pub max_inflight_requests: usize,
467    /// HTTP method this consumer handles (e.g. `"GET"`). When `Some`,
468    /// the consumer registers as a method-aware REST endpoint and the
469    /// path is treated as a template (e.g. `/users/{id}` is matched
470    /// against any `/users/<value>`). When `None`, the consumer
471    /// registers in the legacy path-only `api_routes` registry.
472    /// Extracted from the `httpMethod=` URI param at config build time.
473    pub method: Option<String>,
474    /// Server-side TLS config. Populated from `tlsCert`/`tlsKey` URI params.
475    /// `None` for plain HTTP servers.
476    pub tls_config: Option<crate::config::ServerTlsConfig>,
477}
478
479impl UriConfig for HttpServerConfig {
480    /// Returns "http" as the primary scheme (also accepts "https")
481    fn scheme() -> &'static str {
482        "http"
483    }
484
485    fn from_uri(uri: &str) -> Result<Self, CamelError> {
486        let parts = parse_uri(uri)?;
487        Self::from_components(parts)
488    }
489
490    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
491        // Validate scheme - accept both http and https
492        if parts.scheme != "http" && parts.scheme != "https" {
493            return Err(CamelError::InvalidUri(format!(
494                "expected scheme 'http' or 'https', got '{}'",
495                parts.scheme
496            )));
497        }
498
499        // parts.path is everything after the scheme colon, e.g. "//0.0.0.0:8080/orders"
500        // Strip leading "//"
501        let authority_and_path = parts.path.trim_start_matches('/');
502
503        // Split on the first "/" to separate "host:port" from "/path"
504        let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
505            (&authority_and_path[..idx], &authority_and_path[idx..])
506        } else {
507            (authority_and_path, "/")
508        };
509
510        let path = if path_suffix.is_empty() {
511            "/"
512        } else {
513            path_suffix
514        }
515        .to_string();
516
517        // Parse host:port from authority
518        let (host, port) = if let Some(colon) = authority.rfind(':') {
519            let port_str = &authority[colon + 1..];
520            match port_str.parse::<u16>() {
521                Ok(p) => (authority[..colon].to_string(), p),
522                Err(_) => {
523                    return Err(CamelError::InvalidUri(format!(
524                        "invalid port '{}' in authority",
525                        port_str
526                    )));
527                }
528            }
529        } else {
530            // Default port based on scheme: 443 for https, 80 for http
531            let default_port = if parts.scheme == "https" { 443 } else { 80 };
532            (authority.to_string(), default_port)
533        };
534
535        let max_request_body = parts
536            .params
537            .get("maxRequestBody")
538            .and_then(|v| v.parse::<usize>().ok())
539            .unwrap_or(2 * 1024 * 1024); // Default: 2MB
540
541        let max_response_body = parts
542            .params
543            .get("maxResponseBody")
544            .and_then(|v| v.parse::<usize>().ok())
545            .unwrap_or(10 * 1024 * 1024); // Default: 10MB
546
547        let max_inflight_requests = parts
548            .params
549            .get("maxInflightRequests")
550            .and_then(|v| v.parse::<usize>().ok())
551            .unwrap_or(1024);
552
553        // Uppercase-normalize so a hand-written `httpMethod=get` matches the
554        // uppercase method the dispatcher compares against (axum's
555        // `req.method().to_string()` yields "GET"). Without this, a
556        // lower-case `httpMethod` would never match and silently 404.
557        // Review I5.
558        let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
559
560        Ok(Self {
561            scheme: parts.scheme,
562            host,
563            port,
564            path,
565            max_request_body,
566            max_response_body,
567            max_inflight_requests,
568            method,
569            tls_config: {
570                let cert = parts.params.get("tlsCert").cloned();
571                let key = parts.params.get("tlsKey").cloned();
572                match (cert, key) {
573                    (Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
574                        cert_path: c,
575                        key_path: k,
576                    }),
577                    (None, None) => None,
578                    _ => None, // partial — enforced in create_consumer, not here
579                }
580            },
581        })
582    }
583}
584
585impl HttpServerConfig {
586    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
587        let parts = parse_uri(uri)?;
588        let mut server = Self::from_components(parts.clone())?;
589        if !parts.params.contains_key("maxRequestBody") {
590            server.max_request_body = config.max_request_body;
591        }
592        if !parts.params.contains_key("maxResponseBody") {
593            // Default max_response_body is 10MB via HttpConfig::default().max_body_size.
594            server.max_response_body = config.max_body_size;
595        }
596        Ok(server)
597    }
598}
599
600// ---------------------------------------------------------------------------
601// RequestEnvelope / HttpReply
602// ---------------------------------------------------------------------------
603
604/// Body of the HTTP response: already-materialized bytes or a lazy stream.
605///
606/// **Internal plumbing** — subject to change without notice.
607pub enum HttpReplyBody {
608    Bytes(bytes::Bytes),
609    Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
610}
611
612/// An inbound HTTP request sent from the Axum dispatch handler to an
613/// `HttpConsumer` receive loop.
614///
615/// **Internal plumbing** — subject to change without notice.
616pub struct RequestEnvelope {
617    pub method: String,
618    pub path: String,
619    pub query: String,
620    pub headers: http::HeaderMap,
621    pub body: StreamBody,
622    /// Path parameters extracted from a REST template match, e.g.
623    /// `id=42` for a request to `/users/42` matched against
624    /// `/users/{id}`. Empty for non-REST requests or for literal
625    /// template matches. The consumer turns these into
626    /// `CamelHttpPath_<param>` headers on the Exchange (expert guidance E2).
627    pub path_params: std::collections::HashMap<String, String>,
628    pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
629}
630
631/// The HTTP response that `HttpConsumer` sends back to the Axum handler.
632///
633/// **Internal plumbing** — subject to change without notice.
634pub struct HttpReply {
635    pub status: u16,
636    pub headers: Vec<(String, String)>,
637    pub body: HttpReplyBody,
638}
639
640// ---------------------------------------------------------------------------
641// HttpRouteRegistry / ServerRegistry
642// ---------------------------------------------------------------------------
643
644type ServerKey = (String, u16);
645
646/// Handle to a running Axum server on one interface/port.
647struct ServerHandle {
648    registry: HttpRouteRegistry,
649    max_request_body: usize,
650    max_response_body: usize,
651    max_inflight_requests: usize,
652    is_tls: bool,
653    tls_cert_path: Option<String>,
654    tls_key_path: Option<String>,
655    /// JoinHandle for the monitor_axum_task wrapper. `is_finished()` is the
656    /// dead-server eviction signal in `get_or_spawn`.
657    monitor_task: tokio::task::JoinHandle<()>,
658    // Retained so the reload handler (Task 7) can call reload_from_config()
659    // to hot-swap certs without restarting the server.
660    tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
661    tls_source: Option<ServerTlsSource>,
662}
663
664/// Process-global registry mapping (host, port) → running Axum server handle.
665pub struct ServerRegistry {
666    inner: Mutex<HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>>,
667}
668
669impl ServerRegistry {
670    /// Returns the global singleton.
671    pub fn global() -> &'static Self {
672        static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
673        INSTANCE.get_or_init(|| ServerRegistry {
674            inner: Mutex::new(HashMap::new()),
675        })
676    }
677
678    /// Returns route registry for `port`, spawning new Axum server if
679    /// none is running on that port yet.
680    #[allow(clippy::too_many_arguments)]
681    pub async fn get_or_spawn(
682        &'static self,
683        host: &str,
684        port: u16,
685        max_request_body: usize,
686        max_response_body: usize,
687        max_inflight_requests: usize,
688        runtime: Arc<dyn RuntimeObservability>,
689        route_id: String,
690        tls_config: Option<crate::config::ServerTlsConfig>,
691    ) -> Result<HttpRouteRegistry, CamelError> {
692        let host_owned = host.to_string();
693
694        let cell = {
695            let mut guard = self.inner.lock().map_err(|_| {
696                CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
697            })?;
698            let key = (host.to_string(), port);
699            // Evict dead server so a fresh one can spawn (matches gRPC D-L2 pattern).
700            // The monitor task awaits the server task, so monitor_task.is_finished()
701            // is a reliable proxy for the server being gone (either crashed or aborted).
702            if let Some(existing) = guard.get(&key)
703                && let Some(handle) = existing.get()
704                && handle.monitor_task.is_finished()
705            {
706                // Deregister TLS reload handler so a respawned HTTPS server
707                // doesn't reload stale cert config from the crashed handler.
708                if handle.is_tls {
709                    let scheme = if handle.is_tls { "https" } else { "http" };
710                    camel_component_api::tls_source::TlsReloadRegistry::global()
711                        .unregister(scheme, host, port);
712                }
713                guard.remove(&key);
714            }
715            guard
716                .entry(key)
717                .or_insert_with(|| Arc::new(OnceCell::new()))
718                .clone()
719        };
720
721        if let Some(existing) = cell.get()
722            && existing.max_request_body != max_request_body
723        {
724            return Err(CamelError::EndpointCreationFailed(format!(
725                "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
726                existing.max_request_body, max_request_body
727            )));
728        }
729
730        if let Some(existing) = cell.get()
731            && existing.max_response_body != max_response_body
732        {
733            return Err(CamelError::EndpointCreationFailed(format!(
734                "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
735                existing.max_response_body, max_response_body
736            )));
737        }
738
739        if let Some(existing) = cell.get()
740            && existing.max_inflight_requests != max_inflight_requests
741        {
742            return Err(CamelError::EndpointCreationFailed(format!(
743                "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
744                existing.max_inflight_requests, max_inflight_requests
745            )));
746        }
747
748        // TLS mode mismatch: plain vs TLS
749        if let Some(existing) = cell.get()
750            && existing.is_tls != tls_config.is_some()
751        {
752            return Err(CamelError::EndpointCreationFailed(format!(
753                "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
754                existing.is_tls,
755                tls_config.is_some()
756            )));
757        }
758
759        // TLS cert/key mismatch: different cert on same TLS port
760        if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
761            && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
762                || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
763        {
764            return Err(CamelError::EndpointCreationFailed(format!(
765                "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
766            )));
767        }
768
769        let handle = cell
770            .get_or_try_init(|| {
771                let rt = Arc::clone(&runtime);
772                let rid = route_id.clone();
773                async move {
774                    let addr = format!("{host_owned}:{port}");
775                    let listener = tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
776                        CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
777                    })?;
778                    let registry = HttpRouteRegistry::new();
779                    let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
780                    // Constructed once in the TLS branch so they can be retained
781                    // on ServerHandle for the reload handler (Task 7).
782                    let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
783                    let tls_source: Option<ServerTlsSource>;
784                    let server_task = if let Some(ref tls) = tls_config {
785                        let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
786                        let source = ServerTlsSource {
787                            cert_path: std::path::PathBuf::from(&tls.cert_path),
788                            key_path: std::path::PathBuf::from(&tls.key_path),
789                            client_ca_path: None,
790                        };
791                        // Build the RustlsConfig once — clone() is cheap (Arc
792                        // internally) and shares the ArcSwap the reload handler
793                        // will mutate via reload_from_config().
794                        let rustls_cfg = axum_server::tls_rustls::RustlsConfig::from_config(
795                            std::sync::Arc::new(rustls_config),
796                        );
797                        tls_rustls_cfg = Some(rustls_cfg.clone());
798                        tls_source = Some(source);
799                        // Convert tokio listener to std for axum-server
800                        let std_listener = listener.into_std().map_err(|e| {
801                            CamelError::EndpointCreationFailed(format!(
802                                "TLS listener conversion: {e}"
803                            ))
804                        })?;
805                        tokio::spawn(run_axum_server_tls(
806                            std_listener,
807                            rustls_cfg,
808                            registry.clone(),
809                            max_request_body,
810                            max_response_body,
811                            Arc::clone(&inflight),
812                            Arc::clone(&rt),
813                            rid.clone(),
814                        ))
815                    } else {
816                        tls_rustls_cfg = None;
817                        tls_source = None;
818                        tokio::spawn(run_axum_server(
819                            listener,
820                            registry.clone(),
821                            max_request_body,
822                            max_response_body,
823                            Arc::clone(&inflight),
824                            Arc::clone(&rt),
825                            rid.clone(),
826                        ))
827                    };
828                    let addr_for_monitor = format!("{host_owned}:{port}");
829                    let monitor_task = tokio::spawn(monitor_axum_task(
830                        server_task,
831                        addr_for_monitor,
832                        Arc::clone(&rt),
833                        rid,
834                    ));
835                    let handle = ServerHandle {
836                        registry,
837                        max_request_body,
838                        max_response_body,
839                        max_inflight_requests,
840                        is_tls: tls_config.is_some(),
841                        tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
842                        tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
843                        monitor_task,
844                        tls_config: tls_rustls_cfg,
845                        tls_source,
846                    };
847                    // Register reload handler (exactly-once: inside OnceCell init closure).
848                    // Note: HTTP servers are process-lifetime (no release/eviction path),
849                    // so handlers are never unregistered. If eviction is added later,
850                    // add TlsReloadRegistry::global().unregister() there.
851                    if let (Some(tls_cfg), Some(source)) =
852                        (handle.tls_config.as_ref(), handle.tls_source.as_ref())
853                    {
854                        let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
855                            tls_cfg.clone(),
856                            source.clone(),
857                            host_owned.clone(),
858                            port,
859                        ));
860                        camel_component_api::tls_source::TlsReloadRegistry::global()
861                            .register(handler);
862                    }
863                    Ok::<ServerHandle, CamelError>(handle)
864                }
865            })
866            .await?;
867
868        Ok(handle.registry.clone())
869    }
870
871    /// Unregister one consumer from a server. HTTP servers are process-lifetime:
872    /// the server stays in the registry for potential restart. Path
873    /// deregistration happens separately in the consumer's cleanup.
874    pub async fn unregister(&self, host: &str, port: u16) {
875        debug!(
876            host = host,
877            port = port,
878            "consumer unregistered from HTTP server"
879        );
880    }
881
882    /// Reset the global registry — **test-only**.
883    ///
884    /// Clears all registered server handles so that tests can start from a clean
885    /// state. This is intentionally `#[cfg(test)]` because the registry is a
886    /// process-global singleton in production and resetting it would break
887    /// running servers.
888    #[cfg(test)]
889    pub fn reset() {
890        let instance = Self::global();
891        let mut guard = instance
892            .inner
893            .lock()
894            .expect("ServerRegistry lock poisoned during test reset");
895        guard.clear();
896    }
897}
898
899// ---------------------------------------------------------------------------
900// Axum server
901// ---------------------------------------------------------------------------
902
903use axum::{
904    Router,
905    body::Body as AxumBody,
906    extract::{Request, State},
907    http::{Response, StatusCode},
908    response::IntoResponse,
909};
910
911#[derive(Clone)]
912pub(crate) struct AppState {
913    registry: HttpRouteRegistry,
914    max_request_body: usize,
915    max_response_body: usize,
916    inflight: Arc<tokio::sync::Semaphore>,
917}
918
919async fn run_axum_server(
920    listener: tokio::net::TcpListener,
921    registry: HttpRouteRegistry,
922    max_request_body: usize,
923    max_response_body: usize,
924    inflight: Arc<tokio::sync::Semaphore>,
925    runtime: Arc<dyn RuntimeObservability>,
926    route_id: String,
927) {
928    let state = AppState {
929        registry,
930        max_request_body,
931        max_response_body,
932        inflight,
933    };
934    let app = Router::new().fallback(dispatch_handler).with_state(state);
935
936    axum::serve(listener, app).await.unwrap_or_else(|e| {
937        runtime
938            .metrics()
939            .increment_errors(&route_id, "e:http:accept");
940        // log-policy: outside-contract
941        tracing::error!(error = %e, "Axum server error");
942    });
943}
944
945#[allow(clippy::too_many_arguments)]
946async fn run_axum_server_tls(
947    listener: std::net::TcpListener,
948    tls_cfg: axum_server::tls_rustls::RustlsConfig,
949    registry: HttpRouteRegistry,
950    max_request_body: usize,
951    max_response_body: usize,
952    inflight: Arc<tokio::sync::Semaphore>,
953    runtime: Arc<dyn RuntimeObservability>,
954    route_id: String,
955) {
956    let state = AppState {
957        registry,
958        max_request_body,
959        max_response_body,
960        inflight,
961    };
962    let app = Router::new().fallback(dispatch_handler).with_state(state);
963
964    // RustlsConfig is now constructed once in get_or_spawn and retained on
965    // ServerHandle so the reload handler can call reload_from_config() on it.
966
967    axum_server::from_tcp_rustls(listener, tls_cfg)
968        .serve(app.into_make_service())
969        .await
970        .unwrap_or_else(|e| {
971            runtime
972                .metrics()
973                .increment_errors(&route_id, "e:http:accept-tls");
974            // log-policy: outside-contract
975            tracing::error!(error = %e, "Axum TLS server error");
976        });
977}
978
979/// Monitors an Axum server task and emits a structured error event if it
980/// exits unexpectedly.
981///
982/// # Limitations
983/// The HTTP server is shared across all routes on a port. Full per-route
984/// CrashNotification propagation is deferred — this provides observable
985/// structured logging as a first guard.
986async fn monitor_axum_task(
987    handle: tokio::task::JoinHandle<()>,
988    addr: String,
989    runtime: Arc<dyn RuntimeObservability>,
990    route_id: String,
991) {
992    match handle.await {
993        Ok(()) => {
994            // Clean exit (process shutdown or normal stop)
995        }
996        Err(join_err) => {
997            runtime
998                .metrics()
999                .increment_errors(&route_id, "e:http:server-task-exited");
1000            // log-policy: outside-contract
1001            tracing::error!(
1002                addr = %addr,
1003                error = %join_err,
1004                "Axum server task exited unexpectedly — all routes on this port are now dead"
1005            );
1006        }
1007    }
1008}
1009
1010/// Load a rustls ServerConfig from PEM cert/key files.
1011/// Adapted from camel-ws lib.rs load_tls_config.
1012fn load_tls_config(
1013    cert_path: &str,
1014    key_path: &str,
1015) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1016    use std::fs::File;
1017    use std::io::BufReader;
1018
1019    let cert_file = File::open(cert_path)
1020        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1021    let key_file = File::open(key_path)
1022        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1023
1024    let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1025        .collect::<Result<Vec<_>, _>>()
1026        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1027
1028    let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1029        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1030        .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1031
1032    tokio_rustls::rustls::ServerConfig::builder()
1033        .with_no_client_auth()
1034        .with_single_cert(certs, key)
1035        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1036}
1037
1038async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
1039    let path = req.uri().path().to_owned();
1040    let method = req.method().to_string();
1041
1042    // Dispatch precedence (spec §7.2 / ADR-0009):
1043    //   1. Exact API path match (legacy `http:` routes without httpMethod)
1044    //   2. Templated API path match (REST, method-aware, by specificity)
1045    //   3. Static mount longest-prefix
1046    //   4. SPA fallback
1047    //
1048    // Legacy exact runs first: it is a cheap HashMap get, and the two
1049    // registries are mutually exclusive per route — a legacy route carries
1050    // no `httpMethod` and lives only in `api_routes`, while a REST-lowered
1051    // route carries `httpMethod` and lives only in `rest_endpoints`. So an
1052    // exact hit can never shadow a REST route that should have matched,
1053    // and running exact-first honours the documented precedence (the prior
1054    // REST-first order let a templated `GET /api/{resource}` steal a
1055    // request meant for an exact `GET /api/users`). Intra-REST method
1056    // disambiguation is handled inside `match_endpoint`, not by this
1057    // ordering. Review C2.
1058    let api_sender = {
1059        let inner = state.registry.inner.read().await;
1060        inner.api_routes.get(&path).cloned()
1061    }; // lock released BEFORE any IO
1062
1063    let (rest_sender, path_params) = if api_sender.is_some() {
1064        // Exact legacy match won — skip the templated scan entirely.
1065        (None, Default::default())
1066    } else {
1067        let inner = state.registry.inner.read().await;
1068        match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
1069            rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
1070            rest_match::MatchOutcome::Ambiguous => {
1071                // Ambiguous registration should have been rejected at
1072                // lowering time (rest.rs). Reaching here means two
1073                // equal-specificity templates matched one request —
1074                // surface a loud error rather than a silent 404. Review C3.
1075                // log-policy: handler-owned
1076                tracing::warn!(
1077                    method = %method,
1078                    path = %path,
1079                    "ambiguous REST template match — returning 500"
1080                );
1081                return Response::builder()
1082                    .status(StatusCode::INTERNAL_SERVER_ERROR)
1083                    .body(AxumBody::from("Internal Server Error"))
1084                    .expect("infallible"); // allow-unwrap
1085            }
1086            rest_match::MatchOutcome::NotFound => (None, Default::default()),
1087        }
1088    }; // lock released BEFORE any IO
1089
1090    let sender = api_sender.or(rest_sender);
1091
1092    if let Some(sender) = sender {
1093        let query = req.uri().query().unwrap_or("").to_string();
1094        let headers = req.headers().clone();
1095
1096        // Check Content-Length against limit BEFORE opening the stream
1097        let content_length: Option<u64> = headers
1098            .get(http::header::CONTENT_LENGTH)
1099            .and_then(|v| v.to_str().ok())
1100            .and_then(|s| s.parse().ok());
1101
1102        if let Some(len) = content_length
1103            && len > state.max_request_body as u64
1104        {
1105            return Response::builder()
1106                .status(StatusCode::PAYLOAD_TOO_LARGE)
1107                .body(AxumBody::from("Request body exceeds configured limit"))
1108                .expect("infallible"); // allow-unwrap
1109        }
1110
1111        let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1112            Ok(permit) => permit,
1113            Err(_) => {
1114                return Response::builder()
1115                    .status(StatusCode::SERVICE_UNAVAILABLE)
1116                    .body(AxumBody::from("Service Unavailable"))
1117                    .expect("infallible"); // allow-unwrap
1118            }
1119        };
1120
1121        // Build StreamBody from Axum body WITHOUT materializing
1122        let content_type = headers
1123            .get(http::header::CONTENT_TYPE)
1124            .and_then(|v| v.to_str().ok())
1125            .map(|s| s.to_string());
1126
1127        let data_stream: BodyDataStream = req.into_body().into_data_stream();
1128        let mapped_stream = data_stream.map_err(|e| CamelError::Io(e.to_string()));
1129        let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(mapped_stream);
1130
1131        let stream_body = StreamBody {
1132            stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1133            metadata: StreamMetadata {
1134                size_hint: content_length,
1135                content_type,
1136                origin: None,
1137            },
1138        };
1139
1140        let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1141        let envelope = RequestEnvelope {
1142            method,
1143            path,
1144            query,
1145            headers,
1146            body: stream_body,
1147            path_params,
1148            reply_tx,
1149        };
1150
1151        if sender.send(envelope).await.is_err() {
1152            return Response::builder()
1153                .status(StatusCode::SERVICE_UNAVAILABLE)
1154                .body(AxumBody::from("Consumer unavailable"))
1155                .expect("infallible"); // allow-unwrap
1156        }
1157
1158        match reply_rx.await {
1159            Ok(reply) => {
1160                let reply = match reply.body {
1161                    HttpReplyBody::Bytes(b)
1162                        if exceeds_max_response_body(b.len(), state.max_response_body) =>
1163                    {
1164                        HttpReply {
1165                            status: 500,
1166                            headers: vec![],
1167                            body: HttpReplyBody::Bytes(bytes::Bytes::from(
1168                                "Response body exceeds configured limit",
1169                            )),
1170                        }
1171                    }
1172                    _ => reply,
1173                };
1174
1175                let status =
1176                    StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1177                let mut builder = Response::builder().status(status);
1178                for (k, v) in &reply.headers {
1179                    builder = builder.header(k.as_str(), v.as_str());
1180                }
1181                match reply.body {
1182                    HttpReplyBody::Bytes(b) => {
1183                        builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1184                            Response::builder()
1185                                .status(StatusCode::INTERNAL_SERVER_ERROR)
1186                                .body(AxumBody::from("Invalid response headers from consumer"))
1187                                .expect("infallible") // allow-unwrap
1188                        })
1189                    }
1190                    HttpReplyBody::Stream(stream) => builder
1191                        .body(AxumBody::from_stream(stream))
1192                        .unwrap_or_else(|_| {
1193                            Response::builder()
1194                                .status(StatusCode::INTERNAL_SERVER_ERROR)
1195                                .body(AxumBody::from("Invalid response headers from consumer"))
1196                                .expect("infallible") // allow-unwrap
1197                        }),
1198                }
1199            }
1200            Err(_) => Response::builder()
1201                .status(StatusCode::INTERNAL_SERVER_ERROR)
1202                .body(AxumBody::from("Pipeline error"))
1203                .expect("infallible"), // allow-unwrap
1204        }
1205    } else {
1206        // No API route matched — try static mounts
1207        static_dispatch::dispatch_static(&state, req, &path).await
1208    }
1209}
1210
1211fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1212    len > max
1213}
1214
1215fn title_case_header(name: &str) -> String {
1216    name.split('-')
1217        .map(|part| {
1218            let mut chars = part.chars();
1219            match chars.next() {
1220                None => String::new(),
1221                Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1222            }
1223        })
1224        .collect::<Vec<_>>()
1225        .join("-")
1226}
1227
1228// ---------------------------------------------------------------------------
1229// HttpConsumer
1230// ---------------------------------------------------------------------------
1231
1232pub struct HttpConsumer {
1233    config: HttpServerConfig,
1234    /// Runtime observability handle for ADR-0012 metrics and health calls.
1235    runtime: Arc<dyn RuntimeObservability>,
1236}
1237
1238impl HttpConsumer {
1239    pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1240        Self { config, runtime }
1241    }
1242}
1243
1244#[async_trait::async_trait]
1245impl Consumer for HttpConsumer {
1246    async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1247        use camel_component_api::{Body, Exchange, Message};
1248
1249        let registry = ServerRegistry::global()
1250            .get_or_spawn(
1251                &self.config.host,
1252                self.config.port,
1253                self.config.max_request_body,
1254                self.config.max_response_body,
1255                self.config.max_inflight_requests,
1256                self.runtime.clone(),
1257                ctx.route_id().to_string(),
1258                self.config.tls_config.clone(),
1259            )
1260            .await?;
1261
1262        // Create channel for this path and register it
1263        let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(64);
1264        // When the from-URI carries `httpMethod=...` (REST-lowered
1265        // route), register the consumer as a method-aware REST endpoint
1266        // so the dispatcher can route by (method, path template).
1267        // Otherwise fall back to the legacy path-only api_routes
1268        // registry. The two registries never overlap for the same
1269        // route: each consumer registers in exactly one of them.
1270        if let Some(method) = self.config.method.clone() {
1271            let segments = rest_match::parse_path_template(&self.config.path);
1272            registry
1273                .register_rest_endpoint(method, segments, env_tx)
1274                .await;
1275        } else {
1276            registry
1277                .register_api_route(self.config.path.clone(), env_tx)
1278                .await;
1279        }
1280
1281        let path = self.config.path.clone();
1282        let registry_for_cleanup = registry.clone();
1283        let cancel_token = ctx.cancel_token();
1284        loop {
1285            tokio::select! {
1286                _ = ctx.cancelled() => {
1287                    break;
1288                }
1289                envelope = env_rx.recv() => {
1290                    let Some(envelope) = envelope else { break; };
1291
1292                    // Build Exchange from HTTP request
1293                    let mut msg = Message::default();
1294
1295                    // Set standard Camel HTTP headers
1296                    msg.set_header("CamelHttpMethod",
1297                        serde_json::Value::String(envelope.method.clone()));
1298                    msg.set_header("CamelHttpPath",
1299                        serde_json::Value::String(envelope.path.clone()));
1300                    msg.set_header("CamelHttpQuery",
1301                        serde_json::Value::String(envelope.query.clone()));
1302
1303                    // Set path-parameter headers from REST template
1304                    // match. Expert guidance E2: the consumer is
1305                    // responsible for translating the dispatcher's
1306                    // matched params into `CamelHttpPath_<param>`
1307                    // headers on the Exchange, matching the convention
1308                    // used by Camel HTTP for templated routes.
1309                    for (param_name, param_value) in &envelope.path_params {
1310                        msg.set_header(
1311                            format!("CamelHttpPath_{param_name}"),
1312                            serde_json::Value::String(param_value.clone()),
1313                        );
1314                    }
1315
1316                    // Forward HTTP headers with Title-Case names (hyper lowercases them)
1317                    for (k, v) in &envelope.headers {
1318                        if let Ok(val_str) = v.to_str() {
1319                            msg.set_header(
1320                                title_case_header(k.as_str()),
1321                                serde_json::Value::String(val_str.to_string()),
1322                            );
1323                        }
1324                    }
1325
1326                    // Body: always arrives as Body::Stream (native streaming)
1327                    // Routes can call into_bytes() if they need to materialize
1328                    msg.body = Body::Stream(envelope.body);
1329
1330                    #[allow(unused_mut)]
1331                    let mut exchange = Exchange::new(msg);
1332
1333                    // Extract W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
1334                    #[cfg(feature = "otel")]
1335                    {
1336                        let headers: HashMap<String, String> = envelope
1337                            .headers
1338                            .iter()
1339                            .filter_map(|(k, v)| {
1340                                Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
1341                            })
1342                            .collect();
1343                        camel_otel::extract_into_exchange(&mut exchange, &headers);
1344                    }
1345
1346                    let reply_tx = envelope.reply_tx;
1347                    let sender = ctx.sender().clone();
1348                    let path_clone = path.clone();
1349                    let cancel = cancel_token.clone();
1350
1351                    // Spawn a task to handle this request concurrently
1352                    //
1353                    // NOTE: This spawns a separate tokio task for each incoming HTTP request to enable
1354                    // true concurrent request processing. This change was introduced as part of the
1355                    // pipeline concurrency feature and was NOT part of the original HttpConsumer design.
1356                    //
1357                    // Rationale:
1358                    // 1. Without spawning per-request tasks, the send_and_wait() operation would block
1359                    //    the consumer's main loop until the pipeline processing completes
1360                    // 2. This blocking would prevent multiple HTTP requests from being processed
1361                    //    concurrently, even when ConcurrencyModel::Concurrent is enabled on the pipeline
1362                    // 3. The channel would never have multiple exchanges buffered simultaneously,
1363                    //    defeating the purpose of pipeline-side concurrency
1364                    // 4. By spawning a task per request, we allow the consumer loop to continue
1365                    //    accepting new requests while existing ones are processed in the pipeline
1366                    //
1367                    // This approach effectively decouples request acceptance from pipeline processing,
1368                    // allowing the channel to buffer multiple exchanges that can be processed concurrently
1369                    // by the pipeline when ConcurrencyModel::Concurrent is active.
1370                    tokio::spawn(async move {
1371                        // Check for cancellation before sending to pipeline.
1372                        // Returns 503 (Service Unavailable) instead of letting the request
1373                        // enter a shutting-down pipeline. This is a behavioral change from
1374                        // the pre-concurrency implementation where cancellation during
1375                        // processing would result in a 500 (Internal Server Error).
1376                        // 503 is more semantically correct: the server is temporarily
1377                        // unable to handle the request due to shutdown.
1378                        if cancel.is_cancelled() {
1379                            let _ = reply_tx.send(HttpReply {
1380                                status: 503,
1381                                headers: vec![],
1382                                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
1383                            });
1384                            return;
1385                        }
1386
1387                        // Send through pipeline and await result
1388                        let (tx, rx) = tokio::sync::oneshot::channel();
1389                        let envelope = camel_component_api::consumer::ExchangeEnvelope {
1390                            exchange,
1391                            reply_tx: Some(tx),
1392                        };
1393
1394                        let result = match sender.send(envelope).await {
1395                            Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
1396                            Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
1397                        }
1398                        .and_then(|r| r);
1399
1400                        let reply = match result {
1401                            Ok(out) => {
1402                                let status = out
1403                                    .input
1404                                    .header("CamelHttpResponseCode")
1405                                    .and_then(|v| {
1406                                        let raw = v.as_u64()
1407                                            .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
1408                                        let code = raw as u16;
1409                                        (100..1000).contains(&code).then_some(code)
1410                                    })
1411                                    .unwrap_or(200);
1412
1413                                let user_content_type = out
1414                                    .input
1415                                    .header("Content-Type")
1416                                    .and_then(|v| v.as_str().map(|s| s.to_string()));
1417
1418                                let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
1419                                    Body::Empty => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
1420                                    Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
1421                                    Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
1422                                    Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
1423                                    Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
1424                                        v.to_string().into_bytes(),
1425                                    )), Some("application/json".to_string())),
1426                                    Body::Stream(s) => {
1427                                        let ct = s.metadata.content_type.clone();
1428                                        match s.stream.lock().await.take() {
1429                                            Some(stream) => (
1430                                                HttpReplyBody::Stream(stream),
1431                                                ct,
1432                                            ),
1433                                            None => {
1434                                                // log-policy: system-broken
1435                                                tracing::error!(
1436                                                    "Body::Stream already consumed before HTTP reply — returning 500"
1437                                                );
1438                                                let error_reply = HttpReply {
1439                                                    status: 500,
1440                                                    headers: vec![],
1441                                                    body: HttpReplyBody::Bytes(bytes::Bytes::new()),
1442                                                };
1443                                                if reply_tx.send(error_reply).is_err() {
1444                                                    debug!("reply_tx dropped before error reply could be sent");
1445                                                }
1446                                                return;
1447                                            }
1448                                        }
1449                                    }
1450                                };
1451
1452                                let mut resp_headers: Vec<(String, String)> = out
1453                                    .input
1454                                    .headers
1455                                    .iter()
1456                                    .filter(|(k, _)| !k.starts_with("Camel"))
1457                                    .filter(|(k, _)| {
1458                                        !matches!(
1459                                            k.to_lowercase().as_str(),
1460                                            "content-length"
1461                                            | "content-type"
1462                                            | "transfer-encoding"
1463                                            | "connection"
1464                                            | "cache-control"
1465                                            | "date"
1466                                            | "pragma"
1467                                            | "trailer"
1468                                            | "upgrade"
1469                                            | "via"
1470                                            | "warning"
1471                                            | "host"
1472                                            | "user-agent"
1473                                            | "accept"
1474                                            | "accept-encoding"
1475                                            | "accept-language"
1476                                            | "accept-charset"
1477                                            | "authorization"
1478                                            | "proxy-authorization"
1479                                            | "cookie"
1480                                            | "expect"
1481                                            | "from"
1482                                            | "if-match"
1483                                            | "if-modified-since"
1484                                            | "if-none-match"
1485                                            | "if-range"
1486                                            | "if-unmodified-since"
1487                                            | "max-forwards"
1488                                            | "proxy-connection"
1489                                            | "range"
1490                                            | "referer"
1491                                            | "te"
1492                                        )
1493                                    })
1494                                    .filter_map(|(k, v)| {
1495                                        v.as_str().map(|s| (k.clone(), s.to_string()))
1496                                    })
1497                                    .collect();
1498
1499                                let content_type = user_content_type
1500                                    .or(inferred_content_type);
1501                                if let Some(ct) = content_type {
1502                                    resp_headers.push(("Content-Type".to_string(), ct));
1503                                }
1504
1505                                HttpReply {
1506                                    status,
1507                                    headers: resp_headers,
1508                                    body: reply_body,
1509                                }
1510                            }
1511                            Err(e) => {
1512                                pipeline_error_to_reply(e, &path_clone)
1513                            }
1514                        };
1515
1516                        // Reply to Axum handler (ignore error if client disconnected)
1517                        let _ = reply_tx.send(reply);
1518                    });
1519                }
1520            }
1521        }
1522
1523        // Deregister this consumer. Mirror the registration choice:
1524        // REST-registered consumers remove their (method, path) endpoint
1525        // WITHOUT touching sibling verbs on the same template (review C1);
1526        // legacy consumers clean up api_routes.
1527        if let Some(method) = &self.config.method {
1528            registry_for_cleanup
1529                .unregister_rest_endpoint(method, &path)
1530                .await;
1531        } else {
1532            registry_for_cleanup.unregister_api_route(&path).await;
1533        }
1534
1535        // D-L10: decrement the shared server's refcount. When the last
1536        // consumer on this (host, port) leaves, the server + monitor tasks
1537        // are aborted and the registry entry is removed.
1538        ServerRegistry::global()
1539            .unregister(&self.config.host, self.config.port)
1540            .await;
1541
1542        Ok(())
1543    }
1544
1545    async fn stop(&mut self) -> Result<(), CamelError> {
1546        Ok(())
1547    }
1548
1549    fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
1550        camel_component_api::ConcurrencyModel::Concurrent { max: None }
1551    }
1552}
1553
1554// ---------------------------------------------------------------------------
1555// HttpComponent / HttpsComponent
1556// ---------------------------------------------------------------------------
1557
1558pub struct HttpComponent {
1559    config: HttpConfig,
1560}
1561
1562pub(crate) fn build_client(
1563    config: &HttpConfig,
1564    cookie_handling: CookieHandling,
1565    resolve_override: Option<(&str, &[std::net::SocketAddr])>,
1566) -> reqwest::Client {
1567    let mut builder = reqwest::Client::builder()
1568        .no_proxy() // CRITICAL: env proxies bypass resolve_to_addrs
1569        .connect_timeout(Duration::from_millis(config.connect_timeout_ms))
1570        .pool_max_idle_per_host(config.pool_max_idle_per_host)
1571        .pool_idle_timeout(Duration::from_millis(config.pool_idle_timeout_ms));
1572
1573    // Redirects are always handled manually in the producer's send path
1574    // so that each hop can be SSRF-validated. reqwest's built-in redirect
1575    // policy is sync and cannot perform async DNS resolution or SSRF checks.
1576    builder = builder.redirect(reqwest::redirect::Policy::none());
1577
1578    if let Some((host, addrs)) = resolve_override {
1579        builder = builder.resolve_to_addrs(host, addrs);
1580    }
1581
1582    if matches!(cookie_handling, CookieHandling::InMemory) {
1583        // TODO(HTTP-013): enable reqwest cookie jar once workspace reqwest features include cookie_store.
1584    }
1585
1586    if let Some(tls) = &config.tls
1587        && tls.enabled
1588    {
1589        if tls.insecure || !tls.verify_peer {
1590            // log-policy: handler-owned
1591            tracing::warn!("HTTP TLS verification disabled — connections are vulnerable to MitM");
1592            builder = builder.danger_accept_invalid_certs(true);
1593        }
1594
1595        if let Some(ca_path) = &tls.ca_cert_path
1596            && let Ok(ca_bytes) = std::fs::read(ca_path)
1597        {
1598            let cert = reqwest::Certificate::from_pem(&ca_bytes)
1599                .or_else(|_| reqwest::Certificate::from_der(&ca_bytes));
1600            if let Ok(ca_cert) = cert {
1601                builder = builder.add_root_certificate(ca_cert);
1602            }
1603        }
1604
1605        if let (Some(cert_path), Some(key_path)) = (&tls.client_cert_path, &tls.client_key_path)
1606            && let (Ok(cert_bytes), Ok(key_bytes)) =
1607                (std::fs::read(cert_path), std::fs::read(key_path))
1608        {
1609            let mut identity_pem = cert_bytes;
1610            identity_pem.extend_from_slice(&key_bytes);
1611            if let Ok(identity) = reqwest::Identity::from_pem(&identity_pem) {
1612                builder = builder.identity(identity);
1613            }
1614        }
1615    }
1616
1617    builder
1618        .build()
1619        .expect("reqwest::Client::build() with valid config should not fail") // allow-unwrap
1620}
1621
1622impl HttpComponent {
1623    pub fn new() -> Self {
1624        let config = HttpConfig::default();
1625        Self { config }
1626    }
1627
1628    pub fn with_config(config: HttpConfig) -> Self {
1629        Self { config }
1630    }
1631
1632    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1633        match config {
1634            Some(cfg) => Self::with_config(cfg),
1635            None => Self::new(),
1636        }
1637    }
1638}
1639
1640impl Default for HttpComponent {
1641    fn default() -> Self {
1642        Self::new()
1643    }
1644}
1645
1646impl Component for HttpComponent {
1647    fn scheme(&self) -> &str {
1648        "http"
1649    }
1650
1651    fn metadata(&self) -> ComponentMetadata {
1652        ComponentMetadata {
1653            scheme: "http".to_string(),
1654            version: env!("CARGO_PKG_VERSION").to_string(),
1655            description: "HTTP client and server component".to_string(),
1656            uri_syntax: "http://host:port/path?options".to_string(),
1657            capabilities: ComponentCapabilities {
1658                supports_consumer: true,
1659                supports_producer: true,
1660                supports_streaming: true,
1661                ..Default::default()
1662            },
1663            uri_options: vec![
1664                UriOption::new(
1665                    "httpMethod",
1666                    "HTTP method. Defaults to CamelHttpMethod header or POST/GET",
1667                    OptionKind::String,
1668                ),
1669                UriOption::new(
1670                    "throwExceptionOnFailure",
1671                    "Throw on non-2xx status",
1672                    OptionKind::Bool,
1673                )
1674                .with_default("true"),
1675                UriOption::new(
1676                    "okStatusCodeRange",
1677                    "Success status code range",
1678                    OptionKind::String,
1679                )
1680                .with_default("200-299"),
1681                UriOption::new(
1682                    "responseTimeout",
1683                    "Response timeout in milliseconds",
1684                    OptionKind::Int,
1685                ),
1686                UriOption::new(
1687                    "maxBodySize",
1688                    "Max request/response body bytes",
1689                    OptionKind::Int,
1690                )
1691                .with_default("10485760"),
1692                UriOption::new(
1693                    "authMethod",
1694                    "Authentication method",
1695                    OptionKind::Enum(vec!["Basic".to_string(), "Bearer".to_string()]),
1696                ),
1697                UriOption::new("authUsername", "Basic auth username", OptionKind::String).secret(),
1698                UriOption::new("authPassword", "Basic auth password", OptionKind::String).secret(),
1699                UriOption::new("authBearerToken", "Bearer auth token", OptionKind::String).secret(),
1700                UriOption::new("userAgent", "User-Agent header", OptionKind::String),
1701                UriOption::new("followRedirects", "Follow HTTP redirects", OptionKind::Bool)
1702                    .with_default("false"),
1703                UriOption::new("maxRedirects", "Max redirect hops", OptionKind::Int)
1704                    .with_default("10"),
1705            ],
1706            ..ComponentMetadata::minimal("http")
1707        }
1708    }
1709
1710    fn create_endpoint(
1711        &self,
1712        uri: &str,
1713        ctx: &dyn camel_component_api::ComponentContext,
1714    ) -> Result<Box<dyn Endpoint>, CamelError> {
1715        self.config.validate()?;
1716        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
1717        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
1718        let client = build_client(&self.config, config.cookie_handling, None);
1719        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
1720            server_config.host.clone(),
1721            server_config.port,
1722        )));
1723        Ok(Box::new(HttpEndpoint {
1724            uri: uri.to_string(),
1725            config,
1726            server_config,
1727            client,
1728            http_config: self.config.clone(),
1729        }))
1730    }
1731}
1732
1733pub struct HttpsComponent {
1734    config: HttpConfig,
1735}
1736
1737impl HttpsComponent {
1738    pub fn new() -> Self {
1739        let config = HttpConfig::default();
1740        Self { config }
1741    }
1742
1743    pub fn with_config(config: HttpConfig) -> Self {
1744        Self { config }
1745    }
1746
1747    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
1748        match config {
1749            Some(cfg) => Self::with_config(cfg),
1750            None => Self::new(),
1751        }
1752    }
1753}
1754
1755impl Default for HttpsComponent {
1756    fn default() -> Self {
1757        Self::new()
1758    }
1759}
1760
1761impl Component for HttpsComponent {
1762    fn scheme(&self) -> &str {
1763        "https"
1764    }
1765
1766    fn metadata(&self) -> ComponentMetadata {
1767        // HTTPS shares the same URI option surface as HTTP.  Only the scheme
1768        // differs; the metadata for `https` simply reuses the HTTP option list
1769        // and capability declaration.
1770        ComponentMetadata {
1771            scheme: "https".to_string(),
1772            version: env!("CARGO_PKG_VERSION").to_string(),
1773            description: "HTTPS client and server component (TLS over HTTP)".to_string(),
1774            uri_syntax: "https://host:port/path?options".to_string(),
1775            capabilities: ComponentCapabilities {
1776                supports_consumer: true,
1777                supports_producer: true,
1778                supports_streaming: true,
1779                ..Default::default()
1780            },
1781            uri_options: vec![
1782                UriOption::new(
1783                    "httpMethod",
1784                    "HTTP method. Defaults to CamelHttpMethod header or POST/GET",
1785                    OptionKind::String,
1786                ),
1787                UriOption::new(
1788                    "throwExceptionOnFailure",
1789                    "Throw on non-2xx status",
1790                    OptionKind::Bool,
1791                )
1792                .with_default("true"),
1793                UriOption::new(
1794                    "okStatusCodeRange",
1795                    "Success status code range",
1796                    OptionKind::String,
1797                )
1798                .with_default("200-299"),
1799                UriOption::new(
1800                    "responseTimeout",
1801                    "Response timeout in milliseconds",
1802                    OptionKind::Int,
1803                ),
1804                UriOption::new(
1805                    "maxBodySize",
1806                    "Max request/response body bytes",
1807                    OptionKind::Int,
1808                )
1809                .with_default("10485760"),
1810                UriOption::new(
1811                    "authMethod",
1812                    "Authentication method",
1813                    OptionKind::Enum(vec!["Basic".to_string(), "Bearer".to_string()]),
1814                ),
1815                UriOption::new("authUsername", "Basic auth username", OptionKind::String).secret(),
1816                UriOption::new("authPassword", "Basic auth password", OptionKind::String).secret(),
1817                UriOption::new("authBearerToken", "Bearer auth token", OptionKind::String).secret(),
1818                UriOption::new("userAgent", "User-Agent header", OptionKind::String),
1819                UriOption::new("followRedirects", "Follow HTTP redirects", OptionKind::Bool)
1820                    .with_default("false"),
1821                UriOption::new("maxRedirects", "Max redirect hops", OptionKind::Int)
1822                    .with_default("10"),
1823            ],
1824            ..ComponentMetadata::minimal("https")
1825        }
1826    }
1827
1828    fn create_endpoint(
1829        &self,
1830        uri: &str,
1831        ctx: &dyn camel_component_api::ComponentContext,
1832    ) -> Result<Box<dyn Endpoint>, CamelError> {
1833        self.config.validate()?;
1834        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
1835        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
1836        let client = build_client(&self.config, config.cookie_handling, None);
1837        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::new(
1838            server_config.host.clone(),
1839            server_config.port,
1840        )));
1841        Ok(Box::new(HttpEndpoint {
1842            uri: uri.to_string(),
1843            config,
1844            server_config,
1845            client,
1846            http_config: self.config.clone(),
1847        }))
1848    }
1849}
1850
1851// ---------------------------------------------------------------------------
1852// HttpEndpoint
1853// ---------------------------------------------------------------------------
1854
1855struct HttpEndpoint {
1856    uri: String,
1857    config: HttpEndpointConfig,
1858    server_config: HttpServerConfig,
1859    client: reqwest::Client,
1860    http_config: HttpConfig,
1861}
1862
1863impl Endpoint for HttpEndpoint {
1864    fn uri(&self) -> &str {
1865        &self.uri
1866    }
1867
1868    fn create_consumer(
1869        &self,
1870        rt: Arc<dyn camel_component_api::RuntimeObservability>,
1871    ) -> Result<Box<dyn Consumer>, CamelError> {
1872        // Scheme/config consistency check (spec §5) — uses parsed scheme
1873        // from HttpServerConfig, not a fragile port-443 heuristic.
1874        let scheme_is_https = self.server_config.scheme == "https";
1875        let has_tls = self.server_config.tls_config.is_some();
1876
1877        if scheme_is_https && !has_tls {
1878            return Err(CamelError::EndpointCreationFailed(
1879                "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
1880            ));
1881        }
1882        if !scheme_is_https && has_tls {
1883            return Err(CamelError::EndpointCreationFailed(
1884                "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
1885            ));
1886        }
1887        Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
1888    }
1889
1890    fn create_producer(
1891        &self,
1892        _rt: Arc<dyn camel_component_api::RuntimeObservability>,
1893        _ctx: &ProducerContext,
1894    ) -> Result<BoxProcessor, CamelError> {
1895        let producer = HttpProducer {
1896            config: Arc::new(self.config.clone()),
1897            client: self.client.clone(),
1898            http_config: Arc::new(self.http_config.clone()),
1899        };
1900        if let Some(ref provider) = self.config.token_provider {
1901            let layer = BearerTokenLayer::new(Arc::clone(provider));
1902            Ok(BoxProcessor::new(layer.layer(producer)))
1903        } else {
1904            Ok(BoxProcessor::new(producer))
1905        }
1906    }
1907}
1908
1909// ---------------------------------------------------------------------------
1910// HttpProducer
1911// ---------------------------------------------------------------------------
1912
1913#[derive(Clone)]
1914struct HttpProducer {
1915    config: Arc<HttpEndpointConfig>,
1916    client: reqwest::Client,
1917    http_config: Arc<HttpConfig>,
1918}
1919
1920impl HttpProducer {
1921    fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
1922        if let Some(ref method) = config.http_method {
1923            return method.to_uppercase();
1924        }
1925        if let Some(method) = exchange
1926            .input
1927            .header("CamelHttpMethod")
1928            .and_then(|v| v.as_str())
1929        {
1930            return method.to_uppercase();
1931        }
1932        if !exchange.input.body.is_empty() {
1933            return "POST".to_string();
1934        }
1935        "GET".to_string()
1936    }
1937
1938    fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
1939        if let Some(uri) = exchange
1940            .input
1941            .header("CamelHttpUri")
1942            .and_then(|v| v.as_str())
1943        {
1944            let mut url = uri.to_string();
1945            if let Some(path) = exchange
1946                .input
1947                .header("CamelHttpPath")
1948                .and_then(|v| v.as_str())
1949            {
1950                if !url.ends_with('/') && !path.starts_with('/') {
1951                    url.push('/');
1952                }
1953                url.push_str(path);
1954            }
1955            if let Some(query) = exchange
1956                .input
1957                .header("CamelHttpQuery")
1958                .and_then(|v| v.as_str())
1959            {
1960                url.push('?');
1961                url.push_str(query);
1962            }
1963            return url;
1964        }
1965
1966        let mut url = config.base_url.clone();
1967
1968        if let Some(path) = exchange
1969            .input
1970            .header("CamelHttpPath")
1971            .and_then(|v| v.as_str())
1972        {
1973            if !url.ends_with('/') && !path.starts_with('/') {
1974                url.push('/');
1975            }
1976            url.push_str(path);
1977        }
1978
1979        if let Some(query) = exchange
1980            .input
1981            .header("CamelHttpQuery")
1982            .and_then(|v| v.as_str())
1983        {
1984            url.push('?');
1985            url.push_str(query);
1986        } else if !config.query_params.is_empty() {
1987            let mut parsed = url::Url::parse(&url).expect("base URL must be valid"); // allow-unwrap
1988            for (k, v) in &config.query_params {
1989                parsed.query_pairs_mut().append_pair(k, v);
1990            }
1991            url = parsed.to_string();
1992        }
1993
1994        url
1995    }
1996
1997    fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
1998        status >= range.0 && status <= range.1
1999    }
2000}
2001
2002impl Service<Exchange> for HttpProducer {
2003    type Response = Exchange;
2004    type Error = CamelError;
2005    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
2006
2007    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
2008        Poll::Ready(Ok(()))
2009    }
2010
2011    fn call(&mut self, mut exchange: Exchange) -> Self::Future {
2012        let config = self.config.clone();
2013        let client = self.client.clone();
2014        let http_config = self.http_config.clone();
2015
2016        Box::pin(async move {
2017            let method_str = HttpProducer::resolve_method(&exchange, &config);
2018            let url = HttpProducer::resolve_url(&exchange, &config);
2019
2020            // SECURITY: Validate URL for SSRF
2021            ssrf::validate_url_for_ssrf(&url, &config)?;
2022
2023            // Resolve hostname and pin validated IPs to prevent DNS-rebinding TOCTOU
2024            // (L-H2). When the URL uses a domain name and SSRF protection is active,
2025            // build a per-request client with resolve_to_addrs so reqwest connects
2026            // directly to the validated addresses without re-resolving DNS.
2027            let resolved = ssrf::resolve_initial_url_for_ssrf(&url, config.allow_internal).await?;
2028            let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
2029                build_client(
2030                    &http_config,
2031                    config.cookie_handling,
2032                    Some((host.as_str(), addrs)),
2033                )
2034            } else {
2035                client
2036            };
2037
2038            debug!(
2039                correlation_id = %exchange.correlation_id(),
2040                method = %method_str,
2041                url = %url,
2042                "HTTP request"
2043            );
2044
2045            let method = method_str.parse::<reqwest::Method>().map_err(|e| {
2046                CamelError::ProcessorError(format!("Invalid HTTP method '{}': {}", method_str, e))
2047            })?;
2048
2049            // Collect headers for potential redirect replay
2050            let mut collected_headers: Vec<(
2051                reqwest::header::HeaderName,
2052                reqwest::header::HeaderValue,
2053            )> = Vec::new();
2054
2055            if let Some(user_agent) = &config.user_agent
2056                && !config.bridge_endpoint
2057                && let Ok(val) = reqwest::header::HeaderValue::from_str(user_agent)
2058            {
2059                collected_headers.push((reqwest::header::USER_AGENT, val));
2060            }
2061
2062            // Inject W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
2063            #[cfg(feature = "otel")]
2064            let should_inject_otel = !config.bridge_endpoint;
2065            #[cfg(feature = "otel")]
2066            if should_inject_otel {
2067                let mut otel_headers = HashMap::new();
2068                camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
2069                for (k, v) in otel_headers {
2070                    if let (Ok(name), Ok(val)) = (
2071                        reqwest::header::HeaderName::from_bytes(k.as_bytes()),
2072                        reqwest::header::HeaderValue::from_str(&v),
2073                    ) {
2074                        collected_headers.push((name, val));
2075                    }
2076                }
2077            }
2078
2079            for (key, value) in &exchange.input.headers {
2080                if !key.starts_with("Camel")
2081                    && !config
2082                        .skip_request_headers
2083                        .iter()
2084                        .any(|h| h.eq_ignore_ascii_case(key))
2085                    && let Some(val_str) = value.as_str()
2086                    && let (Ok(name), Ok(val)) = (
2087                        reqwest::header::HeaderName::from_bytes(key.as_bytes()),
2088                        reqwest::header::HeaderValue::from_str(val_str),
2089                    )
2090                {
2091                    collected_headers.push((name, val));
2092                }
2093            }
2094
2095            // Auth headers
2096            if !config.bridge_endpoint {
2097                match &config.auth {
2098                    HttpAuth::None => {}
2099                    HttpAuth::Basic { username, password } => {
2100                        use base64::Engine;
2101                        // allow-secret: credentials combined for base64 Basic auth header
2102                        let credentials = format!("{username}:{password}");
2103                        let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
2104                        if let Ok(val) =
2105                            reqwest::header::HeaderValue::from_str(&format!("Basic {encoded}"))
2106                        {
2107                            collected_headers.push((reqwest::header::AUTHORIZATION, val));
2108                        }
2109                    }
2110                    HttpAuth::Bearer { token } => {
2111                        // allow-secret: Bearer token in Authorization header
2112                        let bearer = format!("Bearer {token}");
2113                        if let Ok(val) = reqwest::header::HeaderValue::from_str(&bearer) {
2114                            collected_headers.push((reqwest::header::AUTHORIZATION, val));
2115                        }
2116                    }
2117                }
2118
2119                if config.connection_close
2120                    && let Ok(val) = reqwest::header::HeaderValue::from_str("close")
2121                {
2122                    collected_headers.push((reqwest::header::CONNECTION, val));
2123                }
2124            }
2125
2126            // Materialize body
2127            let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
2128            let materialized_body: Option<Vec<u8>> = if is_stream_body {
2129                None // Streams can't be replayed on redirect
2130            } else {
2131                let body = std::mem::take(&mut exchange.input.body);
2132                let bytes = body.into_bytes(config.max_body_size).await?;
2133                if bytes.is_empty() {
2134                    None
2135                } else {
2136                    Some(bytes.to_vec())
2137                }
2138            };
2139
2140            let response = if config.follow_redirects && !is_stream_body {
2141                // Use manual redirect loop with per-hop SSRF validation
2142                ssrf::send_with_ssrf_safe_redirects(
2143                    &client,
2144                    &http_config,
2145                    &config,
2146                    method,
2147                    &url,
2148                    collected_headers,
2149                    materialized_body,
2150                    config.max_redirects,
2151                    config.response_timeout,
2152                )
2153                .await?
2154            } else {
2155                // Direct send (no redirect following, or streaming body)
2156                let mut request = client.request(method, &url);
2157
2158                if let Some(timeout) = config.response_timeout {
2159                    request = request.timeout(timeout);
2160                }
2161
2162                for (name, value) in &collected_headers {
2163                    request = request.header(name, value);
2164                }
2165
2166                if is_stream_body {
2167                    if let Body::Stream(ref s) = exchange.input.body {
2168                        let mut stream_lock = s.stream.lock().await;
2169                        if let Some(stream) = stream_lock.take() {
2170                            request = request.body(reqwest::Body::wrap_stream(stream));
2171                        } else {
2172                            return Err(CamelError::AlreadyConsumed);
2173                        }
2174                    }
2175                } else if let Some(ref body_bytes) = materialized_body {
2176                    request = request.body(body_bytes.clone());
2177                }
2178
2179                request
2180                    .send()
2181                    .await
2182                    .map_err(|e| CamelError::ProcessorError(format!("HTTP request failed: {e}")))?
2183            };
2184
2185            let status_code = response.status().as_u16();
2186            let status_text = response
2187                .status()
2188                .canonical_reason()
2189                .unwrap_or("Unknown")
2190                .to_string();
2191
2192            for (key, value) in response.headers() {
2193                if config
2194                    .skip_response_headers
2195                    .iter()
2196                    .any(|h| h.eq_ignore_ascii_case(key.as_str()))
2197                {
2198                    continue;
2199                }
2200                if let Ok(val_str) = value.to_str() {
2201                    exchange.input.set_header(
2202                        title_case_header(key.as_str()),
2203                        serde_json::Value::String(val_str.to_string()),
2204                    );
2205                }
2206            }
2207
2208            exchange.input.set_header(
2209                "CamelHttpResponseCode",
2210                serde_json::Value::Number(status_code.into()),
2211            );
2212            exchange.input.set_header(
2213                "CamelHttpResponseText",
2214                serde_json::Value::String(status_text.clone()),
2215            );
2216
2217            // Read response body with timeout and size guard (HTTP-004, HTTP-005)
2218            let read_timeout = Duration::from_millis(config.read_timeout_ms);
2219            let response_body = tokio::time::timeout(read_timeout, async {
2220                // Check Content-Length header before allocating
2221                if let Some(content_len) = response.content_length()
2222                    && content_len > config.max_response_bytes as u64
2223                {
2224                    return Err(CamelError::ProcessorError(format!(
2225                        "Response body too large: {} bytes exceeds limit of {} bytes",
2226                        content_len, config.max_response_bytes
2227                    )));
2228                }
2229                // Use bytes_stream() for lazy streaming with size guard
2230                use futures::TryStreamExt;
2231                let mut stream = response.bytes_stream();
2232                let mut total: usize = 0;
2233                let mut collected = Vec::new();
2234                while let Some(chunk) = stream.try_next().await.map_err(|e| {
2235                    CamelError::ProcessorError(format!("Failed to read response body: {e}"))
2236                })? {
2237                    total += chunk.len();
2238                    if total > config.max_response_bytes {
2239                        return Err(CamelError::ProcessorError(format!(
2240                            "Response body too large: {} bytes exceeds limit of {} bytes",
2241                            total, config.max_response_bytes
2242                        )));
2243                    }
2244                    collected.push(chunk);
2245                }
2246                let mut result = bytes::BytesMut::with_capacity(total);
2247                for chunk in collected {
2248                    result.extend_from_slice(&chunk);
2249                }
2250                Ok::<bytes::Bytes, CamelError>(result.freeze())
2251            })
2252            .await
2253            .map_err(|_| {
2254                CamelError::ProcessorError(format!(
2255                    "Read timeout after {}ms",
2256                    config.read_timeout_ms
2257                ))
2258            })??;
2259
2260            if config.throw_exception_on_failure
2261                && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
2262            {
2263                return Err(CamelError::HttpOperationFailed {
2264                    method: method_str,
2265                    url,
2266                    status_code,
2267                    status_text,
2268                    response_body: Some(String::from_utf8_lossy(&response_body).to_string()),
2269                });
2270            }
2271
2272            if !response_body.is_empty() {
2273                exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
2274            }
2275
2276            debug!(
2277                correlation_id = %exchange.correlation_id(),
2278                status = status_code,
2279                url = %url,
2280                "HTTP response"
2281            );
2282            Ok(exchange)
2283        })
2284    }
2285}
2286
2287/// Serializes tests that mutate or depend on the global `ServerRegistry`.
2288///
2289/// `ServerRegistry::global()` is a process-wide singleton that persists
2290/// across tests. `ServerRegistry::reset()` clears ALL entries; if it races
2291/// with another test that has a live server on a fixed port (e.g. 9991),
2292/// the registry entry is removed while the OS socket is still bound, so
2293/// the next `get_or_spawn` call on that port fails with "Address already
2294/// in use". Holding this mutex for the full body of each affected test
2295/// prevents the race without requiring `--test-threads=1`.
2296#[cfg(test)]
2297pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
2298
2299/// Map a pipeline error to an HTTP reply.
2300///
2301/// Extracted from the inline `match` in `dispatch_handler` for unit
2302/// testability (rc-1dk4). `TypeConversionFailed` (e.g. malformed JSON
2303/// body) maps to `400 Bad Request` with a structured JSON error body;
2304/// `Unauthenticated`/`Unauthorized` keep their existing `401`/`403`
2305/// mappings; all other errors map to `500 Internal Server Error`.
2306fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
2307    match e {
2308        CamelError::Unauthenticated(msg) => {
2309            tracing::warn!(error = %msg, path = %path, "Authentication failed");
2310            HttpReply {
2311                status: 401,
2312                headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
2313                body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
2314            }
2315        }
2316        CamelError::Unauthorized(msg) => {
2317            tracing::warn!(error = %msg, path = %path, "Authorization failed");
2318            HttpReply {
2319                status: 403,
2320                headers: vec![],
2321                body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
2322            }
2323        }
2324        CamelError::TypeConversionFailed(msg) => {
2325            tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
2326            let body = serde_json::to_string(&serde_json::json!({
2327                "error": "bad_request",
2328                "message": msg,
2329            }))
2330            .unwrap_or_else(|_| "{}".to_string()); // allow-unwrap
2331            HttpReply {
2332                status: 400,
2333                headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2334                body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2335            }
2336        }
2337        CamelError::ValidationError(msg) => {
2338            tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
2339            let body = serde_json::to_string(&serde_json::json!({
2340                "error": "validation_error",
2341                "message": msg,
2342            }))
2343            .unwrap_or_else(|_| "{}".to_string()); // allow-unwrap
2344            HttpReply {
2345                status: 400,
2346                headers: vec![("Content-Type".to_string(), "application/json".to_string())],
2347                body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
2348            }
2349        }
2350        CamelError::ConsumerStopping => {
2351            tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
2352            HttpReply {
2353                status: 503,
2354                headers: vec![],
2355                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
2356            }
2357        }
2358        e => {
2359            // log-policy: handler-owned
2360            tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
2361            HttpReply {
2362                status: 500,
2363                headers: vec![],
2364                body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
2365            }
2366        }
2367    }
2368}
2369
2370#[cfg(test)]
2371mod tests {
2372    use camel_component_api::test_support::{NoopRuntimeObservability, PanicRuntimeObservability};
2373    fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2374        std::sync::Arc::new(PanicRuntimeObservability)
2375    }
2376    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2377        std::sync::Arc::new(PanicRuntimeObservability)
2378    }
2379    fn noop_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
2380        std::sync::Arc::new(NoopRuntimeObservability)
2381    }
2382
2383    use super::*;
2384    use crate::rest_match::PathSegment;
2385    use camel_component_api::{Message, NoOpComponentContext};
2386    use std::sync::Arc;
2387    use std::time::Duration;
2388
2389    fn test_producer_ctx() -> ProducerContext {
2390        ProducerContext::new()
2391    }
2392
2393    #[test]
2394    fn test_http_config_defaults() {
2395        let config = HttpEndpointConfig::from_uri("http://localhost:8080/api").unwrap();
2396        assert_eq!(config.base_url, "http://localhost:8080/api");
2397        assert!(config.http_method.is_none());
2398        assert!(config.throw_exception_on_failure);
2399        assert_eq!(config.ok_status_code_range, (200, 299));
2400        assert!(config.response_timeout.is_none());
2401        assert!(matches!(config.auth, HttpAuth::None));
2402        assert!(matches!(config.cookie_handling, CookieHandling::Disabled));
2403        assert!(!config.bridge_endpoint);
2404        assert!(!config.connection_close);
2405    }
2406
2407    #[test]
2408    fn test_http_config_scheme() {
2409        // UriConfig trait method returns "http" as primary scheme
2410        assert_eq!(HttpEndpointConfig::scheme(), "http");
2411    }
2412
2413    #[test]
2414    fn test_http_config_from_components() {
2415        // Test from_components directly (trait method)
2416        let components = camel_component_api::UriComponents {
2417            scheme: "https".to_string(),
2418            path: "//api.example.com/v1".to_string(),
2419            params: std::collections::HashMap::from([(
2420                "httpMethod".to_string(),
2421                "POST".to_string(),
2422            )]),
2423        };
2424        let config = HttpEndpointConfig::from_components(components).unwrap();
2425        assert_eq!(config.base_url, "https://api.example.com/v1");
2426        assert_eq!(config.http_method, Some("POST".to_string()));
2427    }
2428
2429    #[test]
2430    fn test_http_config_with_options() {
2431        let config = HttpEndpointConfig::from_uri(
2432            "https://api.example.com/v1?httpMethod=PUT&throwExceptionOnFailure=false&followRedirects=true&connectTimeout=5000&responseTimeout=10000"
2433        ).unwrap();
2434        assert_eq!(config.base_url, "https://api.example.com/v1");
2435        assert_eq!(config.http_method, Some("PUT".to_string()));
2436        assert!(!config.throw_exception_on_failure);
2437        assert_eq!(config.response_timeout, Some(Duration::from_millis(10000)));
2438    }
2439
2440    #[test]
2441    fn test_http_endpoint_config_auth_and_headers_options() {
2442        let config = HttpEndpointConfig::from_uri(
2443            "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",
2444        )
2445        .unwrap();
2446
2447        assert!(matches!(
2448            config.auth,
2449            HttpAuth::Basic { username, password } if username == "u" && password == "p"
2450        ));
2451        assert_eq!(config.user_agent.as_deref(), Some("camel-test"));
2452        assert!(matches!(config.cookie_handling, CookieHandling::InMemory));
2453        assert!(config.bridge_endpoint);
2454        assert!(config.connection_close);
2455        assert_eq!(
2456            config.skip_request_headers,
2457            vec!["authorization".to_string(), "x-secret".to_string()]
2458        );
2459        assert_eq!(config.skip_response_headers, vec!["set-cookie".to_string()]);
2460    }
2461
2462    #[test]
2463    fn test_http_endpoint_config_bearer_auth() {
2464        let config = HttpEndpointConfig::from_uri(
2465            "http://localhost/api?authMethod=Bearer&authBearerToken=t",
2466        )
2467        .unwrap();
2468        assert!(matches!(
2469            config.auth,
2470            HttpAuth::Bearer { token } if token == "t"
2471        ));
2472    }
2473
2474    #[test]
2475    fn test_from_uri_with_defaults_applies_config_when_uri_param_absent() {
2476        let config = HttpConfig::default()
2477            .with_response_timeout_ms(999)
2478            .with_allow_internal(true)
2479            .with_blocked_hosts(vec!["evil.com".to_string()])
2480            .with_max_body_size(12345);
2481        let endpoint =
2482            HttpEndpointConfig::from_uri_with_defaults("http://example.com/api", &config).unwrap();
2483        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(999)));
2484        assert!(endpoint.allow_internal);
2485        assert_eq!(endpoint.blocked_hosts, vec!["evil.com".to_string()]);
2486        assert_eq!(endpoint.max_body_size, 12345);
2487    }
2488
2489    #[test]
2490    fn test_from_uri_with_defaults_uri_overrides_config() {
2491        let config = HttpConfig::default()
2492            .with_response_timeout_ms(999)
2493            .with_allow_internal(true)
2494            .with_blocked_hosts(vec!["evil.com".to_string()])
2495            .with_max_body_size(12345);
2496        let endpoint = HttpEndpointConfig::from_uri_with_defaults(
2497            "http://example.com/api?responseTimeout=500&allowInternal=false&blockedHosts=bad.net&maxBodySize=99",
2498            &config,
2499        )
2500        .unwrap();
2501        assert_eq!(endpoint.response_timeout, Some(Duration::from_millis(500)));
2502        assert!(!endpoint.allow_internal);
2503        assert_eq!(endpoint.blocked_hosts, vec!["bad.net".to_string()]);
2504        assert_eq!(endpoint.max_body_size, 99);
2505    }
2506
2507    #[test]
2508    fn test_http_config_ok_status_range() {
2509        let config =
2510            HttpEndpointConfig::from_uri("http://localhost/api?okStatusCodeRange=200-204").unwrap();
2511        assert_eq!(config.ok_status_code_range, (200, 204));
2512    }
2513
2514    #[test]
2515    fn test_http_config_wrong_scheme() {
2516        let result = HttpEndpointConfig::from_uri("file:/tmp");
2517        assert!(result.is_err());
2518    }
2519
2520    #[test]
2521    fn test_http_component_scheme() {
2522        let component = HttpComponent::new();
2523        assert_eq!(component.scheme(), "http");
2524    }
2525
2526    #[test]
2527    fn test_https_component_scheme() {
2528        let component = HttpsComponent::new();
2529        assert_eq!(component.scheme(), "https");
2530    }
2531
2532    #[test]
2533    fn test_http_endpoint_creates_consumer() {
2534        let component = HttpComponent::new();
2535        let ctx = NoOpComponentContext;
2536        let endpoint = component
2537            .create_endpoint("http://0.0.0.0:19100/test", &ctx)
2538            .unwrap();
2539        assert!(endpoint.create_consumer(rt()).is_ok());
2540    }
2541
2542    #[test]
2543    fn test_https_endpoint_creates_consumer_errors_without_tls() {
2544        let component = HttpsComponent::new();
2545        let ctx = NoOpComponentContext;
2546        let endpoint = component
2547            .create_endpoint("https://0.0.0.0:8443/test", &ctx)
2548            .unwrap();
2549        // https:// without tlsCert/tlsKey must fail (scheme enforcement)
2550        assert!(endpoint.create_consumer(rt()).is_err());
2551    }
2552
2553    #[test]
2554    fn test_http_endpoint_creates_producer() {
2555        let ctx = test_producer_ctx();
2556        let component = HttpComponent::new();
2557        let endpoint_ctx = NoOpComponentContext;
2558        let endpoint = component
2559            .create_endpoint("http://localhost/api", &endpoint_ctx)
2560            .unwrap();
2561        assert!(endpoint.create_producer(rt(), &ctx).is_ok());
2562    }
2563
2564    // -----------------------------------------------------------------------
2565    // Producer tests
2566    // -----------------------------------------------------------------------
2567
2568    #[tokio::test]
2569    async fn test_producer_with_token_provider() {
2570        use camel_auth::oauth2::TokenProvider;
2571        use tower::ServiceExt;
2572
2573        let captured_auth: Arc<std::sync::Mutex<Option<String>>> =
2574            Arc::new(std::sync::Mutex::new(None));
2575        let captured_clone = Arc::clone(&captured_auth);
2576
2577        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2578        let port = listener.local_addr().unwrap().port();
2579
2580        let _handle = tokio::spawn(async move {
2581            use tokio::io::{AsyncReadExt, AsyncWriteExt};
2582            if let Ok((mut stream, _)) = listener.accept().await {
2583                let mut buf = vec![0u8; 8192];
2584                let n = stream.read(&mut buf).await.unwrap_or(0);
2585                let request = String::from_utf8_lossy(&buf[..n]).to_string();
2586                let auth = request
2587                    .lines()
2588                    .find(|l| l.to_lowercase().starts_with("authorization:"))
2589                    .map(|l| {
2590                        l.split(':')
2591                            .nth(1)
2592                            .map(|s| s.trim().to_string())
2593                            .unwrap_or_default()
2594                    });
2595                *captured_clone.lock().unwrap() = auth;
2596                let body = r#"{"echo":"ok"}"#;
2597                let resp = format!(
2598                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
2599                    body.len(),
2600                    body
2601                );
2602                let _ = stream.write_all(resp.as_bytes()).await;
2603            }
2604        });
2605
2606        #[derive(Debug)]
2607        struct StaticProvider;
2608        #[async_trait::async_trait]
2609        impl TokenProvider for StaticProvider {
2610            async fn get_token(&self) -> Result<String, camel_auth::types::AuthError> {
2611                Ok("injected-token".into())
2612            }
2613        }
2614
2615        let uri = format!("http://127.0.0.1:{}/api?allowInternal=true", port);
2616        let ctx = test_producer_ctx();
2617        let component = HttpComponent::new();
2618        let endpoint_ctx = NoOpComponentContext;
2619        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
2620        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2621
2622        let exchange = Exchange::new(Message::new("hello"));
2623
2624        let layer = BearerTokenLayer::new(Arc::new(StaticProvider));
2625        let mut layered = layer.layer(producer);
2626        let result = layered.ready().await.unwrap().call(exchange).await;
2627        assert!(result.is_ok(), "producer call failed: {:?}", result);
2628
2629        tokio::time::sleep(Duration::from_millis(100)).await;
2630        let auth = captured_auth.lock().unwrap().take();
2631        assert_eq!(auth.as_deref(), Some("Bearer injected-token"));
2632    }
2633
2634    async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
2635        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2636        let addr = listener.local_addr().unwrap();
2637        let url = format!("http://127.0.0.1:{}", addr.port());
2638
2639        let handle = tokio::spawn(async move {
2640            loop {
2641                if let Ok((mut stream, _)) = listener.accept().await {
2642                    tokio::spawn(async move {
2643                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
2644                        let mut buf = vec![0u8; 4096];
2645                        let n = stream.read(&mut buf).await.unwrap_or(0);
2646                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
2647
2648                        let method = request.split_whitespace().next().unwrap_or("GET");
2649
2650                        let body = format!(r#"{{"method":"{}","echo":"ok"}}"#, method);
2651                        let response = format!(
2652                            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Custom: test-value\r\n\r\n{}",
2653                            body.len(),
2654                            body
2655                        );
2656                        let _ = stream.write_all(response.as_bytes()).await;
2657                    });
2658                }
2659            }
2660        });
2661
2662        (url, handle)
2663    }
2664
2665    async fn start_status_server(status: u16) -> (String, tokio::task::JoinHandle<()>) {
2666        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2667        let addr = listener.local_addr().unwrap();
2668        let url = format!("http://127.0.0.1:{}", addr.port());
2669
2670        let handle = tokio::spawn(async move {
2671            loop {
2672                if let Ok((mut stream, _)) = listener.accept().await {
2673                    let status = status;
2674                    tokio::spawn(async move {
2675                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
2676                        let mut buf = vec![0u8; 4096];
2677                        let _ = stream.read(&mut buf).await;
2678
2679                        let status_text = match status {
2680                            404 => "Not Found",
2681                            500 => "Internal Server Error",
2682                            _ => "Error",
2683                        };
2684                        let body = "error body";
2685                        let response = format!(
2686                            "HTTP/1.1 {} {}\r\nContent-Length: {}\r\n\r\n{}",
2687                            status,
2688                            status_text,
2689                            body.len(),
2690                            body
2691                        );
2692                        let _ = stream.write_all(response.as_bytes()).await;
2693                    });
2694                }
2695            }
2696        });
2697
2698        (url, handle)
2699    }
2700
2701    #[tokio::test]
2702    async fn test_http_producer_get_request() {
2703        use tower::ServiceExt;
2704
2705        let (url, _handle) = start_test_server().await;
2706        let ctx = test_producer_ctx();
2707
2708        let component = HttpComponent::new();
2709        let endpoint_ctx = NoOpComponentContext;
2710        let endpoint = component
2711            .create_endpoint(&format!("{url}/api/test?allowInternal=true"), &endpoint_ctx)
2712            .unwrap();
2713        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2714
2715        let exchange = Exchange::new(Message::default());
2716        let result = producer.oneshot(exchange).await.unwrap();
2717
2718        let status = result
2719            .input
2720            .header("CamelHttpResponseCode")
2721            .and_then(|v| v.as_u64())
2722            .unwrap();
2723        assert_eq!(status, 200);
2724
2725        assert!(!result.input.body.is_empty());
2726    }
2727
2728    #[tokio::test]
2729    async fn test_http_producer_post_with_body() {
2730        use tower::ServiceExt;
2731
2732        let (url, _handle) = start_test_server().await;
2733        let ctx = test_producer_ctx();
2734
2735        let component = HttpComponent::new();
2736        let endpoint_ctx = NoOpComponentContext;
2737        let endpoint = component
2738            .create_endpoint(&format!("{url}/api/data?allowInternal=true"), &endpoint_ctx)
2739            .unwrap();
2740        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2741
2742        let exchange = Exchange::new(Message::new("request body"));
2743        let result = producer.oneshot(exchange).await.unwrap();
2744
2745        let status = result
2746            .input
2747            .header("CamelHttpResponseCode")
2748            .and_then(|v| v.as_u64())
2749            .unwrap();
2750        assert_eq!(status, 200);
2751    }
2752
2753    #[tokio::test]
2754    async fn test_http_producer_method_from_header() {
2755        use tower::ServiceExt;
2756
2757        let (url, _handle) = start_test_server().await;
2758        let ctx = test_producer_ctx();
2759
2760        let component = HttpComponent::new();
2761        let endpoint_ctx = NoOpComponentContext;
2762        let endpoint = component
2763            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
2764            .unwrap();
2765        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2766
2767        let mut exchange = Exchange::new(Message::default());
2768        exchange.input.set_header(
2769            "CamelHttpMethod",
2770            serde_json::Value::String("DELETE".to_string()),
2771        );
2772
2773        let result = producer.oneshot(exchange).await.unwrap();
2774        let status = result
2775            .input
2776            .header("CamelHttpResponseCode")
2777            .and_then(|v| v.as_u64())
2778            .unwrap();
2779        assert_eq!(status, 200);
2780    }
2781
2782    #[tokio::test]
2783    async fn test_http_producer_forced_method() {
2784        use tower::ServiceExt;
2785
2786        let (url, _handle) = start_test_server().await;
2787        let ctx = test_producer_ctx();
2788
2789        let component = HttpComponent::new();
2790        let endpoint_ctx = NoOpComponentContext;
2791        let endpoint = component
2792            .create_endpoint(
2793                &format!("{url}/api?httpMethod=PUT&allowInternal=true"),
2794                &endpoint_ctx,
2795            )
2796            .unwrap();
2797        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2798
2799        let exchange = Exchange::new(Message::default());
2800        let result = producer.oneshot(exchange).await.unwrap();
2801
2802        let status = result
2803            .input
2804            .header("CamelHttpResponseCode")
2805            .and_then(|v| v.as_u64())
2806            .unwrap();
2807        assert_eq!(status, 200);
2808    }
2809
2810    #[tokio::test]
2811    async fn test_http_producer_throw_exception_on_failure() {
2812        use tower::ServiceExt;
2813
2814        let (url, _handle) = start_status_server(404).await;
2815        let ctx = test_producer_ctx();
2816
2817        let component = HttpComponent::new();
2818        let endpoint_ctx = NoOpComponentContext;
2819        let endpoint = component
2820            .create_endpoint(
2821                &format!("{url}/not-found?allowInternal=true"),
2822                &endpoint_ctx,
2823            )
2824            .unwrap();
2825        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2826
2827        let exchange = Exchange::new(Message::default());
2828        let result = producer.oneshot(exchange).await;
2829        assert!(result.is_err());
2830
2831        match result.unwrap_err() {
2832            CamelError::HttpOperationFailed { status_code, .. } => {
2833                assert_eq!(status_code, 404);
2834            }
2835            e => panic!("Expected HttpOperationFailed, got: {e}"),
2836        }
2837    }
2838
2839    #[tokio::test]
2840    async fn test_http_producer_no_throw_on_failure() {
2841        use tower::ServiceExt;
2842
2843        let (url, _handle) = start_status_server(500).await;
2844        let ctx = test_producer_ctx();
2845
2846        let component = HttpComponent::new();
2847        let endpoint_ctx = NoOpComponentContext;
2848        let endpoint = component
2849            .create_endpoint(
2850                &format!("{url}/error?throwExceptionOnFailure=false&allowInternal=true"),
2851                &endpoint_ctx,
2852            )
2853            .unwrap();
2854        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2855
2856        let exchange = Exchange::new(Message::default());
2857        let result = producer.oneshot(exchange).await.unwrap();
2858
2859        let status = result
2860            .input
2861            .header("CamelHttpResponseCode")
2862            .and_then(|v| v.as_u64())
2863            .unwrap();
2864        assert_eq!(status, 500);
2865    }
2866
2867    #[tokio::test]
2868    async fn test_http_producer_uri_override() {
2869        use tower::ServiceExt;
2870
2871        let (url, _handle) = start_test_server().await;
2872        let ctx = test_producer_ctx();
2873
2874        let component = HttpComponent::new();
2875        let endpoint_ctx = NoOpComponentContext;
2876        let endpoint = component
2877            .create_endpoint(
2878                "http://localhost:1/does-not-exist?allowInternal=true",
2879                &endpoint_ctx,
2880            )
2881            .unwrap();
2882        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2883
2884        let mut exchange = Exchange::new(Message::default());
2885        exchange.input.set_header(
2886            "CamelHttpUri",
2887            serde_json::Value::String(format!("{url}/api")),
2888        );
2889
2890        let result = producer.oneshot(exchange).await.unwrap();
2891        let status = result
2892            .input
2893            .header("CamelHttpResponseCode")
2894            .and_then(|v| v.as_u64())
2895            .unwrap();
2896        assert_eq!(status, 200);
2897    }
2898
2899    #[tokio::test]
2900    async fn test_http_producer_response_headers_mapped() {
2901        use tower::ServiceExt;
2902
2903        let (url, _handle) = start_test_server().await;
2904        let ctx = test_producer_ctx();
2905
2906        let component = HttpComponent::new();
2907        let endpoint_ctx = NoOpComponentContext;
2908        let endpoint = component
2909            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
2910            .unwrap();
2911        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2912
2913        let exchange = Exchange::new(Message::default());
2914        let result = producer.oneshot(exchange).await.unwrap();
2915
2916        assert!(
2917            result.input.header("Content-Type").is_some(),
2918            "Response should have Content-Type header"
2919        );
2920        assert!(result.input.header("CamelHttpResponseText").is_some());
2921    }
2922
2923    // -----------------------------------------------------------------------
2924    // Bug fix tests: Client configuration per-endpoint
2925    // -----------------------------------------------------------------------
2926
2927    async fn start_redirect_server() -> (String, tokio::task::JoinHandle<()>) {
2928        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2929        let addr = listener.local_addr().unwrap();
2930        let url = format!("http://127.0.0.1:{}", addr.port());
2931
2932        let handle = tokio::spawn(async move {
2933            use tokio::io::{AsyncReadExt, AsyncWriteExt};
2934            loop {
2935                if let Ok((mut stream, _)) = listener.accept().await {
2936                    tokio::spawn(async move {
2937                        let mut buf = vec![0u8; 4096];
2938                        let n = stream.read(&mut buf).await.unwrap_or(0);
2939                        let request = String::from_utf8_lossy(&buf[..n]).to_string();
2940
2941                        // Check if this is a request to /final
2942                        if request.contains("GET /final") {
2943                            let body = r#"{"status":"final"}"#;
2944                            let response = format!(
2945                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
2946                                body.len(),
2947                                body
2948                            );
2949                            let _ = stream.write_all(response.as_bytes()).await;
2950                        } else {
2951                            // Redirect to /final
2952                            let response = "HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\n\r\n";
2953                            let _ = stream.write_all(response.as_bytes()).await;
2954                        }
2955                    });
2956                }
2957            }
2958        });
2959
2960        (url, handle)
2961    }
2962
2963    #[tokio::test]
2964    async fn test_follow_redirects_false_does_not_follow() {
2965        use tower::ServiceExt;
2966
2967        let (url, _handle) = start_redirect_server().await;
2968        let ctx = test_producer_ctx();
2969
2970        let component =
2971            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(false));
2972        let endpoint_ctx = NoOpComponentContext;
2973        let endpoint = component
2974            .create_endpoint(
2975                &format!("{url}?throwExceptionOnFailure=false&allowInternal=true"),
2976                &endpoint_ctx,
2977            )
2978            .unwrap();
2979        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
2980
2981        let exchange = Exchange::new(Message::default());
2982        let result = producer.oneshot(exchange).await.unwrap();
2983
2984        // Should get 302, NOT follow redirect to 200
2985        let status = result
2986            .input
2987            .header("CamelHttpResponseCode")
2988            .and_then(|v| v.as_u64())
2989            .unwrap();
2990        assert_eq!(
2991            status, 302,
2992            "Should NOT follow redirect when followRedirects=false"
2993        );
2994    }
2995
2996    #[tokio::test]
2997    async fn test_follow_redirects_true_follows_redirect() {
2998        use tower::ServiceExt;
2999
3000        let (url, _handle) = start_redirect_server().await;
3001        let ctx = test_producer_ctx();
3002
3003        let component =
3004            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3005        let endpoint_ctx = NoOpComponentContext;
3006        let endpoint = component
3007            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3008            .unwrap();
3009        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3010
3011        let exchange = Exchange::new(Message::default());
3012        let result = producer.oneshot(exchange).await.unwrap();
3013
3014        // Should follow redirect and get 200
3015        let status = result
3016            .input
3017            .header("CamelHttpResponseCode")
3018            .and_then(|v| v.as_u64())
3019            .unwrap();
3020        assert_eq!(
3021            status, 200,
3022            "Should follow redirect when followRedirects=true"
3023        );
3024    }
3025
3026    /// Integration test: with allowInternal=true, redirects to private IPs are followed.
3027    /// This verifies the manual redirect loop executes correctly.
3028    #[tokio::test]
3029    async fn test_redirect_to_private_ip_is_ssrf_blocked() {
3030        use tower::ServiceExt;
3031
3032        // Use the existing redirect server which redirects to /final on the same server
3033        let (url, _handle) = start_redirect_server().await;
3034        let ctx = test_producer_ctx();
3035
3036        let component =
3037            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3038        let endpoint_ctx = NoOpComponentContext;
3039        let endpoint = component
3040            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3041            .unwrap();
3042        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3043
3044        let exchange = Exchange::new(Message::default());
3045        let result = producer.oneshot(exchange).await;
3046
3047        // With allowInternal=true, the redirect should succeed
3048        assert!(
3049            result.is_ok(),
3050            "Redirect should succeed with allowInternal=true, got: {:?}",
3051            result
3052        );
3053        let exchange = result.unwrap();
3054        let status = exchange
3055            .input
3056            .header("CamelHttpResponseCode")
3057            .and_then(|v| v.as_u64())
3058            .unwrap();
3059        assert_eq!(status, 200, "Should follow redirect to /final");
3060    }
3061
3062    /// With allowInternal=true, redirects to private IPs should be followed.
3063    #[tokio::test]
3064    async fn test_redirect_to_private_ip_allowed_when_configured() {
3065        use tower::ServiceExt;
3066
3067        // Start a server that redirects to /final on the same server (127.0.0.1)
3068        let (url, _handle) = start_redirect_server().await;
3069        let ctx = test_producer_ctx();
3070
3071        let component =
3072            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3073        let endpoint_ctx = NoOpComponentContext;
3074        let endpoint = component
3075            .create_endpoint(&format!("{url}?allowInternal=true"), &endpoint_ctx)
3076            .unwrap();
3077        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3078
3079        let exchange = Exchange::new(Message::default());
3080        let result = producer.oneshot(exchange).await.unwrap();
3081
3082        let status = result
3083            .input
3084            .header("CamelHttpResponseCode")
3085            .and_then(|v| v.as_u64())
3086            .unwrap();
3087        assert_eq!(
3088            status, 200,
3089            "Should follow redirect to private IP when allowInternal=true"
3090        );
3091    }
3092
3093    /// Integration test: with allowInternal=false (default), a redirect to a
3094    /// private/metadata IP must be blocked by the SSRF guard — NOT followed.
3095    #[tokio::test]
3096    async fn test_redirect_to_private_ip_blocked_when_ssrf_guard_active() {
3097        use tower::ServiceExt;
3098
3099        // Server that redirects to the AWS metadata endpoint (link-local private IP)
3100        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3101        let addr = listener.local_addr().unwrap();
3102        let url = format!("http://127.0.0.1:{}", addr.port());
3103
3104        let handle = tokio::spawn(async move {
3105            use tokio::io::{AsyncReadExt, AsyncWriteExt};
3106            loop {
3107                if let Ok((mut stream, _)) = listener.accept().await {
3108                    tokio::spawn(async move {
3109                        let mut buf = vec![0u8; 4096];
3110                        let _ = stream.read(&mut buf).await;
3111                        // Always redirect to the metadata endpoint
3112                        let response = "HTTP/1.1 302 Found\r\nLocation: http://169.254.169.254/latest/meta-data/\r\nContent-Length: 0\r\n\r\n";
3113                        let _ = stream.write_all(response.as_bytes()).await;
3114                    });
3115                }
3116            }
3117        });
3118
3119        let ctx = test_producer_ctx();
3120        let component =
3121            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3122        let endpoint_ctx = NoOpComponentContext;
3123        // allowInternal=false is the default — do NOT set it
3124        let endpoint = component.create_endpoint(&url, &endpoint_ctx).unwrap();
3125        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3126
3127        let exchange = Exchange::new(Message::default());
3128        let result = producer.oneshot(exchange).await;
3129
3130        // Must be an error — SSRF guard blocks the redirect target
3131        assert!(
3132            result.is_err(),
3133            "Redirect to private IP 169.254.169.254 must be blocked when allowInternal=false"
3134        );
3135        let err = result.unwrap_err().to_string();
3136        assert!(
3137            err.contains("blocked IP")
3138                || err.contains("private IP")
3139                || err.contains("SSRF")
3140                || err.contains("not allowed"),
3141            "Error should mention SSRF/IP blocking, got: {err}"
3142        );
3143
3144        handle.abort();
3145    }
3146
3147    /// Integration test: exceeding maxRedirects produces a clear error.
3148    #[tokio::test]
3149    async fn test_too_many_redirects_returns_error() {
3150        use tower::ServiceExt;
3151
3152        // Server that always redirects to itself (infinite loop)
3153        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3154        let addr = listener.local_addr().unwrap();
3155        let url = format!("http://127.0.0.1:{}", addr.port());
3156
3157        let handle = tokio::spawn(async move {
3158            use tokio::io::{AsyncReadExt, AsyncWriteExt};
3159            loop {
3160                if let Ok((mut stream, _)) = listener.accept().await {
3161                    tokio::spawn(async move {
3162                        let mut buf = vec![0u8; 4096];
3163                        let _ = stream.read(&mut buf).await;
3164                        // Always redirect to /loop
3165                        let response =
3166                            "HTTP/1.1 302 Found\r\nLocation: /loop\r\nContent-Length: 0\r\n\r\n";
3167                        let _ = stream.write_all(response.as_bytes()).await;
3168                    });
3169                }
3170            }
3171        });
3172
3173        let ctx = test_producer_ctx();
3174        let component =
3175            HttpComponent::with_config(HttpConfig::default().with_follow_redirects(true));
3176        let endpoint_ctx = NoOpComponentContext;
3177        let endpoint = component
3178            .create_endpoint(
3179                &format!("{url}?allowInternal=true&maxRedirects=2"),
3180                &endpoint_ctx,
3181            )
3182            .unwrap();
3183        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3184
3185        let exchange = Exchange::new(Message::default());
3186        let result = producer.oneshot(exchange).await;
3187
3188        // With the fix, exceeding max redirects returns the redirect response
3189        // as-is instead of erroring. The 302 redirect response is returned
3190        // after followRedirects exhausts the allowed redirect count (2).
3191        // Disable throwExceptionOnFailure to inspect the raw response status.
3192        //
3193        // Old behavior: Err("Too many redirects (max 2)")
3194        // New behavior: Ok(ex) with CamelHttpResponseCode = 302
3195        match result {
3196            Err(e) => {
3197                // If throw_exception_on_failure is on, we get HttpOperationFailed
3198                let msg = e.to_string();
3199                assert!(
3200                    msg.contains("HTTP operation failed") || msg.contains("302"),
3201                    "expected redirect-after-exhaustion error, got: {msg}"
3202                );
3203            }
3204            Ok(ex) => {
3205                let response_code = ex
3206                    .input
3207                    .header("CamelHttpResponseCode")
3208                    .and_then(|v| v.as_u64());
3209                assert_eq!(
3210                    response_code,
3211                    Some(302),
3212                    "expected 302 after exhausting redirects"
3213                );
3214            }
3215        }
3216
3217        handle.abort();
3218    }
3219
3220    #[tokio::test]
3221    async fn test_query_params_forwarded_to_http_request() {
3222        use tower::ServiceExt;
3223
3224        let (url, _handle) = start_test_server().await;
3225        let ctx = test_producer_ctx();
3226
3227        let component = HttpComponent::new();
3228        let endpoint_ctx = NoOpComponentContext;
3229        // apiKey is NOT a Camel option, should be forwarded as query param
3230        let endpoint = component
3231            .create_endpoint(
3232                &format!("{url}/api?apiKey=secret123&httpMethod=GET&allowInternal=true"),
3233                &endpoint_ctx,
3234            )
3235            .unwrap();
3236        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3237
3238        let exchange = Exchange::new(Message::default());
3239        let result = producer.oneshot(exchange).await.unwrap();
3240
3241        // The test server returns the request info in response
3242        // We just verify it succeeds (the query param was sent)
3243        let status = result
3244            .input
3245            .header("CamelHttpResponseCode")
3246            .and_then(|v| v.as_u64())
3247            .unwrap();
3248        assert_eq!(status, 200);
3249    }
3250
3251    #[tokio::test]
3252    async fn test_non_camel_query_params_are_forwarded() {
3253        // This test verifies Bug #3 fix: non-Camel options should be forwarded
3254        // We'll test the config parsing, not the actual HTTP call
3255        let config = HttpEndpointConfig::from_uri(
3256            "http://example.com/api?apiKey=secret123&httpMethod=GET&token=abc456",
3257        )
3258        .unwrap();
3259
3260        // apiKey and token are NOT Camel options, should be forwarded
3261        assert!(
3262            config.query_params.contains_key("apiKey"),
3263            "apiKey should be preserved"
3264        );
3265        assert!(
3266            config.query_params.contains_key("token"),
3267            "token should be preserved"
3268        );
3269        assert_eq!(config.query_params.get("apiKey").unwrap(), "secret123");
3270        assert_eq!(config.query_params.get("token").unwrap(), "abc456");
3271
3272        // httpMethod IS a Camel option, should NOT be in query_params
3273        assert!(
3274            !config.query_params.contains_key("httpMethod"),
3275            "httpMethod should not be forwarded"
3276        );
3277    }
3278
3279    #[test]
3280    fn test_query_params_are_url_encoded_when_resolving_url() {
3281        let config =
3282            HttpEndpointConfig::from_uri("http://example.com/api?q=hello world&tag=a+b").unwrap();
3283        let exchange = Exchange::new(Message::default());
3284
3285        let url = HttpProducer::resolve_url(&exchange, &config);
3286
3287        assert!(url.contains("q=hello+world"), "url was: {url}");
3288        assert!(url.contains("tag=a%2Bb"), "url was: {url}");
3289    }
3290
3291    // -----------------------------------------------------------------------
3292    // Timeout tests (HTTP-004)
3293    // -----------------------------------------------------------------------
3294
3295    async fn start_slow_server(delay_ms: u64) -> (String, tokio::task::JoinHandle<()>) {
3296        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3297        let addr = listener.local_addr().unwrap();
3298        let url = format!("http://127.0.0.1:{}", addr.port());
3299
3300        let handle = tokio::spawn(async move {
3301            loop {
3302                if let Ok((mut stream, _)) = listener.accept().await {
3303                    let delay = delay_ms;
3304                    tokio::spawn(async move {
3305                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
3306                        let mut buf = vec![0u8; 4096];
3307                        let _ = stream.read(&mut buf).await;
3308                        // Send headers immediately (no Content-Length → chunked)
3309                        let headers = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n";
3310                        let _ = stream.write_all(headers.as_bytes()).await;
3311                        // Delay before sending body chunk
3312                        tokio::time::sleep(Duration::from_millis(delay)).await;
3313                        let body = r#"{"status":"slow"}"#;
3314                        let chunk = format!("{:x}\r\n{}\r\n0\r\n\r\n", body.len(), body);
3315                        let _ = stream.write_all(chunk.as_bytes()).await;
3316                    });
3317                }
3318            }
3319        });
3320
3321        (url, handle)
3322    }
3323
3324    #[tokio::test]
3325    async fn test_http_producer_timeout() {
3326        use tower::ServiceExt;
3327
3328        // Server delays 500ms, client timeout is 100ms → should timeout
3329        let (url, _handle) = start_slow_server(500).await;
3330        let ctx = test_producer_ctx();
3331
3332        let component = HttpComponent::with_config(
3333            HttpConfig::default()
3334                .with_read_timeout_ms(100)
3335                .with_response_timeout_ms(30_000), // generous response timeout
3336        );
3337        let endpoint_ctx = NoOpComponentContext;
3338        let endpoint = component
3339            .create_endpoint(&format!("{url}/slow?allowInternal=true"), &endpoint_ctx)
3340            .unwrap();
3341        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3342
3343        let exchange = Exchange::new(Message::default());
3344        let result = producer.oneshot(exchange).await;
3345
3346        assert!(result.is_err(), "Expected timeout error, got: {:?}", result);
3347        let err = result.unwrap_err().to_string();
3348        assert!(
3349            err.contains("Read timeout") || err.contains("timeout"),
3350            "Error should mention timeout, got: {}",
3351            err
3352        );
3353    }
3354
3355    #[tokio::test]
3356    async fn test_http_producer_no_timeout_when_fast() {
3357        use tower::ServiceExt;
3358
3359        let (url, _handle) = start_test_server().await;
3360        let ctx = test_producer_ctx();
3361
3362        let component =
3363            HttpComponent::with_config(HttpConfig::default().with_read_timeout_ms(5_000));
3364        let endpoint_ctx = NoOpComponentContext;
3365        let endpoint = component
3366            .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
3367            .unwrap();
3368        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3369
3370        let exchange = Exchange::new(Message::default());
3371        let result = producer.oneshot(exchange).await.unwrap();
3372
3373        let status = result
3374            .input
3375            .header("CamelHttpResponseCode")
3376            .and_then(|v| v.as_u64())
3377            .unwrap();
3378        assert_eq!(status, 200);
3379    }
3380
3381    // -----------------------------------------------------------------------
3382    // SSRF Protection tests
3383    // -----------------------------------------------------------------------
3384
3385    #[tokio::test]
3386    async fn test_http_producer_blocks_metadata_endpoint() {
3387        use tower::ServiceExt;
3388
3389        let ctx = test_producer_ctx();
3390        let component = HttpComponent::new();
3391        let endpoint_ctx = NoOpComponentContext;
3392        let endpoint = component
3393            .create_endpoint("http://example.com/api?allowInternal=false", &endpoint_ctx)
3394            .unwrap();
3395        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3396
3397        let mut exchange = Exchange::new(Message::default());
3398        exchange.input.set_header(
3399            "CamelHttpUri",
3400            serde_json::Value::String("http://169.254.169.254/latest/meta-data/".to_string()),
3401        );
3402
3403        let result = producer.oneshot(exchange).await;
3404        assert!(result.is_err(), "Should block AWS metadata endpoint");
3405
3406        let err = result.unwrap_err();
3407        assert!(
3408            err.to_string().contains("Private IP"),
3409            "Error should mention private IP blocking, got: {}",
3410            err
3411        );
3412    }
3413
3414    #[test]
3415    fn test_ssrf_config_defaults() {
3416        let config = HttpEndpointConfig::from_uri("http://example.com/api").unwrap();
3417        assert!(
3418            !config.allow_internal,
3419            "Private IPs should be blocked by default"
3420        );
3421        assert!(
3422            config.blocked_hosts.is_empty(),
3423            "Blocked hosts should be empty by default"
3424        );
3425    }
3426
3427    #[test]
3428    fn test_ssrf_config_allow_internal() {
3429        let config =
3430            HttpEndpointConfig::from_uri("http://example.com/api?allowInternal=true").unwrap();
3431        assert!(
3432            config.allow_internal,
3433            "Private IPs should be allowed when explicitly set"
3434        );
3435    }
3436
3437    #[test]
3438    fn test_ssrf_config_blocked_hosts() {
3439        let config = HttpEndpointConfig::from_uri(
3440            "http://example.com/api?blockedHosts=evil.com,malware.net",
3441        )
3442        .unwrap();
3443        assert_eq!(config.blocked_hosts, vec!["evil.com", "malware.net"]);
3444    }
3445
3446    #[tokio::test]
3447    async fn test_http_producer_blocks_localhost() {
3448        use tower::ServiceExt;
3449
3450        let ctx = test_producer_ctx();
3451        let component = HttpComponent::new();
3452        let endpoint_ctx = NoOpComponentContext;
3453        let endpoint = component
3454            .create_endpoint("http://example.com/api", &endpoint_ctx)
3455            .unwrap();
3456        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3457
3458        let mut exchange = Exchange::new(Message::default());
3459        exchange.input.set_header(
3460            "CamelHttpUri",
3461            serde_json::Value::String("http://localhost:8080/internal".to_string()),
3462        );
3463
3464        let result = producer.oneshot(exchange).await;
3465        assert!(result.is_err(), "Should block localhost");
3466    }
3467
3468    #[tokio::test]
3469    async fn test_http_producer_blocks_loopback_ip() {
3470        use tower::ServiceExt;
3471
3472        let ctx = test_producer_ctx();
3473        let component = HttpComponent::new();
3474        let endpoint_ctx = NoOpComponentContext;
3475        let endpoint = component
3476            .create_endpoint("http://example.com/api", &endpoint_ctx)
3477            .unwrap();
3478        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3479
3480        let mut exchange = Exchange::new(Message::default());
3481        exchange.input.set_header(
3482            "CamelHttpUri",
3483            serde_json::Value::String("http://127.0.0.1:8080/internal".to_string()),
3484        );
3485
3486        let result = producer.oneshot(exchange).await;
3487        assert!(result.is_err(), "Should block loopback IP");
3488    }
3489
3490    #[tokio::test]
3491    async fn test_http_producer_allows_private_ip_when_enabled() {
3492        use tower::ServiceExt;
3493
3494        let ctx = test_producer_ctx();
3495        let component = HttpComponent::new();
3496        let endpoint_ctx = NoOpComponentContext;
3497        // With allowInternal=true, the validation should pass
3498        // (actual connection will fail, but that's expected)
3499        let endpoint = component
3500            .create_endpoint("http://192.168.1.1/api?allowInternal=true", &endpoint_ctx)
3501            .unwrap();
3502        let producer = endpoint.create_producer(rt(), &ctx).unwrap();
3503
3504        let exchange = Exchange::new(Message::default());
3505
3506        // The request will fail because we can't connect, but it should NOT fail
3507        // due to SSRF protection
3508        let result = producer.oneshot(exchange).await;
3509        // We expect connection error, not SSRF error
3510        if let Err(ref e) = result {
3511            let err_str = e.to_string();
3512            assert!(
3513                !err_str.contains("Private IP") && !err_str.contains("not allowed"),
3514                "Should not be SSRF error, got: {}",
3515                err_str
3516            );
3517        }
3518    }
3519
3520    // -----------------------------------------------------------------------
3521    // HttpServerConfig tests
3522    // -----------------------------------------------------------------------
3523
3524    #[test]
3525    fn test_http_server_config_parse() {
3526        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/orders").unwrap();
3527        assert_eq!(cfg.host, "0.0.0.0");
3528        assert_eq!(cfg.port, 8080);
3529        assert_eq!(cfg.path, "/orders");
3530        assert_eq!(cfg.max_inflight_requests, 1024);
3531    }
3532
3533    #[test]
3534    fn test_http_server_config_scheme() {
3535        // UriConfig trait method returns "http" as primary scheme
3536        assert_eq!(HttpServerConfig::scheme(), "http");
3537    }
3538
3539    #[test]
3540    fn test_http_server_config_from_components() {
3541        // Test from_components directly (trait method)
3542        let components = camel_component_api::UriComponents {
3543            scheme: "https".to_string(),
3544            path: "//0.0.0.0:8443/api".to_string(),
3545            params: std::collections::HashMap::from([
3546                ("maxRequestBody".to_string(), "5242880".to_string()),
3547                ("maxInflightRequests".to_string(), "7".to_string()),
3548            ]),
3549        };
3550        let cfg = HttpServerConfig::from_components(components).unwrap();
3551        assert_eq!(cfg.host, "0.0.0.0");
3552        assert_eq!(cfg.port, 8443);
3553        assert_eq!(cfg.path, "/api");
3554        assert_eq!(cfg.max_request_body, 5242880);
3555        assert_eq!(cfg.max_inflight_requests, 7);
3556    }
3557
3558    #[test]
3559    fn test_http_server_config_default_path() {
3560        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:3000").unwrap();
3561        assert_eq!(cfg.path, "/");
3562    }
3563
3564    #[test]
3565    fn test_http_server_config_wrong_scheme() {
3566        assert!(HttpServerConfig::from_uri("file:/tmp").is_err());
3567    }
3568
3569    #[test]
3570    fn test_http_server_config_invalid_port() {
3571        assert!(HttpServerConfig::from_uri("http://localhost:abc/path").is_err());
3572    }
3573
3574    #[test]
3575    fn test_http_server_config_default_port_by_scheme() {
3576        // HTTP without explicit port should default to 80
3577        let cfg_http = HttpServerConfig::from_uri("http://0.0.0.0/orders").unwrap();
3578        assert_eq!(cfg_http.port, 80);
3579
3580        // HTTPS without explicit port should default to 443
3581        let cfg_https = HttpServerConfig::from_uri("https://0.0.0.0/orders").unwrap();
3582        assert_eq!(cfg_https.port, 443);
3583    }
3584
3585    #[test]
3586    fn test_request_envelope_and_reply_are_send() {
3587        fn assert_send<T: Send>() {}
3588        assert_send::<RequestEnvelope>();
3589        assert_send::<HttpReply>();
3590    }
3591
3592    // -----------------------------------------------------------------------
3593    // ServerRegistry tests
3594    // -----------------------------------------------------------------------
3595
3596    #[test]
3597    fn test_server_registry_global_is_singleton() {
3598        let r1 = ServerRegistry::global();
3599        let r2 = ServerRegistry::global();
3600        assert!(std::ptr::eq(r1 as *const _, r2 as *const _));
3601    }
3602
3603    #[allow(clippy::await_holding_lock)]
3604    #[tokio::test]
3605    async fn test_concurrent_get_or_spawn_returns_same_registry() {
3606        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3607        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3608        let port = listener.local_addr().unwrap().port();
3609        drop(listener);
3610
3611        let results: Arc<std::sync::Mutex<Vec<HttpRouteRegistry>>> =
3612            Arc::new(std::sync::Mutex::new(Vec::new()));
3613
3614        let mut handles = Vec::new();
3615        for _ in 0..4 {
3616            let results = results.clone();
3617            handles.push(tokio::spawn(async move {
3618                let registry = ServerRegistry::global()
3619                    .get_or_spawn(
3620                        "127.0.0.1",
3621                        port,
3622                        2 * 1024 * 1024,
3623                        10 * 1024 * 1024,
3624                        1024,
3625                        test_rt(),
3626                        "test-route".into(),
3627                        None,
3628                    )
3629                    .await
3630                    .unwrap();
3631                results.lock().unwrap().push(registry);
3632            }));
3633        }
3634
3635        for h in handles {
3636            h.await.unwrap();
3637        }
3638
3639        let registries = results.lock().unwrap();
3640        assert_eq!(registries.len(), 4);
3641        for i in 1..registries.len() {
3642            assert!(
3643                Arc::ptr_eq(&registries[0].inner, &registries[i].inner),
3644                "all concurrent callers should get same route registry"
3645            );
3646        }
3647    }
3648
3649    #[test]
3650    fn test_server_registry_distinguishes_host_and_port() {
3651        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3652        let rt = tokio::runtime::Runtime::new().expect("runtime");
3653        rt.block_on(async {
3654            let registry = ServerRegistry::global();
3655            // Use two distinct host values with same configured port key.
3656            // Port 0 is acceptable here because the registry key uses the configured
3657            // tuple, not the OS-assigned ephemeral port.
3658            let d1 = registry
3659                .get_or_spawn(
3660                    "127.0.0.1",
3661                    0,
3662                    1024 * 1024,
3663                    10 * 1024 * 1024,
3664                    1024,
3665                    test_rt(),
3666                    "test-route-1".into(),
3667                    None,
3668                )
3669                .await;
3670            let d2 = registry
3671                .get_or_spawn(
3672                    "0.0.0.0",
3673                    0,
3674                    1024 * 1024,
3675                    10 * 1024 * 1024,
3676                    1024,
3677                    test_rt(),
3678                    "test-route-2".into(),
3679                    None,
3680                )
3681                .await;
3682            assert!(d1.is_ok());
3683            assert!(d2.is_ok());
3684            assert!(!Arc::ptr_eq(&d1.unwrap().inner, &d2.unwrap().inner));
3685        });
3686    }
3687
3688    #[allow(clippy::await_holding_lock)]
3689    #[tokio::test]
3690    async fn test_shared_server_max_request_body_policy_is_deterministic() {
3691        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3692        let registry = ServerRegistry::global();
3693        // First registration: maxRequestBody = 1 MB
3694        let d1 = registry
3695            .get_or_spawn(
3696                "127.0.0.1",
3697                9991,
3698                1024 * 1024,
3699                10 * 1024 * 1024,
3700                1024,
3701                test_rt(),
3702                "test-route".into(),
3703                None,
3704            )
3705            .await;
3706        assert!(d1.is_ok());
3707
3708        // Second registration on same (host,port): maxRequestBody = 2 MB
3709        // Expected: explicit EndpointCreationFailed about incompatible maxRequestBody
3710        let d2 = registry
3711            .get_or_spawn(
3712                "127.0.0.1",
3713                9991,
3714                2 * 1024 * 1024,
3715                10 * 1024 * 1024,
3716                1024,
3717                test_rt(),
3718                "test-route-2".into(),
3719                None,
3720            )
3721            .await;
3722        assert!(d2.is_err());
3723        let err = d2.unwrap_err();
3724        assert!(
3725            err.to_string().contains("maxRequestBody") || err.to_string().contains("incompatible"),
3726            "Expected incompatible maxRequestBody error, got: {}",
3727            err
3728        );
3729    }
3730
3731    #[test]
3732    fn test_server_registry_reset_clears_entries() {
3733        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3734        let rt = tokio::runtime::Runtime::new().expect("runtime");
3735        rt.block_on(async {
3736            // Register something on a unique port
3737            let d1 = ServerRegistry::global()
3738                .get_or_spawn(
3739                    "127.0.0.1",
3740                    9992,
3741                    1024 * 1024,
3742                    10 * 1024 * 1024,
3743                    1024,
3744                    test_rt(),
3745                    "test-route".into(),
3746                    None,
3747                )
3748                .await;
3749            assert!(d1.is_ok());
3750
3751            // Verify entry exists
3752            let guard = ServerRegistry::global().inner.lock().expect("lock");
3753            assert!(guard.contains_key(&("127.0.0.1".to_string(), 9992)));
3754            drop(guard);
3755
3756            // Reset
3757            ServerRegistry::reset();
3758
3759            // Verify cleared
3760            let guard = ServerRegistry::global().inner.lock().expect("lock");
3761            assert!(
3762                guard.is_empty(),
3763                "registry should be empty after reset, has {} entries",
3764                guard.len()
3765            );
3766        });
3767    }
3768
3769    #[tokio::test]
3770    async fn registry_rejects_tls_on_plain_port() {
3771        ServerRegistry::reset();
3772        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
3773
3774        // First route: plain HTTP
3775        let _r1 = ServerRegistry::global()
3776            .get_or_spawn(
3777                "127.0.0.1",
3778                0,
3779                1024,
3780                1024,
3781                16,
3782                Arc::clone(&rt),
3783                "route-1".into(),
3784                None, // plain
3785            )
3786            .await;
3787
3788        // Second route: TLS on same port → must fail
3789        let result = ServerRegistry::global()
3790            .get_or_spawn(
3791                "127.0.0.1",
3792                0,
3793                1024,
3794                1024,
3795                16,
3796                Arc::clone(&rt),
3797                "route-2".into(),
3798                Some(crate::config::ServerTlsConfig {
3799                    cert_path: "/x.pem".into(),
3800                    key_path: "/y.pem".into(),
3801                }),
3802            )
3803            .await;
3804        assert!(result.is_err(), "must reject TLS on plain port");
3805    }
3806
3807    // -----------------------------------------------------------------------
3808    // D-L10: HTTP monitor_axum_task refcounted shutdown
3809    // -----------------------------------------------------------------------
3810
3811    #[tokio::test]
3812    async fn test_unregister_last_http_route_keeps_server_alive() {
3813        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
3814        ServerRegistry::reset();
3815        let registry = ServerRegistry::global();
3816
3817        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3818        let port = listener.local_addr().unwrap().port();
3819        drop(listener); // Release — ServerRegistry will rebind
3820        let rt = test_rt();
3821
3822        // Register 2 routes on the same (host, port) — OnceCell returns the
3823        // same ServerHandle.
3824        let _r1 = registry
3825            .get_or_spawn(
3826                "127.0.0.1",
3827                port,
3828                1024 * 1024,
3829                10 * 1024 * 1024,
3830                16,
3831                rt.clone(),
3832                "test-route-1".into(),
3833                None,
3834            )
3835            .await
3836            .unwrap();
3837        let _r2 = registry
3838            .get_or_spawn(
3839                "127.0.0.1",
3840                port,
3841                1024 * 1024,
3842                10 * 1024 * 1024,
3843                16,
3844                rt,
3845                "test-route-2".into(),
3846                None,
3847            )
3848            .await
3849            .unwrap();
3850
3851        let key = ("127.0.0.1".to_string(), port);
3852        let cell = {
3853            let guard = registry.inner.lock().expect("lock");
3854            guard.get(&key).expect("entry should exist").clone()
3855        };
3856
3857        // Unregister first route -> monitor still alive (count = 1).
3858        registry.unregister("127.0.0.1", port).await;
3859        {
3860            let handle = cell
3861                .get()
3862                .expect("handle should still exist after first unregister");
3863            assert!(
3864                !handle.monitor_task.is_finished(),
3865                "monitor task should still be alive after first unregister"
3866            );
3867        }
3868
3869        // Unregister second route -> server stays alive (process-lifetime).
3870        registry.unregister("127.0.0.1", port).await;
3871        tokio::time::sleep(Duration::from_millis(20)).await;
3872        {
3873            let handle = cell
3874                .get()
3875                .expect("handle should still exist after last unregister");
3876            assert!(
3877                !handle.monitor_task.is_finished(),
3878                "monitor task should still be alive — server is process-lifetime"
3879            );
3880        }
3881
3882        // Entry stays in registry for potential restart.
3883        {
3884            let guard = registry.inner.lock().expect("lock");
3885            assert!(
3886                guard.get(&key).is_some(),
3887                "entry should remain in registry — server kept alive for restart"
3888            );
3889        }
3890    }
3891
3892    // -----------------------------------------------------------------------
3893    // Axum dispatch handler tests
3894    // -----------------------------------------------------------------------
3895
3896    #[tokio::test]
3897    async fn test_dispatch_handler_returns_404_for_unknown_path() {
3898        let registry = HttpRouteRegistry::new();
3899        // Nothing registered in route registry
3900        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3901        let port = listener.local_addr().unwrap().port();
3902        tokio::spawn(run_axum_server(
3903            listener,
3904            registry,
3905            2 * 1024 * 1024,
3906            10 * 1024 * 1024,
3907            Arc::new(tokio::sync::Semaphore::new(1024)),
3908            test_rt(),
3909            "test-route".into(),
3910        ));
3911
3912        // Wait for server to start
3913        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3914
3915        let resp = reqwest::get(format!("http://127.0.0.1:{port}/unknown"))
3916            .await
3917            .unwrap();
3918        assert_eq!(resp.status().as_u16(), 404);
3919    }
3920
3921    // -----------------------------------------------------------------------
3922    // HttpConsumer tests
3923    // -----------------------------------------------------------------------
3924
3925    #[tokio::test]
3926    async fn test_http_consumer_start_registers_path() {
3927        use camel_component_api::ConsumerContext;
3928
3929        // Get an OS-assigned free port
3930        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3931        let port = listener.local_addr().unwrap().port();
3932        drop(listener); // Release port — ServerRegistry will rebind it
3933
3934        let consumer_cfg = HttpServerConfig {
3935            scheme: "http".to_string(),
3936            host: "127.0.0.1".to_string(),
3937            port,
3938            path: "/ping".to_string(),
3939            max_request_body: 2 * 1024 * 1024,
3940            max_response_body: 10 * 1024 * 1024,
3941            max_inflight_requests: 1024,
3942            method: None,
3943            tls_config: None,
3944        };
3945        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
3946
3947        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
3948        let token = tokio_util::sync::CancellationToken::new();
3949        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
3950
3951        tokio::spawn(async move {
3952            consumer.start(ctx).await.unwrap();
3953        });
3954
3955        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3956
3957        let client = reqwest::Client::new();
3958        let resp_future = client
3959            .post(format!("http://127.0.0.1:{port}/ping"))
3960            .body("hello world")
3961            .send();
3962
3963        let (http_result, _) = tokio::join!(resp_future, async {
3964            if let Some(mut envelope) = rx.recv().await {
3965                // Set a custom status code
3966                envelope.exchange.input.set_header(
3967                    "CamelHttpResponseCode",
3968                    serde_json::Value::Number(201.into()),
3969                );
3970                if let Some(reply_tx) = envelope.reply_tx {
3971                    let _ = reply_tx.send(Ok(envelope.exchange));
3972                }
3973            }
3974        });
3975
3976        let resp = http_result.unwrap();
3977        assert_eq!(resp.status().as_u16(), 201);
3978
3979        token.cancel();
3980    }
3981
3982    #[tokio::test]
3983    async fn test_http_consumer_returns_503_when_inflight_limit_reached() {
3984        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
3985
3986        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3987        let port = listener.local_addr().unwrap().port();
3988        drop(listener);
3989
3990        let consumer_cfg = HttpServerConfig {
3991            scheme: "http".to_string(),
3992            host: "127.0.0.1".to_string(),
3993            port,
3994            path: "/saturation".to_string(),
3995            max_request_body: 2 * 1024 * 1024,
3996            max_response_body: 10 * 1024 * 1024,
3997            max_inflight_requests: 1,
3998            method: None,
3999            tls_config: None,
4000        };
4001        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4002
4003        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4004        let token = tokio_util::sync::CancellationToken::new();
4005        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4006        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4007        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4008
4009        let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel::<()>();
4010        let (unblock_first_tx, unblock_first_rx) = tokio::sync::oneshot::channel::<()>();
4011
4012        tokio::spawn(async move {
4013            let mut first_seen_tx = Some(first_seen_tx);
4014            let mut unblock_first_rx = Some(unblock_first_rx);
4015
4016            while let Some(envelope) = rx.recv().await {
4017                if let Some(tx) = first_seen_tx.take() {
4018                    let _ = tx.send(());
4019                    if let Some(rx_unblock) = unblock_first_rx.take() {
4020                        let _ = rx_unblock.await;
4021                    }
4022                }
4023
4024                if let Some(reply_tx) = envelope.reply_tx {
4025                    let _ = reply_tx.send(Ok(envelope.exchange));
4026                }
4027            }
4028        });
4029
4030        let client = reqwest::Client::new();
4031        let first_req = {
4032            let client = client.clone();
4033            async move {
4034                client
4035                    .get(format!("http://127.0.0.1:{port}/saturation"))
4036                    .send()
4037                    .await
4038                    .unwrap()
4039            }
4040        };
4041
4042        let first_handle = tokio::spawn(first_req);
4043        first_seen_rx.await.unwrap();
4044
4045        let second_resp = client
4046            .get(format!("http://127.0.0.1:{port}/saturation"))
4047            .send()
4048            .await
4049            .unwrap();
4050
4051        assert_eq!(second_resp.status().as_u16(), 503);
4052
4053        let _ = unblock_first_tx.send(());
4054        let first_resp = first_handle.await.unwrap();
4055        assert_eq!(first_resp.status().as_u16(), 200);
4056
4057        token.cancel();
4058    }
4059
4060    #[tokio::test]
4061    #[allow(clippy::await_holding_lock)]
4062    async fn test_http_consumer_enforces_max_response_body_for_bytes() {
4063        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4064
4065        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4066
4067        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4068        let port = listener.local_addr().unwrap().port();
4069        drop(listener);
4070
4071        let consumer_cfg = HttpServerConfig {
4072            scheme: "http".to_string(),
4073            host: "127.0.0.1".to_string(),
4074            port,
4075            path: "/limit-bytes".to_string(),
4076            max_request_body: 2 * 1024 * 1024,
4077            max_response_body: 16,
4078            max_inflight_requests: 1024,
4079            method: None,
4080            tls_config: None,
4081        };
4082        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4083
4084        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4085        let token = tokio_util::sync::CancellationToken::new();
4086        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4087        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4088        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4089
4090        let client = reqwest::Client::new();
4091        let send_fut = client
4092            .get(format!("http://127.0.0.1:{port}/limit-bytes"))
4093            .send();
4094
4095        let (http_result, _) = tokio::join!(send_fut, async {
4096            if let Some(mut envelope) = rx.recv().await {
4097                envelope.exchange.input.body =
4098                    camel_component_api::Body::Bytes(bytes::Bytes::from(vec![b'x'; 32]));
4099                if let Some(reply_tx) = envelope.reply_tx {
4100                    let _ = reply_tx.send(Ok(envelope.exchange));
4101                }
4102            }
4103        });
4104
4105        let resp = http_result.unwrap();
4106        assert_eq!(resp.status().as_u16(), 500);
4107        let body = resp.text().await.unwrap();
4108        assert_eq!(body, "Response body exceeds configured limit");
4109        token.cancel();
4110    }
4111
4112    #[tokio::test]
4113    #[allow(clippy::await_holding_lock)]
4114    async fn test_http_consumer_enforces_max_response_body_for_json() {
4115        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4116
4117        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4118
4119        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4120        let port = listener.local_addr().unwrap().port();
4121        drop(listener);
4122
4123        let consumer_cfg = HttpServerConfig {
4124            scheme: "http".to_string(),
4125            host: "127.0.0.1".to_string(),
4126            port,
4127            path: "/limit-json".to_string(),
4128            max_request_body: 2 * 1024 * 1024,
4129            max_response_body: 16,
4130            max_inflight_requests: 1024,
4131            method: None,
4132            tls_config: None,
4133        };
4134        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4135
4136        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4137        let token = tokio_util::sync::CancellationToken::new();
4138        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4139        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4140        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4141
4142        let client = reqwest::Client::new();
4143        let send_fut = client
4144            .get(format!("http://127.0.0.1:{port}/limit-json"))
4145            .send();
4146
4147        let (http_result, _) = tokio::join!(send_fut, async {
4148            if let Some(mut envelope) = rx.recv().await {
4149                envelope.exchange.input.body = camel_component_api::Body::Json(
4150                    serde_json::json!({"message":"this response is bigger than sixteen"}),
4151                );
4152                if let Some(reply_tx) = envelope.reply_tx {
4153                    let _ = reply_tx.send(Ok(envelope.exchange));
4154                }
4155            }
4156        });
4157
4158        let resp = http_result.unwrap();
4159        assert_eq!(resp.status().as_u16(), 500);
4160        let body = resp.text().await.unwrap();
4161        assert_eq!(body, "Response body exceeds configured limit");
4162        token.cancel();
4163    }
4164
4165    #[tokio::test]
4166    #[allow(clippy::await_holding_lock)]
4167    async fn test_http_consumer_enforces_max_response_body_for_xml() {
4168        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4169
4170        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4171
4172        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4173        let port = listener.local_addr().unwrap().port();
4174        drop(listener);
4175
4176        let consumer_cfg = HttpServerConfig {
4177            scheme: "http".to_string(),
4178            host: "127.0.0.1".to_string(),
4179            port,
4180            path: "/limit-xml".to_string(),
4181            max_request_body: 2 * 1024 * 1024,
4182            max_response_body: 16,
4183            max_inflight_requests: 1024,
4184            method: None,
4185            tls_config: None,
4186        };
4187        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4188
4189        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4190        let token = tokio_util::sync::CancellationToken::new();
4191        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4192        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4193        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4194
4195        let client = reqwest::Client::new();
4196        let send_fut = client
4197            .get(format!("http://127.0.0.1:{port}/limit-xml"))
4198            .send();
4199
4200        let (http_result, _) = tokio::join!(send_fut, async {
4201            if let Some(mut envelope) = rx.recv().await {
4202                envelope.exchange.input.body = camel_component_api::Body::Xml(
4203                    "<root><value>way-too-large</value></root>".into(),
4204                );
4205                if let Some(reply_tx) = envelope.reply_tx {
4206                    let _ = reply_tx.send(Ok(envelope.exchange));
4207                }
4208            }
4209        });
4210
4211        let resp = http_result.unwrap();
4212        assert_eq!(resp.status().as_u16(), 500);
4213        let body = resp.text().await.unwrap();
4214        assert_eq!(body, "Response body exceeds configured limit");
4215        token.cancel();
4216    }
4217
4218    #[tokio::test]
4219    #[allow(clippy::await_holding_lock)]
4220    async fn test_http_consumer_does_not_enforce_max_response_body_for_stream() {
4221        use camel_component_api::{
4222            CamelError, ConsumerContext, ExchangeEnvelope, StreamBody, StreamMetadata,
4223        };
4224        use futures::stream;
4225
4226        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4227
4228        let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
4229        let port = listener.local_addr().unwrap().port();
4230        drop(listener);
4231
4232        let consumer_cfg = HttpServerConfig {
4233            scheme: "http".to_string(),
4234            host: "0.0.0.0".to_string(),
4235            port,
4236            path: "/limit-stream".to_string(),
4237            max_request_body: 2 * 1024 * 1024,
4238            max_response_body: 16,
4239            max_inflight_requests: 1024,
4240            method: None,
4241            tls_config: None,
4242        };
4243        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
4244
4245        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4246        let token = tokio_util::sync::CancellationToken::new();
4247        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4248        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4249        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4250
4251        let client = reqwest::Client::new();
4252        let send_fut = client
4253            .get(format!("http://127.0.0.1:{port}/limit-stream"))
4254            .send();
4255
4256        let (http_result, _) = tokio::join!(send_fut, async {
4257            if let Some(mut envelope) = rx.recv().await {
4258                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
4259                    vec![Ok(bytes::Bytes::from(vec![b'x'; 32]))];
4260                let stream = Box::pin(stream::iter(chunks));
4261                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
4262                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
4263                    metadata: StreamMetadata {
4264                        size_hint: Some(32),
4265                        content_type: Some("application/octet-stream".into()),
4266                        origin: None,
4267                    },
4268                });
4269                if let Some(reply_tx) = envelope.reply_tx {
4270                    let _ = reply_tx.send(Ok(envelope.exchange));
4271                }
4272            }
4273        });
4274
4275        let resp = http_result.unwrap();
4276        assert_eq!(resp.status().as_u16(), 200);
4277        let body = resp.bytes().await.unwrap();
4278        assert_eq!(body.len(), 32);
4279        token.cancel();
4280    }
4281
4282    // -----------------------------------------------------------------------
4283    // Integration tests
4284    // -----------------------------------------------------------------------
4285
4286    #[tokio::test]
4287    #[allow(clippy::await_holding_lock)]
4288    async fn test_integration_single_consumer_round_trip() {
4289        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4290
4291        // Spawns an HTTP consumer on the global ServerRegistry
4292        // (HttpConsumer::start → get_or_spawn). Serialize against the other
4293        // registry tests so parallel runs do not race on shared global state.
4294        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4295
4296        // Get an OS-assigned free port (ephemeral)
4297        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4298        let port = listener.local_addr().unwrap().port();
4299        drop(listener); // Release — ServerRegistry will rebind
4300
4301        let component = HttpComponent::new();
4302        let endpoint_ctx = NoOpComponentContext;
4303        let endpoint = component
4304            .create_endpoint(&format!("http://127.0.0.1:{port}/echo"), &endpoint_ctx)
4305            .unwrap();
4306        let mut consumer = endpoint.create_consumer(rt()).unwrap();
4307
4308        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4309        let token = tokio_util::sync::CancellationToken::new();
4310        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4311
4312        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4313        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4314
4315        let client = reqwest::Client::new();
4316        let send_fut = client
4317            .post(format!("http://127.0.0.1:{port}/echo"))
4318            .header("Content-Type", "text/plain")
4319            .body("ping")
4320            .send();
4321
4322        let (http_result, _) = tokio::join!(send_fut, async {
4323            if let Some(mut envelope) = rx.recv().await {
4324                assert_eq!(
4325                    envelope.exchange.input.header("CamelHttpMethod"),
4326                    Some(&serde_json::Value::String("POST".into()))
4327                );
4328                assert_eq!(
4329                    envelope.exchange.input.header("CamelHttpPath"),
4330                    Some(&serde_json::Value::String("/echo".into()))
4331                );
4332                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
4333                if let Some(reply_tx) = envelope.reply_tx {
4334                    let _ = reply_tx.send(Ok(envelope.exchange));
4335                }
4336            }
4337        });
4338
4339        let resp = http_result.unwrap();
4340        assert_eq!(resp.status().as_u16(), 200);
4341        let body = resp.text().await.unwrap();
4342        assert_eq!(body, "pong");
4343
4344        token.cancel();
4345    }
4346
4347    #[tokio::test]
4348    #[allow(clippy::await_holding_lock)]
4349    async fn test_integration_two_consumers_shared_port() {
4350        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4351
4352        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4353
4354        // Get an OS-assigned free port (ephemeral)
4355        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4356        let port = listener.local_addr().unwrap().port();
4357        drop(listener);
4358
4359        let component = HttpComponent::new();
4360        let endpoint_ctx = NoOpComponentContext;
4361
4362        // Consumer A: /hello
4363        let endpoint_a = component
4364            .create_endpoint(&format!("http://127.0.0.1:{port}/hello"), &endpoint_ctx)
4365            .unwrap();
4366        let mut consumer_a = endpoint_a.create_consumer(rt()).unwrap();
4367
4368        // Consumer B: /world
4369        let endpoint_b = component
4370            .create_endpoint(&format!("http://127.0.0.1:{port}/world"), &endpoint_ctx)
4371            .unwrap();
4372        let mut consumer_b = endpoint_b.create_consumer(rt()).unwrap();
4373
4374        let (tx_a, mut rx_a) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4375        let token_a = tokio_util::sync::CancellationToken::new();
4376        let ctx_a = ConsumerContext::new(tx_a, token_a.clone(), "http-test-route-a".to_string());
4377
4378        let (tx_b, mut rx_b) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4379        let token_b = tokio_util::sync::CancellationToken::new();
4380        let ctx_b = ConsumerContext::new(tx_b, token_b.clone(), "http-test-route-b".to_string());
4381
4382        tokio::spawn(async move { consumer_a.start(ctx_a).await.unwrap() });
4383        tokio::spawn(async move { consumer_b.start(ctx_b).await.unwrap() });
4384        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4385
4386        let client = reqwest::Client::new();
4387
4388        // Request to /hello
4389        let fut_hello = client.get(format!("http://127.0.0.1:{port}/hello")).send();
4390        let (resp_hello, _) = tokio::join!(fut_hello, async {
4391            if let Some(mut envelope) = rx_a.recv().await {
4392                envelope.exchange.input.body =
4393                    camel_component_api::Body::Text("hello-response".to_string());
4394                if let Some(reply_tx) = envelope.reply_tx {
4395                    let _ = reply_tx.send(Ok(envelope.exchange));
4396                }
4397            }
4398        });
4399
4400        // Request to /world
4401        let fut_world = client.get(format!("http://127.0.0.1:{port}/world")).send();
4402        let (resp_world, _) = tokio::join!(fut_world, async {
4403            if let Some(mut envelope) = rx_b.recv().await {
4404                envelope.exchange.input.body =
4405                    camel_component_api::Body::Text("world-response".to_string());
4406                if let Some(reply_tx) = envelope.reply_tx {
4407                    let _ = reply_tx.send(Ok(envelope.exchange));
4408                }
4409            }
4410        });
4411
4412        let body_a = resp_hello.unwrap().text().await.unwrap();
4413        let body_b = resp_world.unwrap().text().await.unwrap();
4414
4415        assert_eq!(body_a, "hello-response");
4416        assert_eq!(body_b, "world-response");
4417
4418        token_a.cancel();
4419        token_b.cancel();
4420    }
4421
4422    #[tokio::test]
4423    #[allow(clippy::await_holding_lock)]
4424    async fn test_integration_unregistered_path_returns_404() {
4425        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4426
4427        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
4428
4429        // Get an OS-assigned free port (ephemeral)
4430        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4431        let port = listener.local_addr().unwrap().port();
4432        drop(listener);
4433
4434        let component = HttpComponent::new();
4435        let endpoint_ctx = NoOpComponentContext;
4436        let endpoint = component
4437            .create_endpoint(
4438                &format!("http://127.0.0.1:{port}/registered"),
4439                &endpoint_ctx,
4440            )
4441            .unwrap();
4442        let mut consumer = endpoint.create_consumer(rt()).unwrap();
4443
4444        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4445        let token = tokio_util::sync::CancellationToken::new();
4446        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4447
4448        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4449
4450        // Wait until the server is actually accepting connections (CI runners can be slow).
4451        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
4452        loop {
4453            if tokio::net::TcpStream::connect(format!("127.0.0.1:{port}"))
4454                .await
4455                .is_ok()
4456            {
4457                break;
4458            }
4459            if std::time::Instant::now() >= deadline {
4460                panic!("HTTP server did not start within 5s on port {port}");
4461            }
4462            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4463        }
4464
4465        let client = reqwest::Client::new();
4466        let resp = client
4467            .get(format!("http://127.0.0.1:{port}/not-there"))
4468            .send()
4469            .await
4470            .unwrap();
4471        assert_eq!(resp.status().as_u16(), 404);
4472
4473        token.cancel();
4474    }
4475
4476    #[test]
4477    fn test_http_consumer_declares_concurrent() {
4478        use camel_component_api::ConcurrencyModel;
4479
4480        let config = HttpServerConfig {
4481            scheme: "http".to_string(),
4482            host: "127.0.0.1".to_string(),
4483            port: 19999,
4484            path: "/test".to_string(),
4485            max_request_body: 2 * 1024 * 1024,
4486            max_response_body: 10 * 1024 * 1024,
4487            max_inflight_requests: 1024,
4488            method: None,
4489            tls_config: None,
4490        };
4491        let consumer = HttpConsumer::new(config, test_rt());
4492        assert_eq!(
4493            consumer.concurrency_model(),
4494            ConcurrencyModel::Concurrent { max: None }
4495        );
4496    }
4497
4498    #[test]
4499    fn server_config_parses_tls_cert_and_key() {
4500        let cfg = HttpServerConfig::from_uri(
4501            "https://0.0.0.0:8443/api?tlsCert=/a/cert.pem&tlsKey=/a/key.pem",
4502        )
4503        .unwrap();
4504        assert_eq!(cfg.tls_config.as_ref().unwrap().cert_path, "/a/cert.pem");
4505        assert_eq!(cfg.tls_config.as_ref().unwrap().key_path, "/a/key.pem");
4506    }
4507
4508    #[test]
4509    fn server_config_no_tls_when_params_absent() {
4510        let cfg = HttpServerConfig::from_uri("http://0.0.0.0:8080/api").unwrap();
4511        assert!(cfg.tls_config.is_none());
4512    }
4513
4514    // -----------------------------------------------------------------------
4515    // HttpReplyBody streaming tests
4516    // -----------------------------------------------------------------------
4517
4518    #[tokio::test]
4519    async fn test_http_reply_body_stream_variant_exists() {
4520        use bytes::Bytes;
4521        use camel_component_api::CamelError;
4522        use futures::stream;
4523
4524        let chunks: Vec<Result<Bytes, CamelError>> =
4525            vec![Ok(Bytes::from("hello")), Ok(Bytes::from(" world"))];
4526        let stream = Box::pin(stream::iter(chunks));
4527        let reply_body = HttpReplyBody::Stream(stream);
4528        // Si compila y el match funciona, el test pasa
4529        match reply_body {
4530            HttpReplyBody::Stream(_) => {}
4531            HttpReplyBody::Bytes(_) => panic!("expected Stream variant"),
4532        }
4533    }
4534
4535    // -----------------------------------------------------------------------
4536    // OpenTelemetry propagation tests (only compiled with "otel" feature)
4537    // -----------------------------------------------------------------------
4538
4539    #[cfg(feature = "otel")]
4540    mod otel_tests {
4541        use super::*;
4542        use camel_component_api::Message;
4543        use tower::ServiceExt;
4544
4545        #[tokio::test]
4546        async fn test_producer_injects_traceparent_header() {
4547            let (url, _handle) = start_test_server_with_header_capture().await;
4548            let ctx = test_producer_ctx();
4549
4550            let component = HttpComponent::new();
4551            let endpoint_ctx = NoOpComponentContext;
4552            let endpoint = component
4553                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4554                .unwrap();
4555            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4556
4557            // Create exchange with an OTel context by extracting from a traceparent header
4558            let mut exchange = Exchange::new(Message::default());
4559            let mut headers = std::collections::HashMap::new();
4560            headers.insert(
4561                "traceparent".to_string(),
4562                "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01".to_string(),
4563            );
4564            camel_otel::extract_into_exchange(&mut exchange, &headers);
4565
4566            let result = producer.oneshot(exchange).await.unwrap();
4567
4568            // Verify request succeeded
4569            let status = result
4570                .input
4571                .header("CamelHttpResponseCode")
4572                .and_then(|v| v.as_u64())
4573                .unwrap();
4574            assert_eq!(status, 200);
4575
4576            // The test server echoes back the received traceparent header
4577            let traceparent = result.input.header("X-Received-Traceparent");
4578            assert!(
4579                traceparent.is_some(),
4580                "traceparent header should have been sent"
4581            );
4582
4583            let traceparent_str = traceparent.unwrap().as_str().unwrap();
4584            // Verify format: version-traceid-spanid-flags
4585            let parts: Vec<&str> = traceparent_str.split('-').collect();
4586            assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
4587            assert_eq!(parts[0], "00", "version should be 00");
4588            assert_eq!(
4589                parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
4590                "trace-id should match"
4591            );
4592            assert_eq!(parts[2], "00f067aa0ba902b7", "span-id should match");
4593            assert_eq!(parts[3], "01", "flags should be 01 (sampled)");
4594        }
4595
4596        #[tokio::test]
4597        async fn test_consumer_extracts_traceparent_header() {
4598            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4599
4600            // Get an OS-assigned free port
4601            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4602            let port = listener.local_addr().unwrap().port();
4603            drop(listener);
4604
4605            let component = HttpComponent::new();
4606            let endpoint_ctx = NoOpComponentContext;
4607            let endpoint = component
4608                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
4609                .unwrap();
4610            let mut consumer = endpoint.create_consumer(rt()).unwrap();
4611
4612            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4613            let token = tokio_util::sync::CancellationToken::new();
4614            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4615
4616            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4617            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4618
4619            // Send request with traceparent header
4620            let client = reqwest::Client::new();
4621            let send_fut = client
4622                .post(format!("http://127.0.0.1:{port}/trace"))
4623                .header(
4624                    "traceparent",
4625                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
4626                )
4627                .body("test")
4628                .send();
4629
4630            let (http_result, _) = tokio::join!(send_fut, async {
4631                if let Some(envelope) = rx.recv().await {
4632                    // Verify the exchange has a valid OTel context by re-injecting it
4633                    // and checking the traceparent matches
4634                    let mut injected_headers = std::collections::HashMap::new();
4635                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
4636
4637                    assert!(
4638                        injected_headers.contains_key("traceparent"),
4639                        "Exchange should have traceparent after extraction"
4640                    );
4641
4642                    let traceparent = injected_headers.get("traceparent").unwrap();
4643                    let parts: Vec<&str> = traceparent.split('-').collect();
4644                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
4645                    assert_eq!(
4646                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
4647                        "Trace ID should match the original traceparent header"
4648                    );
4649
4650                    if let Some(reply_tx) = envelope.reply_tx {
4651                        let _ = reply_tx.send(Ok(envelope.exchange));
4652                    }
4653                }
4654            });
4655
4656            let resp = http_result.unwrap();
4657            assert_eq!(resp.status().as_u16(), 200);
4658
4659            token.cancel();
4660        }
4661
4662        #[tokio::test]
4663        async fn test_consumer_extracts_mixed_case_traceparent_header() {
4664            use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4665
4666            // Get an OS-assigned free port
4667            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4668            let port = listener.local_addr().unwrap().port();
4669            drop(listener);
4670
4671            let component = HttpComponent::new();
4672            let endpoint_ctx = NoOpComponentContext;
4673            let endpoint = component
4674                .create_endpoint(&format!("http://127.0.0.1:{port}/trace"), &endpoint_ctx)
4675                .unwrap();
4676            let mut consumer = endpoint.create_consumer(rt()).unwrap();
4677
4678            let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4679            let token = tokio_util::sync::CancellationToken::new();
4680            let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4681
4682            tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4683            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4684
4685            // Send request with MIXED-CASE TraceParent header (not lowercase)
4686            let client = reqwest::Client::new();
4687            let send_fut = client
4688                .post(format!("http://127.0.0.1:{port}/trace"))
4689                .header(
4690                    "TraceParent",
4691                    "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
4692                )
4693                .body("test")
4694                .send();
4695
4696            let (http_result, _) = tokio::join!(send_fut, async {
4697                if let Some(envelope) = rx.recv().await {
4698                    // Verify the exchange has a valid OTel context by re-injecting it
4699                    // and checking the traceparent matches
4700                    let mut injected_headers = HashMap::new();
4701                    camel_otel::inject_from_exchange(&envelope.exchange, &mut injected_headers);
4702
4703                    assert!(
4704                        injected_headers.contains_key("traceparent"),
4705                        "Exchange should have traceparent after extraction from mixed-case header"
4706                    );
4707
4708                    let traceparent = injected_headers.get("traceparent").unwrap();
4709                    let parts: Vec<&str> = traceparent.split('-').collect();
4710                    assert_eq!(parts.len(), 4, "traceparent should have 4 parts");
4711                    assert_eq!(
4712                        parts[1], "4bf92f3577b34da6a3ce929d0e0e4736",
4713                        "Trace ID should match the original mixed-case TraceParent header"
4714                    );
4715
4716                    if let Some(reply_tx) = envelope.reply_tx {
4717                        let _ = reply_tx.send(Ok(envelope.exchange));
4718                    }
4719                }
4720            });
4721
4722            let resp = http_result.unwrap();
4723            assert_eq!(resp.status().as_u16(), 200);
4724
4725            token.cancel();
4726        }
4727
4728        #[tokio::test]
4729        async fn test_producer_no_trace_context_no_crash() {
4730            let (url, _handle) = start_test_server().await;
4731            let ctx = test_producer_ctx();
4732
4733            let component = HttpComponent::new();
4734            let endpoint_ctx = NoOpComponentContext;
4735            let endpoint = component
4736                .create_endpoint(&format!("{url}/api?allowInternal=true"), &endpoint_ctx)
4737                .unwrap();
4738            let producer = endpoint.create_producer(rt(), &ctx).unwrap();
4739
4740            // Create exchange with default (empty) otel_context - no trace context
4741            let exchange = Exchange::new(Message::default());
4742
4743            // Should succeed without panic
4744            let result = producer.oneshot(exchange).await.unwrap();
4745
4746            // Verify request succeeded
4747            let status = result
4748                .input
4749                .header("CamelHttpResponseCode")
4750                .and_then(|v| v.as_u64())
4751                .unwrap();
4752            assert_eq!(status, 200);
4753        }
4754
4755        /// Test server that captures and echoes back the traceparent header
4756        async fn start_test_server_with_header_capture() -> (String, tokio::task::JoinHandle<()>) {
4757            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4758            let addr = listener.local_addr().unwrap();
4759            let url = format!("http://127.0.0.1:{}", addr.port());
4760
4761            let handle = tokio::spawn(async move {
4762                loop {
4763                    if let Ok((mut stream, _)) = listener.accept().await {
4764                        tokio::spawn(async move {
4765                            use tokio::io::{AsyncReadExt, AsyncWriteExt};
4766                            let mut buf = vec![0u8; 8192];
4767                            let n = stream.read(&mut buf).await.unwrap_or(0);
4768                            let request = String::from_utf8_lossy(&buf[..n]).to_string();
4769
4770                            // Extract traceparent header from request
4771                            let traceparent = request
4772                                .lines()
4773                                .find(|line| line.to_lowercase().starts_with("traceparent:"))
4774                                .map(|line| {
4775                                    line.split(':')
4776                                        .nth(1)
4777                                        .map(|s| s.trim().to_string())
4778                                        .unwrap_or_default()
4779                                })
4780                                .unwrap_or_default();
4781
4782                            let body =
4783                                format!(r#"{{"echo":"ok","traceparent":"{}"}}"#, traceparent);
4784                            let response = format!(
4785                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Received-Traceparent: {}\r\n\r\n{}",
4786                                body.len(),
4787                                traceparent,
4788                                body
4789                            );
4790                            let _ = stream.write_all(response.as_bytes()).await;
4791                        });
4792                    }
4793                }
4794            });
4795
4796            (url, handle)
4797        }
4798    }
4799
4800    // -----------------------------------------------------------------------
4801    // Response streaming tests (Eje A - Task 2)
4802    // -----------------------------------------------------------------------
4803
4804    // -----------------------------------------------------------------------
4805    // Request streaming tests (Eje B - Task 3)
4806    // -----------------------------------------------------------------------
4807
4808    #[tokio::test]
4809    async fn test_request_body_arrives_as_stream() {
4810        use camel_component_api::Body;
4811        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4812
4813        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4814        let port = listener.local_addr().unwrap().port();
4815        drop(listener);
4816
4817        let component = HttpComponent::new();
4818        let endpoint_ctx = NoOpComponentContext;
4819        let endpoint = component
4820            .create_endpoint(&format!("http://127.0.0.1:{port}/upload"), &endpoint_ctx)
4821            .unwrap();
4822        let mut consumer = endpoint.create_consumer(rt()).unwrap();
4823
4824        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4825        let token = tokio_util::sync::CancellationToken::new();
4826        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4827
4828        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4829        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4830
4831        let client = reqwest::Client::new();
4832        let send_fut = client
4833            .post(format!("http://127.0.0.1:{port}/upload"))
4834            .body("hello streaming world")
4835            .send();
4836
4837        let (http_result, _) = tokio::join!(send_fut, async {
4838            if let Some(mut envelope) = rx.recv().await {
4839                // Body must be Body::Stream, not Body::Text or Body::Bytes
4840                assert!(
4841                    matches!(envelope.exchange.input.body, Body::Stream(_)),
4842                    "expected Body::Stream, got discriminant {:?}",
4843                    std::mem::discriminant(&envelope.exchange.input.body)
4844                );
4845                // Materialize to verify content
4846                let bytes = envelope
4847                    .exchange
4848                    .input
4849                    .body
4850                    .into_bytes(1024 * 1024)
4851                    .await
4852                    .unwrap();
4853                assert_eq!(&bytes[..], b"hello streaming world");
4854
4855                envelope.exchange.input.body = camel_component_api::Body::Empty;
4856                if let Some(reply_tx) = envelope.reply_tx {
4857                    let _ = reply_tx.send(Ok(envelope.exchange));
4858                }
4859            }
4860        });
4861
4862        let resp = http_result.unwrap();
4863        assert_eq!(resp.status().as_u16(), 200);
4864
4865        token.cancel();
4866    }
4867
4868    // -----------------------------------------------------------------------
4869    // Response streaming tests (Eje A - Task 2)
4870    // -----------------------------------------------------------------------
4871
4872    #[tokio::test]
4873    async fn test_streaming_response_chunked() {
4874        use bytes::Bytes;
4875        use camel_component_api::Body;
4876        use camel_component_api::CamelError;
4877        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
4878        use camel_component_api::{StreamBody, StreamMetadata};
4879        use futures::stream;
4880        use std::sync::Arc;
4881        use tokio::sync::Mutex;
4882
4883        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4884        let port = listener.local_addr().unwrap().port();
4885        drop(listener);
4886
4887        let component = HttpComponent::new();
4888        let endpoint_ctx = NoOpComponentContext;
4889        let endpoint = component
4890            .create_endpoint(&format!("http://127.0.0.1:{port}/stream"), &endpoint_ctx)
4891            .unwrap();
4892        let mut consumer = endpoint.create_consumer(rt()).unwrap();
4893
4894        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
4895        let token = tokio_util::sync::CancellationToken::new();
4896        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4897
4898        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4899        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4900
4901        let client = reqwest::Client::new();
4902        let send_fut = client.get(format!("http://127.0.0.1:{port}/stream")).send();
4903
4904        let (http_result, _) = tokio::join!(send_fut, async {
4905            if let Some(mut envelope) = rx.recv().await {
4906                // Respond with Body::Stream
4907                let chunks: Vec<Result<Bytes, CamelError>> =
4908                    vec![Ok(Bytes::from("chunk1")), Ok(Bytes::from("chunk2"))];
4909                let stream = Box::pin(stream::iter(chunks));
4910                envelope.exchange.input.body = Body::Stream(StreamBody {
4911                    stream: Arc::new(Mutex::new(Some(stream))),
4912                    metadata: StreamMetadata::default(),
4913                });
4914                if let Some(reply_tx) = envelope.reply_tx {
4915                    let _ = reply_tx.send(Ok(envelope.exchange));
4916                }
4917            }
4918        });
4919
4920        let resp = http_result.unwrap();
4921        assert_eq!(resp.status().as_u16(), 200);
4922        let body = resp.text().await.unwrap();
4923        assert_eq!(body, "chunk1chunk2");
4924
4925        token.cancel();
4926    }
4927
4928    // -----------------------------------------------------------------------
4929    // 413 Content-Length limit test (Task 4)
4930    // -----------------------------------------------------------------------
4931
4932    #[tokio::test]
4933    async fn test_413_when_content_length_exceeds_limit() {
4934        use camel_component_api::ConsumerContext;
4935
4936        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4937        let port = listener.local_addr().unwrap().port();
4938        drop(listener);
4939
4940        // maxRequestBody=100 — any request declaring more than 100 bytes must get 413
4941        let component = HttpComponent::new();
4942        let endpoint_ctx = NoOpComponentContext;
4943        let endpoint = component
4944            .create_endpoint(
4945                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=100"),
4946                &endpoint_ctx,
4947            )
4948            .unwrap();
4949        let mut consumer = endpoint.create_consumer(rt()).unwrap();
4950
4951        let (tx, _rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4952        let token = tokio_util::sync::CancellationToken::new();
4953        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
4954
4955        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
4956        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4957
4958        let client = reqwest::Client::new();
4959        let resp = client
4960            .post(format!("http://127.0.0.1:{port}/upload"))
4961            .header("Content-Length", "1000") // declares 1000 bytes, limit is 100
4962            .body("x".repeat(1000))
4963            .send()
4964            .await
4965            .unwrap();
4966
4967        assert_eq!(resp.status().as_u16(), 413);
4968
4969        token.cancel();
4970    }
4971
4972    /// Chunked upload without Content-Length header must NOT be rejected by maxRequestBody.
4973    /// The spec says: "If there is no Content-Length, the limit does not apply at the
4974    /// consumer level — the route is responsible."
4975    #[tokio::test]
4976    async fn test_chunked_upload_without_content_length_bypasses_limit() {
4977        use bytes::Bytes;
4978        use camel_component_api::Body;
4979        use camel_component_api::ConsumerContext;
4980        use futures::stream;
4981
4982        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4983        let port = listener.local_addr().unwrap().port();
4984        drop(listener);
4985
4986        // maxRequestBody=10 — very small limit; chunked uploads have no Content-Length
4987        let component = HttpComponent::new();
4988        let endpoint_ctx = NoOpComponentContext;
4989        let endpoint = component
4990            .create_endpoint(
4991                &format!("http://127.0.0.1:{port}/upload?maxRequestBody=10"),
4992                &endpoint_ctx,
4993            )
4994            .unwrap();
4995        let mut consumer = endpoint.create_consumer(rt()).unwrap();
4996
4997        let (tx, mut rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
4998        let token = tokio_util::sync::CancellationToken::new();
4999        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5000
5001        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5002        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5003
5004        let client = reqwest::Client::new();
5005
5006        // Use wrap_stream so reqwest sends chunked transfer encoding WITHOUT a
5007        // Content-Length header. 100 bytes exceeds the 10-byte maxRequestBody limit,
5008        // but since there's no Content-Length the 413 check must NOT fire.
5009        let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
5010            Ok(Bytes::from("y".repeat(50))),
5011            Ok(Bytes::from("y".repeat(50))),
5012        ];
5013        let stream_body = reqwest::Body::wrap_stream(stream::iter(chunks));
5014        let send_fut = client
5015            .post(format!("http://127.0.0.1:{port}/upload"))
5016            .body(stream_body)
5017            .send();
5018
5019        let consumer_fut = async {
5020            // Use timeout to avoid deadlock if the handler rejects before enqueueing
5021            match tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
5022                Ok(Some(mut envelope)) => {
5023                    assert!(
5024                        matches!(envelope.exchange.input.body, Body::Stream(_)),
5025                        "expected Body::Stream"
5026                    );
5027                    envelope.exchange.input.body = camel_component_api::Body::Empty;
5028                    if let Some(reply_tx) = envelope.reply_tx {
5029                        let _ = reply_tx.send(Ok(envelope.exchange));
5030                    }
5031                }
5032                Ok(None) => panic!("consumer channel closed unexpectedly"),
5033                Err(_) => {
5034                    // Timeout: the request was rejected before reaching the consumer.
5035                    // The HTTP response will carry the real status code (we check below).
5036                }
5037            }
5038        };
5039
5040        let (http_result, _) = tokio::join!(send_fut, consumer_fut);
5041
5042        let resp = http_result.unwrap();
5043        // Must NOT be 413; chunked uploads without Content-Length bypass the limit.
5044        assert_ne!(
5045            resp.status().as_u16(),
5046            413,
5047            "chunked upload must not be rejected by maxRequestBody"
5048        );
5049        assert_eq!(resp.status().as_u16(), 200);
5050
5051        token.cancel();
5052    }
5053
5054    #[test]
5055    fn test_is_private_ip_ranges() {
5056        use camel_api::is_ssrf_blocked_ip;
5057        assert!(is_ssrf_blocked_ip(&"10.0.0.1".parse().unwrap())); // allow-unwrap
5058        assert!(is_ssrf_blocked_ip(&"172.16.1.10".parse().unwrap())); // allow-unwrap
5059        assert!(is_ssrf_blocked_ip(&"192.168.1.1".parse().unwrap())); // allow-unwrap
5060        assert!(is_ssrf_blocked_ip(&"127.0.0.1".parse().unwrap())); // allow-unwrap
5061        assert!(is_ssrf_blocked_ip(&"169.254.1.1".parse().unwrap())); // allow-unwrap
5062        assert!(is_ssrf_blocked_ip(&"0.1.2.3".parse().unwrap())); // allow-unwrap
5063
5064        assert!(is_ssrf_blocked_ip(&"::1".parse().unwrap())); // allow-unwrap
5065        assert!(is_ssrf_blocked_ip(&"fc00::1".parse().unwrap())); // allow-unwrap
5066        assert!(is_ssrf_blocked_ip(&"fd12::1".parse().unwrap())); // allow-unwrap
5067        assert!(is_ssrf_blocked_ip(&"fe80::1".parse().unwrap())); // allow-unwrap
5068        // ::ffff:0:0/96 (IPv4-mapped): only blocked if the mapped IPv4 is blocked
5069        assert!(is_ssrf_blocked_ip(&"::ffff:10.0.0.1".parse().unwrap())); // allow-unwrap
5070        assert!(is_ssrf_blocked_ip(&"::ffff:192.168.1.1".parse().unwrap())); // allow-unwrap
5071        assert!(is_ssrf_blocked_ip(&"::ffff:127.0.0.1".parse().unwrap())); // allow-unwrap
5072
5073        assert!(!is_ssrf_blocked_ip(&"8.8.8.8".parse().unwrap())); // allow-unwrap
5074        assert!(!is_ssrf_blocked_ip(&"::ffff:8.8.8.8".parse().unwrap())); // allow-unwrap — public IPv4-mapped
5075        assert!(!is_ssrf_blocked_ip(
5076            &"2001:4860:4860::8888".parse().unwrap()
5077        )); // allow-unwrap
5078    }
5079
5080    #[test]
5081    fn test_title_case_header() {
5082        assert_eq!(title_case_header("content-type"), "Content-Type");
5083        assert_eq!(title_case_header("authorization"), "Authorization");
5084        assert_eq!(title_case_header("x-custom-header"), "X-Custom-Header");
5085        assert_eq!(title_case_header("host"), "Host");
5086        assert_eq!(title_case_header("x-b3-traceid"), "X-B3-Traceid");
5087        assert_eq!(title_case_header("single"), "Single");
5088        assert_eq!(title_case_header(""), "");
5089    }
5090
5091    #[test]
5092    fn test_resolve_url_combines_path_and_query_sources() {
5093        let cfg = HttpEndpointConfig::from_uri("http://example.com/base?foo=bar").unwrap();
5094        let mut exchange = Exchange::new(Message::default());
5095        exchange.input.set_header(
5096            "CamelHttpPath",
5097            serde_json::Value::String("next".to_string()),
5098        );
5099        let url = HttpProducer::resolve_url(&exchange, &cfg);
5100        assert!(url.starts_with("http://example.com/base/next?"));
5101        assert!(url.contains("foo=bar"));
5102
5103        exchange.input.set_header(
5104            "CamelHttpUri",
5105            serde_json::Value::String("http://other.test/root".to_string()),
5106        );
5107        exchange.input.set_header(
5108            "CamelHttpQuery",
5109            serde_json::Value::String("a=1&b=2".to_string()),
5110        );
5111
5112        let override_url = HttpProducer::resolve_url(&exchange, &cfg);
5113        assert_eq!(override_url, "http://other.test/root/next?a=1&b=2");
5114    }
5115
5116    #[test]
5117    fn test_http_producer_helpers_status_and_size_boundaries() {
5118        assert!(HttpProducer::is_ok_status(200, (200, 299)));
5119        assert!(HttpProducer::is_ok_status(299, (200, 299)));
5120        assert!(!HttpProducer::is_ok_status(199, (200, 299)));
5121        assert!(!HttpProducer::is_ok_status(300, (200, 299)));
5122
5123        assert!(!exceeds_max_response_body(10, 10));
5124        assert!(exceeds_max_response_body(11, 10));
5125    }
5126
5127    // -----------------------------------------------------------------------
5128    // Content-Type inference tests
5129    // -----------------------------------------------------------------------
5130
5131    async fn setup_consumer_on_free_port(
5132        path: &str,
5133    ) -> (
5134        u16,
5135        tokio::sync::mpsc::Receiver<camel_component_api::ExchangeEnvelope>,
5136        tokio_util::sync::CancellationToken,
5137    ) {
5138        use camel_component_api::ConsumerContext;
5139
5140        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5141        let port = listener.local_addr().unwrap().port();
5142        drop(listener);
5143
5144        let consumer_cfg = HttpServerConfig {
5145            scheme: "http".to_string(),
5146            host: "127.0.0.1".to_string(),
5147            port,
5148            path: path.to_string(),
5149            max_request_body: 2 * 1024 * 1024,
5150            max_response_body: 10 * 1024 * 1024,
5151            max_inflight_requests: 1024,
5152            method: None,
5153            tls_config: None,
5154        };
5155        let mut consumer = HttpConsumer::new(consumer_cfg, test_rt());
5156
5157        let (tx, rx) = tokio::sync::mpsc::channel::<camel_component_api::ExchangeEnvelope>(16);
5158        let token = tokio_util::sync::CancellationToken::new();
5159        let ctx = ConsumerContext::new(tx, token.clone(), "http-test-route".to_string());
5160
5161        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
5162        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5163
5164        (port, rx, token)
5165    }
5166
5167    #[tokio::test]
5168    async fn test_content_type_inferred_for_json_body() {
5169        let (port, mut rx, token) = setup_consumer_on_free_port("/json").await;
5170
5171        let client = reqwest::Client::new();
5172        let send_fut = client.get(format!("http://127.0.0.1:{port}/json")).send();
5173
5174        let (http_result, _) = tokio::join!(send_fut, async {
5175            if let Some(mut envelope) = rx.recv().await {
5176                envelope.exchange.input.body =
5177                    camel_component_api::Body::Json(serde_json::json!({"message": "hello"}));
5178                if let Some(reply_tx) = envelope.reply_tx {
5179                    let _ = reply_tx.send(Ok(envelope.exchange));
5180                }
5181            }
5182        });
5183
5184        let resp = http_result.unwrap();
5185        assert_eq!(resp.status().as_u16(), 200);
5186        let ct = resp
5187            .headers()
5188            .get("content-type")
5189            .expect("Content-Type header should be present");
5190        assert_eq!(ct, "application/json");
5191        let body = resp.text().await.unwrap();
5192        assert_eq!(body, r#"{"message":"hello"}"#);
5193
5194        token.cancel();
5195    }
5196
5197    #[tokio::test]
5198    async fn test_content_type_inferred_for_text_body() {
5199        let (port, mut rx, token) = setup_consumer_on_free_port("/text").await;
5200
5201        let client = reqwest::Client::new();
5202        let send_fut = client.get(format!("http://127.0.0.1:{port}/text")).send();
5203
5204        let (http_result, _) = tokio::join!(send_fut, async {
5205            if let Some(mut envelope) = rx.recv().await {
5206                envelope.exchange.input.body =
5207                    camel_component_api::Body::Text("plain text response".to_string());
5208                if let Some(reply_tx) = envelope.reply_tx {
5209                    let _ = reply_tx.send(Ok(envelope.exchange));
5210                }
5211            }
5212        });
5213
5214        let resp = http_result.unwrap();
5215        assert_eq!(resp.status().as_u16(), 200);
5216        let ct = resp
5217            .headers()
5218            .get("content-type")
5219            .expect("Content-Type header should be present");
5220        assert_eq!(ct, "text/plain; charset=utf-8");
5221        let body = resp.text().await.unwrap();
5222        assert_eq!(body, "plain text response");
5223
5224        token.cancel();
5225    }
5226
5227    #[tokio::test]
5228    async fn test_content_type_inferred_for_xml_body() {
5229        let (port, mut rx, token) = setup_consumer_on_free_port("/xml").await;
5230
5231        let client = reqwest::Client::new();
5232        let send_fut = client.get(format!("http://127.0.0.1:{port}/xml")).send();
5233
5234        let (http_result, _) = tokio::join!(send_fut, async {
5235            if let Some(mut envelope) = rx.recv().await {
5236                envelope.exchange.input.body =
5237                    camel_component_api::Body::Xml("<root><item>value</item></root>".to_string());
5238                if let Some(reply_tx) = envelope.reply_tx {
5239                    let _ = reply_tx.send(Ok(envelope.exchange));
5240                }
5241            }
5242        });
5243
5244        let resp = http_result.unwrap();
5245        assert_eq!(resp.status().as_u16(), 200);
5246        let ct = resp
5247            .headers()
5248            .get("content-type")
5249            .expect("Content-Type header should be present");
5250        assert_eq!(ct, "application/xml");
5251        let body = resp.text().await.unwrap();
5252        assert_eq!(body, "<root><item>value</item></root>");
5253
5254        token.cancel();
5255    }
5256
5257    #[tokio::test]
5258    async fn test_no_content_type_for_empty_body() {
5259        let (port, mut rx, token) = setup_consumer_on_free_port("/empty").await;
5260
5261        let client = reqwest::Client::new();
5262        let send_fut = client.get(format!("http://127.0.0.1:{port}/empty")).send();
5263
5264        let (http_result, _) = tokio::join!(send_fut, async {
5265            if let Some(mut envelope) = rx.recv().await {
5266                envelope.exchange.input.body = camel_component_api::Body::Empty;
5267                if let Some(reply_tx) = envelope.reply_tx {
5268                    let _ = reply_tx.send(Ok(envelope.exchange));
5269                }
5270            }
5271        });
5272
5273        let resp = http_result.unwrap();
5274        assert_eq!(resp.status().as_u16(), 200);
5275        assert!(
5276            resp.headers().get("content-type").is_none(),
5277            "Empty body should not set Content-Type"
5278        );
5279
5280        token.cancel();
5281    }
5282
5283    #[tokio::test]
5284    async fn test_no_content_type_for_raw_bytes_body() {
5285        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes").await;
5286
5287        let client = reqwest::Client::new();
5288        let send_fut = client.get(format!("http://127.0.0.1:{port}/bytes")).send();
5289
5290        let (http_result, _) = tokio::join!(send_fut, async {
5291            if let Some(mut envelope) = rx.recv().await {
5292                envelope.exchange.input.body =
5293                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"\x00\x01\x02"));
5294                if let Some(reply_tx) = envelope.reply_tx {
5295                    let _ = reply_tx.send(Ok(envelope.exchange));
5296                }
5297            }
5298        });
5299
5300        let resp = http_result.unwrap();
5301        assert_eq!(resp.status().as_u16(), 200);
5302        assert!(
5303            resp.headers().get("content-type").is_none(),
5304            "Raw Bytes body should not set Content-Type"
5305        );
5306
5307        token.cancel();
5308    }
5309
5310    #[tokio::test]
5311    async fn test_content_type_from_stream_metadata() {
5312        use camel_component_api::{StreamBody, StreamMetadata};
5313        use futures::stream;
5314
5315        let (port, mut rx, token) = setup_consumer_on_free_port("/stream-ct").await;
5316
5317        let client = reqwest::Client::new();
5318        let send_fut = client
5319            .get(format!("http://127.0.0.1:{port}/stream-ct"))
5320            .send();
5321
5322        let (http_result, _) = tokio::join!(send_fut, async {
5323            if let Some(mut envelope) = rx.recv().await {
5324                let chunks: Vec<Result<bytes::Bytes, CamelError>> =
5325                    vec![Ok(bytes::Bytes::from("audio data"))];
5326                let stream = Box::pin(stream::iter(chunks));
5327                envelope.exchange.input.body = camel_component_api::Body::Stream(StreamBody {
5328                    stream: Arc::new(tokio::sync::Mutex::new(Some(stream))),
5329                    metadata: StreamMetadata {
5330                        size_hint: None,
5331                        content_type: Some("audio/mpeg".to_string()),
5332                        origin: None,
5333                    },
5334                });
5335                if let Some(reply_tx) = envelope.reply_tx {
5336                    let _ = reply_tx.send(Ok(envelope.exchange));
5337                }
5338            }
5339        });
5340
5341        let resp = http_result.unwrap();
5342        assert_eq!(resp.status().as_u16(), 200);
5343        let ct = resp
5344            .headers()
5345            .get("content-type")
5346            .expect("Content-Type header should be present");
5347        assert_eq!(ct, "audio/mpeg");
5348        let body = resp.text().await.unwrap();
5349        assert_eq!(body, "audio data");
5350
5351        token.cancel();
5352    }
5353
5354    #[tokio::test]
5355    async fn test_user_content_type_overrides_inferred() {
5356        let (port, mut rx, token) = setup_consumer_on_free_port("/override-ct").await;
5357
5358        let client = reqwest::Client::new();
5359        let send_fut = client
5360            .get(format!("http://127.0.0.1:{port}/override-ct"))
5361            .send();
5362
5363        let (http_result, _) = tokio::join!(send_fut, async {
5364            if let Some(mut envelope) = rx.recv().await {
5365                envelope.exchange.input.body =
5366                    camel_component_api::Body::Json(serde_json::json!({"ok": true}));
5367                envelope.exchange.input.set_header(
5368                    "Content-Type",
5369                    serde_json::Value::String("text/html".to_string()),
5370                );
5371                if let Some(reply_tx) = envelope.reply_tx {
5372                    let _ = reply_tx.send(Ok(envelope.exchange));
5373                }
5374            }
5375        });
5376
5377        let resp = http_result.unwrap();
5378        assert_eq!(resp.status().as_u16(), 200);
5379        let ct = resp
5380            .headers()
5381            .get("content-type")
5382            .expect("Content-Type header should be present");
5383        assert_eq!(
5384            ct, "text/html",
5385            "User-set Content-Type should take precedence over inferred type"
5386        );
5387
5388        token.cancel();
5389    }
5390
5391    #[tokio::test]
5392    async fn test_user_content_type_with_bytes_body() {
5393        let (port, mut rx, token) = setup_consumer_on_free_port("/bytes-ct").await;
5394
5395        let client = reqwest::Client::new();
5396        let send_fut = client
5397            .get(format!("http://127.0.0.1:{port}/bytes-ct"))
5398            .send();
5399
5400        let (http_result, _) = tokio::join!(send_fut, async {
5401            if let Some(mut envelope) = rx.recv().await {
5402                envelope.exchange.input.body =
5403                    camel_component_api::Body::Bytes(bytes::Bytes::from_static(b"{\"ok\":true}"));
5404                envelope.exchange.input.set_header(
5405                    "Content-Type",
5406                    serde_json::Value::String("application/json".to_string()),
5407                );
5408                if let Some(reply_tx) = envelope.reply_tx {
5409                    let _ = reply_tx.send(Ok(envelope.exchange));
5410                }
5411            }
5412        });
5413
5414        let resp = http_result.unwrap();
5415        assert_eq!(resp.status().as_u16(), 200);
5416        let ct = resp
5417            .headers()
5418            .get("content-type")
5419            .expect("Content-Type header should be present for Bytes body with user header");
5420        assert_eq!(
5421            ct, "application/json",
5422            "User Content-Type should be sent for Bytes body"
5423        );
5424
5425        token.cancel();
5426    }
5427
5428    // -----------------------------------------------------------------------
5429    // Server monitor tests (GRL-005)
5430    // -----------------------------------------------------------------------
5431
5432    #[tokio::test]
5433    async fn monitor_task_silent_on_clean_exit() {
5434        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {});
5435        // Clean exit should complete without panicking or logging errors
5436        monitor_axum_task(
5437            handle,
5438            "127.0.0.1:0".to_string(),
5439            noop_rt(),
5440            "test-monitor".into(),
5441        )
5442        .await;
5443    }
5444
5445    #[tokio::test]
5446    async fn monitor_task_handles_panicked_task() {
5447        let handle: tokio::task::JoinHandle<()> = tokio::spawn(async {
5448            panic!("simulated server crash");
5449        });
5450        // Should complete without panicking even though the inner task panicked
5451        monitor_axum_task(
5452            handle,
5453            "127.0.0.1:9999".to_string(),
5454            noop_rt(),
5455            "test-monitor".into(),
5456        )
5457        .await;
5458    }
5459
5460    // -----------------------------------------------------------------------
5461    // Credential redaction tests
5462    // -----------------------------------------------------------------------
5463
5464    #[test]
5465    fn http_auth_basic_debug_redacts_password() {
5466        let auth = HttpAuth::Basic {
5467            username: "admin".to_string(),
5468            password: "hunter2".to_string(),
5469        };
5470        let debug = format!("{:?}", auth);
5471        assert!(
5472            !debug.contains("hunter2"),
5473            "password must be redacted: {debug}"
5474        );
5475        assert!(debug.contains("admin"), "username should appear: {debug}");
5476    }
5477
5478    #[test]
5479    fn http_auth_bearer_debug_redacts_token() {
5480        let auth = HttpAuth::Bearer {
5481            token: "eyJhbGciOiJIUzI1NiJ9.secret".to_string(),
5482        };
5483        let debug = format!("{:?}", auth);
5484        assert!(
5485            !debug.contains("eyJhbGci"),
5486            "token must be redacted: {debug}"
5487        );
5488    }
5489
5490    #[test]
5491    fn http_auth_none_debug_shows_variant() {
5492        let debug = format!("{:?}", HttpAuth::None);
5493        assert!(
5494            debug.contains("None"),
5495            "None variant should appear: {debug}"
5496        );
5497    }
5498
5499    #[test]
5500    fn http_endpoint_config_debug_redacts_auth_credentials() {
5501        let config = HttpEndpointConfig::from_uri(
5502            "http://localhost/api?authMethod=Basic&authUsername=admin&authPassword=secret123",
5503        )
5504        .unwrap();
5505        let debug = format!("{:?}", config);
5506        assert!(
5507            !debug.contains("secret123"),
5508            "password must be redacted in HttpEndpointConfig debug: {debug}"
5509        );
5510    }
5511
5512    // -----------------------------------------------------------------------
5513    // Static file serving tests (Task 5)
5514    // -----------------------------------------------------------------------
5515
5516    use crate::registry::{HttpRouteRegistry, MountMode, StaticMount};
5517    use tower_http::services::ServeDir;
5518
5519    fn make_test_registry() -> HttpRouteRegistry {
5520        HttpRouteRegistry::new()
5521    }
5522
5523    fn make_test_state(registry: HttpRouteRegistry) -> AppState {
5524        AppState {
5525            registry,
5526            max_request_body: 2 * 1024 * 1024,
5527            max_response_body: 10 * 1024 * 1024,
5528            inflight: Arc::new(tokio::sync::Semaphore::new(1024)),
5529        }
5530    }
5531
5532    #[allow(clippy::await_holding_lock)]
5533    #[tokio::test]
5534    async fn test_static_file_serving_serves_file_contents() {
5535        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5536        ServerRegistry::reset();
5537
5538        // Create temp dir with test files
5539        let temp_dir =
5540            std::env::temp_dir().join(format!("http_static_test_{}", std::process::id()));
5541        std::fs::create_dir_all(&temp_dir).unwrap();
5542        std::fs::write(temp_dir.join("hello.txt"), "Hello, static world!").unwrap();
5543        std::fs::write(temp_dir.join("style.css"), "body { color: red; }").unwrap();
5544
5545        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
5546
5547        let registry = make_test_registry();
5548        let serve_dir = ServeDir::new(&canonical_dir)
5549            .precompressed_gzip()
5550            .precompressed_br()
5551            .append_index_html_on_directories(true);
5552
5553        let mount = StaticMount {
5554            mount_path: "/".to_string(),
5555            mode: MountMode::Static,
5556            dir: canonical_dir.clone(),
5557            cache_control: "public, max-age=3600".to_string(),
5558            error_pages: std::collections::HashMap::new(),
5559            serve_dir,
5560        };
5561        registry.register_static_mount(mount).await.unwrap();
5562
5563        let state = make_test_state(registry);
5564
5565        // Test serving hello.txt
5566        let req = Request::builder()
5567            .uri("/hello.txt")
5568            .body(AxumBody::empty())
5569            .unwrap();
5570        let resp = static_dispatch::dispatch_static(&state, req, "/hello.txt").await;
5571        assert_eq!(resp.status(), StatusCode::OK);
5572        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5573            .await
5574            .unwrap();
5575        assert_eq!(&body[..], b"Hello, static world!");
5576
5577        // Test serving style.css
5578        let req = Request::builder()
5579            .uri("/style.css")
5580            .body(AxumBody::empty())
5581            .unwrap();
5582        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
5583        assert_eq!(resp.status(), StatusCode::OK);
5584        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5585            .await
5586            .unwrap();
5587        assert_eq!(&body[..], b"body { color: red; }");
5588
5589        // Test 404 for non-existent file
5590        let req = Request::builder()
5591            .uri("/missing.txt")
5592            .body(AxumBody::empty())
5593            .unwrap();
5594        let resp = static_dispatch::dispatch_static(&state, req, "/missing.txt").await;
5595        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
5596
5597        // Cleanup
5598        std::fs::remove_dir_all(&temp_dir).ok();
5599    }
5600
5601    #[allow(clippy::await_holding_lock)]
5602    #[tokio::test]
5603    async fn test_spa_fallback_serves_index_for_unknown_paths() {
5604        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5605        ServerRegistry::reset();
5606
5607        let temp_dir = std::env::temp_dir().join(format!("http_spa_test_{}", std::process::id()));
5608        std::fs::create_dir_all(&temp_dir).unwrap();
5609        std::fs::write(temp_dir.join("index.html"), "<h1>SPA App</h1>").unwrap();
5610        std::fs::write(temp_dir.join("app.js"), "console.log('app')").unwrap();
5611
5612        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
5613
5614        let registry = make_test_registry();
5615        let serve_dir = ServeDir::new(&canonical_dir)
5616            .precompressed_gzip()
5617            .precompressed_br()
5618            .append_index_html_on_directories(true);
5619
5620        let mount = StaticMount {
5621            mount_path: "/".to_string(),
5622            mode: MountMode::Spa,
5623            dir: canonical_dir.clone(),
5624            cache_control: "public, max-age=0".to_string(),
5625            error_pages: std::collections::HashMap::new(),
5626            serve_dir,
5627        };
5628        // Register as SPA mount
5629        registry.register_static_mount(mount).await.unwrap();
5630
5631        let state = make_test_state(registry);
5632
5633        // SPA fallback: GET /dashboard with Accept: text/html → index.html
5634        let req = Request::builder()
5635            .method("GET")
5636            .uri("/dashboard")
5637            .header("Accept", "text/html")
5638            .body(AxumBody::empty())
5639            .unwrap();
5640        let resp = static_dispatch::dispatch_static(&state, req, "/dashboard").await;
5641        assert_eq!(resp.status(), StatusCode::OK);
5642        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5643            .await
5644            .unwrap();
5645        assert_eq!(&body[..], b"<h1>SPA App</h1>");
5646
5647        // Static file still works: GET /app.js
5648        let req = Request::builder()
5649            .method("GET")
5650            .uri("/app.js")
5651            .body(AxumBody::empty())
5652            .unwrap();
5653        let resp = static_dispatch::dispatch_static(&state, req, "/app.js").await;
5654        assert_eq!(resp.status(), StatusCode::OK);
5655        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5656            .await
5657            .unwrap();
5658        assert_eq!(&body[..], b"console.log('app')");
5659
5660        // No SPA fallback for JSON accept → 404
5661        let req = Request::builder()
5662            .method("GET")
5663            .uri("/api/data")
5664            .header("Accept", "application/json")
5665            .body(AxumBody::empty())
5666            .unwrap();
5667        let resp = static_dispatch::dispatch_static(&state, req, "/api/data").await;
5668        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
5669
5670        // No SPA fallback for file extensions → 404
5671        let req = Request::builder()
5672            .method("GET")
5673            .uri("/style.css")
5674            .header("Accept", "text/html")
5675            .body(AxumBody::empty())
5676            .unwrap();
5677        let resp = static_dispatch::dispatch_static(&state, req, "/style.css").await;
5678        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
5679
5680        // Cleanup
5681        std::fs::remove_dir_all(&temp_dir).ok();
5682    }
5683
5684    #[allow(clippy::await_holding_lock)]
5685    #[tokio::test]
5686    async fn test_error_page_mapping_serves_custom_404() {
5687        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
5688        ServerRegistry::reset();
5689
5690        let temp_dir = std::env::temp_dir().join(format!("http_error_test_{}", std::process::id()));
5691        let errors_dir = temp_dir.join("errors");
5692        std::fs::create_dir_all(&errors_dir).unwrap();
5693        std::fs::write(temp_dir.join("index.html"), "<h1>Home</h1>").unwrap();
5694        std::fs::write(errors_dir.join("404.html"), "<h1>Custom 404</h1>").unwrap();
5695
5696        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
5697        let canonical_404 = std::fs::canonicalize(errors_dir.join("404.html")).unwrap();
5698
5699        let registry = make_test_registry();
5700        let serve_dir = ServeDir::new(&canonical_dir)
5701            .precompressed_gzip()
5702            .precompressed_br()
5703            .append_index_html_on_directories(true);
5704
5705        let mut error_pages = std::collections::HashMap::new();
5706        error_pages.insert(404, canonical_404);
5707
5708        let mount = StaticMount {
5709            mount_path: "/".to_string(),
5710            mode: MountMode::Static,
5711            dir: canonical_dir.clone(),
5712            cache_control: "public, max-age=0".to_string(),
5713            error_pages,
5714            serve_dir,
5715        };
5716        registry.register_static_mount(mount).await.unwrap();
5717
5718        let state = make_test_state(registry);
5719
5720        // Request non-existent file → custom 404 page
5721        let req = Request::builder()
5722            .method("GET")
5723            .uri("/missing.html")
5724            .body(AxumBody::empty())
5725            .unwrap();
5726        let resp = static_dispatch::dispatch_static(&state, req, "/missing.html").await;
5727        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
5728        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5729            .await
5730            .unwrap();
5731        assert_eq!(&body[..], b"<h1>Custom 404</h1>");
5732
5733        // Existing file still works
5734        let req = Request::builder()
5735            .method("GET")
5736            .uri("/index.html")
5737            .body(AxumBody::empty())
5738            .unwrap();
5739        let resp = static_dispatch::dispatch_static(&state, req, "/index.html").await;
5740        assert_eq!(resp.status(), StatusCode::OK);
5741        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
5742            .await
5743            .unwrap();
5744        assert_eq!(&body[..], b"<h1>Home</h1>");
5745
5746        // Cleanup
5747        std::fs::remove_dir_all(&temp_dir).ok();
5748    }
5749
5750    #[tokio::test]
5751    async fn http_consumer_returns_body_and_code_on_stop() {
5752        use camel_api::{Body, BoxProcessor, BoxProcessorExt, Exchange, Message};
5753        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
5754        use tower::ServiceExt;
5755
5756        // Pipeline: set_body("nope") + set CamelHttpResponseCode=409 + Stop.
5757        let set_body_step = CompiledStep::Process {
5758            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
5759                ex.input.body = Body::Text("nope".into());
5760                Box::pin(async move { Ok(ex) })
5761            }),
5762            body_contract: None,
5763            lifecycle: None,
5764        };
5765        let set_status_step = CompiledStep::Process {
5766            processor: BoxProcessor::from_fn(|mut ex: Exchange| {
5767                ex.input.set_header(
5768                    "CamelHttpResponseCode",
5769                    serde_json::Value::Number(409.into()),
5770                );
5771                Box::pin(async move { Ok(ex) })
5772            }),
5773            body_contract: None,
5774            lifecycle: None,
5775        };
5776        let pipeline = compose_pipeline_with_handler(
5777            vec![set_body_step, set_status_step, CompiledStep::Stop],
5778            None,
5779            PipelineRuntimeCtx::compile_time(),
5780        );
5781
5782        let ex = Exchange::new(Message::default());
5783        let result = pipeline.oneshot(ex).await;
5784        assert!(result.is_ok(), "Stop must arrive as Ok (Bug B fix)");
5785        let returned = result.unwrap();
5786        assert_eq!(returned.input.body.as_text(), Some("nope"));
5787        assert_eq!(
5788            returned
5789                .input
5790                .header("CamelHttpResponseCode")
5791                .and_then(|v| v.as_u64()),
5792            Some(409)
5793        );
5794    }
5795
5796    #[tokio::test]
5797    async fn http_consumer_returns_200_when_body_empty_on_stop() {
5798        // After ADR-0024: Stop with no body + no status header produces 200 (same as
5799        // a normal completion with no body). The 204 default is gone — users who
5800        // want 204 set CamelHttpResponseCode=204 explicitly.
5801        //
5802        // This test stays at the pipeline level (consistent with the test above).
5803        // E2E coverage of the full HTTP dispatch path is in
5804        // crates/camel-test/tests/integration_test.rs.
5805        use camel_api::{Exchange, Message};
5806        use camel_core::route::{CompiledStep, PipelineRuntimeCtx, compose_pipeline_with_handler};
5807        use tower::ServiceExt;
5808
5809        let pipeline = compose_pipeline_with_handler(
5810            vec![CompiledStep::Stop],
5811            None,
5812            PipelineRuntimeCtx::compile_time(),
5813        );
5814        let ex = Exchange::new(Message::default());
5815        let result = pipeline.oneshot(ex).await;
5816        assert!(result.is_ok(), "Stop with empty body arrives as Ok");
5817        // Body is default (empty); no CamelHttpResponseCode header was set.
5818        // The HTTP reply finaliser (tested at E2E) maps this to status=200 + empty body.
5819    }
5820
5821    // -----------------------------------------------------------------------
5822    // Task 5: Method-aware REST dispatch tests
5823    // -----------------------------------------------------------------------
5824
5825    /// Spins up an axum server on a free port with a fresh registry.
5826    /// Returns the port plus the registry so the caller can register
5827    /// REST endpoints directly.
5828    async fn spawn_test_server() -> (u16, HttpRouteRegistry) {
5829        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5830        let port = listener.local_addr().unwrap().port();
5831        let registry = HttpRouteRegistry::new();
5832        tokio::spawn(run_axum_server(
5833            listener,
5834            registry.clone(),
5835            2 * 1024 * 1024,
5836            10 * 1024 * 1024,
5837            Arc::new(tokio::sync::Semaphore::new(1024)),
5838            test_rt(),
5839            "test-route".into(),
5840        ));
5841        // Give the server a moment to start accepting.
5842        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5843        (port, registry)
5844    }
5845
5846    /// Helper for REST integration tests: spawns a responder task that
5847    /// reads from `rx`, writes a fixed `(status, body)` back via the
5848    /// envelope's reply channel, and returns once the test request is
5849    /// satisfied.
5850    fn spawn_responder(
5851        mut rx: tokio::sync::mpsc::Receiver<RequestEnvelope>,
5852        status: u16,
5853        body: String,
5854    ) -> tokio::task::JoinHandle<()> {
5855        tokio::spawn(async move {
5856            if let Some(envelope) = rx.recv().await {
5857                let _ = envelope.reply_tx.send(HttpReply {
5858                    status,
5859                    headers: vec![],
5860                    body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
5861                });
5862            }
5863        })
5864    }
5865
5866    #[tokio::test]
5867    async fn method_aware_dispatch_same_path_different_verbs() {
5868        let (port, registry) = spawn_test_server().await;
5869
5870        // Register two REST endpoints on the same path with different
5871        // methods. This is the core scenario REST DSL needs to support:
5872        // GET /users (list) and POST /users (create) must not overwrite
5873        // each other.
5874        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
5875        registry
5876            .register_rest_endpoint(
5877                "GET".into(),
5878                vec![PathSegment::Literal("users".into())],
5879                get_tx,
5880            )
5881            .await;
5882
5883        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
5884        registry
5885            .register_rest_endpoint(
5886                "POST".into(),
5887                vec![PathSegment::Literal("users".into())],
5888                post_tx,
5889            )
5890            .await;
5891
5892        let get_handle = spawn_responder(get_rx, 200, "list".into());
5893        let post_handle = spawn_responder(post_rx, 201, "create".into());
5894
5895        let client = reqwest::Client::new();
5896
5897        // GET /users → list route
5898        let resp = client
5899            .get(format!("http://127.0.0.1:{port}/users"))
5900            .send()
5901            .await
5902            .unwrap();
5903        assert_eq!(resp.status().as_u16(), 200);
5904        let body = resp.text().await.unwrap();
5905        assert_eq!(body, "list");
5906
5907        // POST /users → create route
5908        let resp = client
5909            .post(format!("http://127.0.0.1:{port}/users"))
5910            .send()
5911            .await
5912            .unwrap();
5913        assert_eq!(resp.status().as_u16(), 201);
5914        let body = resp.text().await.unwrap();
5915        assert_eq!(body, "create");
5916
5917        let _ = tokio::join!(get_handle, post_handle);
5918    }
5919
5920    #[tokio::test]
5921    async fn method_aware_dispatch_templated_path_extracts_params() {
5922        let (port, registry) = spawn_test_server().await;
5923
5924        // Register GET /users/{id} as a templated endpoint. The
5925        // dispatcher should match `/users/42` against the template and
5926        // attach `id=42` to the envelope's path_params.
5927        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
5928        registry
5929            .register_rest_endpoint(
5930                "GET".into(),
5931                vec![
5932                    PathSegment::Literal("users".into()),
5933                    PathSegment::Param("id".into()),
5934                ],
5935                tx,
5936            )
5937            .await;
5938
5939        // Spawn a responder that echoes the captured id back in the body
5940        // so the test can verify the param was set.
5941        let handle = tokio::spawn(async move {
5942            if let Some(envelope) = rx.recv().await {
5943                let id = envelope.path_params.get("id").cloned().unwrap_or_default();
5944                let _ = envelope.reply_tx.send(HttpReply {
5945                    status: 200,
5946                    headers: vec![],
5947                    body: HttpReplyBody::Bytes(bytes::Bytes::from(format!("id={id}"))),
5948                });
5949            }
5950        });
5951
5952        let client = reqwest::Client::new();
5953        let resp = client
5954            .get(format!("http://127.0.0.1:{port}/users/42"))
5955            .send()
5956            .await
5957            .unwrap();
5958        assert_eq!(resp.status().as_u16(), 200);
5959        let body = resp.text().await.unwrap();
5960        assert_eq!(body, "id=42");
5961
5962        let _ = handle.await;
5963    }
5964
5965    #[tokio::test]
5966    async fn method_aware_dispatch_unmatched_method_falls_through() {
5967        // If no REST endpoint matches the method, dispatch must fall
5968        // through to the legacy api_routes lookup or static mounts. With
5969        // nothing else registered, the request gets 404 from static
5970        // dispatch.
5971        let (port, _registry) = spawn_test_server().await;
5972
5973        // Register only GET /users; a DELETE /users request has no match.
5974        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
5975        _registry
5976            .register_rest_endpoint(
5977                "GET".into(),
5978                vec![PathSegment::Literal("users".into())],
5979                get_tx,
5980            )
5981            .await;
5982
5983        // Drain the GET channel in the background so the consumer side
5984        // doesn't block (we don't expect any envelopes here).
5985        let drain = tokio::spawn(async move {
5986            let mut get_rx = get_rx;
5987            while get_rx.recv().await.is_some() {}
5988        });
5989
5990        let client = reqwest::Client::new();
5991        let resp = client
5992            .delete(format!("http://127.0.0.1:{port}/users"))
5993            .send()
5994            .await
5995            .unwrap();
5996        assert_eq!(resp.status().as_u16(), 404);
5997
5998        drop(drain);
5999    }
6000
6001    #[tokio::test]
6002    async fn regression_legacy_exact_api_route_still_works() {
6003        // A `http:` route registered without an `httpMethod=` URI param
6004        // lands in the legacy api_routes registry. The dispatcher must
6005        // still find it via exact path lookup. This guards against
6006        // regressions introduced by the new REST-aware dispatch.
6007        let (port, registry) = spawn_test_server().await;
6008
6009        let (tx, mut rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6010        registry.register_api_route("/legacy/path".into(), tx).await;
6011
6012        let handle = tokio::spawn(async move {
6013            if let Some(envelope) = rx.recv().await {
6014                let _ = envelope.reply_tx.send(HttpReply {
6015                    status: 200,
6016                    headers: vec![],
6017                    body: HttpReplyBody::Bytes(bytes::Bytes::from("legacy ok")),
6018                });
6019            }
6020        });
6021
6022        let client = reqwest::Client::new();
6023        let resp = client
6024            .get(format!("http://127.0.0.1:{port}/legacy/path"))
6025            .send()
6026            .await
6027            .unwrap();
6028        assert_eq!(resp.status().as_u16(), 200);
6029        let body = resp.text().await.unwrap();
6030        assert_eq!(body, "legacy ok");
6031
6032        let _ = handle.await;
6033    }
6034
6035    #[allow(clippy::await_holding_lock)]
6036    #[tokio::test]
6037    async fn regression_static_mount_still_works() {
6038        // Verify that static file serving still works after the
6039        // dispatch refactor. We register a temp-dir mount and request
6040        // a file from it; the static dispatcher should serve it.
6041        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6042        ServerRegistry::reset();
6043
6044        let temp_dir = std::env::temp_dir().join(format!("http_regress_{}", std::process::id()));
6045        std::fs::create_dir_all(&temp_dir).unwrap();
6046        std::fs::write(temp_dir.join("regress.txt"), "static works").unwrap();
6047        let canonical_dir = std::fs::canonicalize(&temp_dir).unwrap();
6048
6049        let registry = make_test_registry();
6050        let serve_dir = ServeDir::new(&canonical_dir)
6051            .precompressed_gzip()
6052            .precompressed_br()
6053            .append_index_html_on_directories(true);
6054        let mount = StaticMount {
6055            mount_path: "/".to_string(),
6056            mode: MountMode::Static,
6057            dir: canonical_dir.clone(),
6058            cache_control: "public, max-age=3600".to_string(),
6059            error_pages: std::collections::HashMap::new(),
6060            serve_dir,
6061        };
6062        registry.register_static_mount(mount).await.unwrap();
6063
6064        let state = make_test_state(registry);
6065        let req = Request::builder()
6066            .uri("/regress.txt")
6067            .body(AxumBody::empty())
6068            .unwrap();
6069        let resp = static_dispatch::dispatch_static(&state, req, "/regress.txt").await;
6070        assert_eq!(resp.status(), StatusCode::OK);
6071        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
6072            .await
6073            .unwrap();
6074        assert_eq!(&body[..], b"static works");
6075
6076        std::fs::remove_dir_all(&temp_dir).ok();
6077    }
6078
6079    // -----------------------------------------------------------------------
6080    // Review I4: dispatch-layer regression coverage for C1/C2/C3 + the
6081    // templated from-URI round-trip. These exercise the real axum dispatch
6082    // path (register → HTTP request → reply) so a regression in any of the
6083    // three critical fixes surfaces as a test failure rather than a silent
6084    // production 404/500.
6085    // -----------------------------------------------------------------------
6086
6087    #[tokio::test]
6088    async fn deregister_one_method_keeps_sibling_verbs() {
6089        // Review C1: stopping the GET /users consumer must NOT tear down the
6090        // live POST /users endpoint. Register both, deregister GET only,
6091        // then verify POST still dispatches.
6092        let (port, registry) = spawn_test_server().await;
6093
6094        let (get_tx, get_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6095        registry
6096            .register_rest_endpoint(
6097                "GET".into(),
6098                vec![PathSegment::Literal("users".into())],
6099                get_tx,
6100            )
6101            .await;
6102
6103        let (post_tx, post_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6104        registry
6105            .register_rest_endpoint(
6106                "POST".into(),
6107                vec![PathSegment::Literal("users".into())],
6108                post_tx,
6109            )
6110            .await;
6111
6112        // Drain GET in the background (no requests expected after deregister).
6113        let drain = tokio::spawn(async move {
6114            let mut get_rx = get_rx;
6115            while get_rx.recv().await.is_some() {}
6116        });
6117
6118        // Deregister ONLY the GET endpoint — the C1 bug used to drop POST too.
6119        registry.unregister_rest_endpoint("GET", "/users").await;
6120        drop(drain);
6121
6122        let post_handle = spawn_responder(post_rx, 201, "create".into());
6123
6124        let client = reqwest::Client::new();
6125        // POST /users must still reach its consumer after GET was removed.
6126        let resp = client
6127            .post(format!("http://127.0.0.1:{port}/users"))
6128            .send()
6129            .await
6130            .unwrap();
6131        assert_eq!(resp.status().as_u16(), 201);
6132        assert_eq!(resp.text().await.unwrap(), "create");
6133
6134        let _ = post_handle.await;
6135    }
6136
6137    #[tokio::test]
6138    async fn dispatch_exact_legacy_beats_rest_template() {
6139        // Review C2: an exact legacy API route (`GET /api/users`, no
6140        // httpMethod) must win over a templated REST route
6141        // (`GET /api/{resource}`) for the request `/api/users`, per spec
6142        // §7.2 / ADR-0009 precedence (exact → templated → static → SPA).
6143        let (port, registry) = spawn_test_server().await;
6144
6145        // Exact legacy route.
6146        let (exact_tx, exact_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6147        registry
6148            .register_api_route("/api/users".into(), exact_tx)
6149            .await;
6150        let exact_handle = spawn_responder(exact_rx, 200, "exact".into());
6151
6152        // Templated REST route that would ALSO match /api/users.
6153        let (tpl_tx, tpl_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6154        registry
6155            .register_rest_endpoint(
6156                "GET".into(),
6157                vec![
6158                    PathSegment::Literal("api".into()),
6159                    PathSegment::Param("resource".into()),
6160                ],
6161                tpl_tx,
6162            )
6163            .await;
6164        // The templated handler must NOT receive the /api/users request. If
6165        // it does, it replies "template-leak" so a future assertion could
6166        // catch it. We do NOT await this task: the exact-match branch wins
6167        // and the templated channel never receives, so awaiting would block
6168        // until the test runtime tears down.
6169        let _tpl_drain = tokio::spawn(async move {
6170            let mut tpl_rx = tpl_rx;
6171            if let Some(env) = tpl_rx.recv().await {
6172                let _ = env.reply_tx.send(HttpReply {
6173                    status: 200,
6174                    headers: vec![],
6175                    body: HttpReplyBody::Bytes(bytes::Bytes::from("template-leak")),
6176                });
6177            }
6178        });
6179
6180        let client = reqwest::Client::new();
6181        let resp = client
6182            .get(format!("http://127.0.0.1:{port}/api/users"))
6183            .send()
6184            .await
6185            .unwrap();
6186        assert_eq!(resp.status().as_u16(), 200);
6187        // Exact-match handler answered — not the templated one.
6188        assert_eq!(resp.text().await.unwrap(), "exact");
6189
6190        let _ = exact_handle.await;
6191    }
6192
6193    #[tokio::test]
6194    async fn ambiguous_rest_templates_return_500_not_silent_404() {
6195        // Review C3: two equal-specificity templates that both match one
6196        // request are an ambiguous registration. At runtime this must
6197        // surface as a loud 500 (with a warn! log), NOT a silent fall-through
6198        // to 404. Compile-time rejection is covered in camel-dsl rest tests.
6199        let (port, registry) = spawn_test_server().await;
6200
6201        let (a_tx, _a_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6202        registry
6203            .register_rest_endpoint(
6204                "GET".into(),
6205                vec![
6206                    PathSegment::Literal("users".into()),
6207                    PathSegment::Param("id".into()),
6208                ],
6209                a_tx,
6210            )
6211            .await;
6212
6213        let (b_tx, _b_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(8);
6214        registry
6215            .register_rest_endpoint(
6216                "GET".into(),
6217                vec![
6218                    PathSegment::Literal("users".into()),
6219                    PathSegment::Param("name".into()),
6220                ],
6221                b_tx,
6222            )
6223            .await;
6224
6225        let client = reqwest::Client::new();
6226        let resp = client
6227            .get(format!("http://127.0.0.1:{port}/users/42"))
6228            .send()
6229            .await
6230            .unwrap();
6231        // Ambiguous → 500 (previously a silent 404).
6232        assert_eq!(resp.status().as_u16(), 500);
6233    }
6234
6235    #[test]
6236    fn from_uri_round_trips_templated_path_with_http_method() {
6237        // Review I4: a REST-lowered from-URI like
6238        // `http://0.0.0.0:8080/users/{id}?httpMethod=GET` must round-trip
6239        // through HttpServerConfig::from_uri, preserving the templated path
6240        // and the (uppercased) method. This is the binding the DSL lowering
6241        // emits and the consumer reads; it was previously unasserted.
6242        use crate::UriConfig;
6243        let cfg =
6244            HttpServerConfig::from_uri("http://0.0.0.0:8080/users/{id}?httpMethod=GET").unwrap();
6245        assert_eq!(cfg.host, "0.0.0.0");
6246        assert_eq!(cfg.port, 8080);
6247        assert_eq!(cfg.path, "/users/{id}");
6248        assert_eq!(cfg.method.as_deref(), Some("GET"));
6249
6250        // Lower-case httpMethod is uppercased (review I5).
6251        let cfg_lc =
6252            HttpServerConfig::from_uri("http://0.0.0.0:8080/orders?httpMethod=post").unwrap();
6253        assert_eq!(cfg_lc.method.as_deref(), Some("POST"));
6254        assert_eq!(cfg_lc.path, "/orders");
6255    }
6256
6257    // -----------------------------------------------------------------------
6258    // rc-1dk4: TypeConversionFailed → 400 Bad Request
6259    // -----------------------------------------------------------------------
6260
6261    #[test]
6262    fn type_conversion_failed_maps_to_400() {
6263        let reply = pipeline_error_to_reply(
6264            CamelError::TypeConversionFailed("invalid JSON at line 1".to_string()),
6265            "/api/users",
6266        );
6267        assert_eq!(reply.status, 400);
6268        // Content-Type must be application/json
6269        let ct = reply
6270            .headers
6271            .iter()
6272            .find(|(k, _)| k == "Content-Type")
6273            .map(|(_, v)| v.as_str());
6274        assert_eq!(ct, Some("application/json"));
6275        // Body must contain structured error JSON
6276        let body = match &reply.body {
6277            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
6278            _ => panic!("expected bytes body"),
6279        };
6280        assert!(body.contains("\"error\""));
6281        assert!(body.contains("bad_request"));
6282        assert!(body.contains("invalid JSON at line 1"));
6283    }
6284
6285    #[test]
6286    fn other_error_still_maps_to_500() {
6287        let reply =
6288            pipeline_error_to_reply(CamelError::RouteError("boom".to_string()), "/api/users");
6289        assert_eq!(reply.status, 500);
6290    }
6291
6292    #[test]
6293    fn unauthenticated_maps_to_401() {
6294        let reply = pipeline_error_to_reply(
6295            CamelError::Unauthenticated("no token".to_string()),
6296            "/api/users",
6297        );
6298        assert_eq!(reply.status, 401);
6299    }
6300
6301    #[test]
6302    fn unauthorized_maps_to_403() {
6303        let reply = pipeline_error_to_reply(
6304            CamelError::Unauthorized("forbidden".to_string()),
6305            "/api/users",
6306        );
6307        assert_eq!(reply.status, 403);
6308    }
6309
6310    #[test]
6311    fn validation_error_maps_to_400() {
6312        let reply = pipeline_error_to_reply(
6313            CamelError::ValidationError("body does not match schema".to_string()),
6314            "/api/users",
6315        );
6316        assert_eq!(reply.status, 400);
6317        let ct = reply
6318            .headers
6319            .iter()
6320            .find(|(k, _)| k == "Content-Type")
6321            .map(|(_, v)| v.as_str());
6322        assert_eq!(ct, Some("application/json"));
6323        let body = match &reply.body {
6324            HttpReplyBody::Bytes(b) => String::from_utf8_lossy(b).to_string(),
6325            _ => panic!("expected bytes body"),
6326        };
6327        assert!(body.contains("\"error\""));
6328        assert!(body.contains("validation_error"));
6329        assert!(body.contains("body does not match schema"));
6330    }
6331
6332    #[test]
6333    fn https_consumer_without_tls_cert_errors() {
6334        let endpoint = HttpEndpoint {
6335            uri: "https://0.0.0.0:8443/api".to_string(),
6336            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
6337            server_config: HttpServerConfig::from_uri("https://0.0.0.0:8443/api").unwrap(),
6338            client: reqwest::Client::new(),
6339            http_config: HttpConfig::default(),
6340        };
6341        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
6342        let result = endpoint.create_consumer(rt);
6343        assert!(result.is_err(), "expected error for https without tls cert");
6344        if let Err(e) = result {
6345            let msg = e.to_string();
6346            assert!(msg.contains("tlsCert"), "error must mention tlsCert: {msg}");
6347        }
6348    }
6349
6350    #[test]
6351    fn http_consumer_with_tls_config_errors() {
6352        let endpoint = HttpEndpoint {
6353            uri: "http://0.0.0.0:8080/api".to_string(),
6354            config: HttpEndpointConfig::from_uri("http://0.0.0.0:8080/api").unwrap(),
6355            server_config: HttpServerConfig::from_uri(
6356                "http://0.0.0.0:8080/api?tlsCert=/x.pem&tlsKey=/y.pem",
6357            )
6358            .unwrap(),
6359            client: reqwest::Client::new(),
6360            http_config: HttpConfig::default(),
6361        };
6362        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
6363        let result = endpoint.create_consumer(rt);
6364        assert!(result.is_err(), "expected error for http with tls config");
6365        if let Err(e) = result {
6366            let msg = e.to_string();
6367            assert!(msg.contains("https"), "error must mention https: {msg}");
6368        }
6369    }
6370
6371    #[test]
6372    fn https_consumer_with_partial_tls_cert_only_errors() {
6373        // tlsCert without tlsKey → tls_config is None at parse time
6374        // → create_consumer sees https:// + no TLS → must error
6375        let server_config =
6376            HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
6377        assert!(
6378            server_config.tls_config.is_none(),
6379            "partial tlsCert must not create ServerTlsConfig"
6380        );
6381        let endpoint = HttpEndpoint {
6382            uri: "https://0.0.0.0:8443/api?tlsCert=/x.pem".to_string(),
6383            config: HttpEndpointConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem")
6384                .unwrap(),
6385            server_config,
6386            client: reqwest::Client::new(),
6387            http_config: HttpConfig::default(),
6388        };
6389        let rt: Arc<dyn RuntimeObservability> = Arc::new(NoopRuntimeObservability);
6390        let result = endpoint.create_consumer(rt);
6391        assert!(
6392            result.is_err(),
6393            "must error: https:// requires both tlsCert and tlsKey"
6394        );
6395    }
6396
6397    #[test]
6398    fn load_tls_config_parses_valid_pem() {
6399        // Install rustls crypto provider (aws-lc-rs — matches reqwest/hyper-rustls tree)
6400        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
6401        use camel_component_api::test_support::tls;
6402        let (_, cert_pem, key_pem) = tls::gen_server_cert();
6403        let cert_path = tls::write_pem_tmp("http-load-cert.pem", &cert_pem);
6404        let key_path = tls::write_pem_tmp("http-load-key.pem", &key_pem);
6405
6406        let config = load_tls_config(cert_path.to_str().unwrap(), key_path.to_str().unwrap());
6407        assert!(config.is_ok(), "must parse valid PEM: {:?}", config.err());
6408    }
6409
6410    #[tokio::test(flavor = "multi_thread")]
6411    #[allow(clippy::await_holding_lock)]
6412    async fn consumer_tls_handshake_roundtrip() {
6413        use camel_component_api::test_support::tls;
6414        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6415
6416        // Install rustls crypto provider (aws-lc-rs)
6417        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
6418
6419        // Serialize against global ServerRegistry singleton
6420        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6421
6422        // Generate CA + server cert
6423        let (ca_pem, cert_pem, key_pem) = tls::gen_server_cert();
6424        let cert_path = tls::write_pem_tmp("http-tls-handshake-cert.pem", &cert_pem);
6425        let key_path = tls::write_pem_tmp("http-tls-handshake-key.pem", &key_pem);
6426        let ca_path = tls::write_pem_tmp("http-tls-handshake-ca.pem", &ca_pem);
6427
6428        // Get ephemeral port
6429        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6430        let port = probe.local_addr().unwrap().port();
6431        drop(probe);
6432
6433        ServerRegistry::reset();
6434
6435        // Create real HttpComponent + endpoint with TLS URI
6436        let component = HttpComponent::new();
6437        let endpoint_ctx = NoOpComponentContext;
6438        let uri = format!(
6439            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
6440            cert_path.to_string_lossy(),
6441            key_path.to_string_lossy(),
6442        );
6443        let endpoint = component
6444            .create_endpoint(&uri, &endpoint_ctx)
6445            .expect("create TLS endpoint");
6446        let mut consumer = endpoint.create_consumer(rt()).expect("create consumer");
6447
6448        // Start consumer — this calls get_or_spawn with tls_config
6449        let (tx, mut rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6450        let token = tokio_util::sync::CancellationToken::new();
6451        let ctx = ConsumerContext::new(tx, token.clone(), "tls-handshake-test".to_string());
6452        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6453
6454        // Give server time to start
6455        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
6456
6457        // Client with CA cert — REAL verification (no danger_accept_invalid)
6458        let ca_bytes = std::fs::read(&ca_path).unwrap();
6459        let client = reqwest::Client::builder()
6460            .add_root_certificate(reqwest::Certificate::from_pem(&ca_bytes).unwrap())
6461            .build()
6462            .unwrap();
6463
6464        let send_fut = client
6465            .post(format!("https://localhost:{port}/test"))
6466            .body("ping")
6467            .send();
6468
6469        // Handler: receive envelope, reply 200 with "pong" body
6470        let (http_result, _) = tokio::join!(send_fut, async {
6471            if let Some(mut envelope) = rx.recv().await {
6472                envelope.exchange.input.body = camel_component_api::Body::Text("pong".to_string());
6473                if let Some(reply_tx) = envelope.reply_tx {
6474                    let _ = reply_tx.send(Ok(envelope.exchange));
6475                }
6476            }
6477        });
6478
6479        let resp = http_result.expect("TLS handshake + request must succeed");
6480
6481        assert_eq!(resp.status().as_u16(), 200, "must get 200 through TLS");
6482        let body = resp.text().await.unwrap();
6483        assert_eq!(body, "pong");
6484
6485        token.cancel();
6486    }
6487
6488    #[tokio::test(flavor = "multi_thread")]
6489    #[allow(clippy::await_holding_lock)]
6490    async fn consumer_tls_rejects_client_without_ca() {
6491        use camel_component_api::test_support::tls;
6492        use camel_component_api::{ConsumerContext, ExchangeEnvelope};
6493
6494        let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default();
6495
6496        // Serialize against global ServerRegistry singleton
6497        let _guard = REGISTRY_TEST_MUTEX.lock().unwrap();
6498
6499        let (_, cert_pem, key_pem) = tls::gen_server_cert();
6500        let cert_path = tls::write_pem_tmp("http-neg-cert.pem", &cert_pem);
6501        let key_path = tls::write_pem_tmp("http-neg-key.pem", &key_pem);
6502
6503        let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6504        let port = probe.local_addr().unwrap().port();
6505        drop(probe);
6506
6507        ServerRegistry::reset();
6508
6509        // Spawn TLS server via real HttpComponent path
6510        let component = HttpComponent::new();
6511        let endpoint_ctx = NoOpComponentContext;
6512        let uri = format!(
6513            "https://127.0.0.1:{port}/test?tlsCert={}&tlsKey={}",
6514            cert_path.to_string_lossy(),
6515            key_path.to_string_lossy(),
6516        );
6517        let endpoint = component.create_endpoint(&uri, &endpoint_ctx).unwrap();
6518        let mut consumer = endpoint.create_consumer(rt()).unwrap();
6519        let (tx, _rx) = tokio::sync::mpsc::channel::<ExchangeEnvelope>(16);
6520        let token = tokio_util::sync::CancellationToken::new();
6521        let ctx = ConsumerContext::new(tx, token.clone(), "tls-neg-test".to_string());
6522        tokio::spawn(async move { consumer.start(ctx).await.unwrap() });
6523
6524        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
6525
6526        // Client WITHOUT CA cert — must fail TLS verification
6527        let client = reqwest::Client::builder().build().unwrap();
6528
6529        let result = client
6530            .get(format!("https://localhost:{port}/test"))
6531            .send()
6532            .await;
6533
6534        assert!(
6535            result.is_err(),
6536            "must reject without CA — proves real verification"
6537        );
6538
6539        token.cancel();
6540    }
6541
6542    #[test]
6543    fn server_config_partial_tls_cert_without_key() {
6544        // Parse URI with only tlsCert (no tlsKey)
6545        let cfg = HttpServerConfig::from_uri("https://0.0.0.0:8443/api?tlsCert=/x.pem").unwrap();
6546        // Partial params → tls_config must be None
6547        assert!(cfg.tls_config.is_none());
6548    }
6549}