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