Skip to main content

camel_component_http/
lib.rs

1pub mod bundle;
2pub(crate) mod client_cache;
3pub mod config;
4mod header_policy;
5pub mod health;
6#[cfg(test)]
7mod health_wiring_tests;
8pub mod registry;
9pub(crate) mod rest_match;
10pub(crate) mod ssrf;
11pub mod static_config;
12pub mod static_dispatch;
13pub mod static_endpoint;
14pub(crate) mod tls;
15#[cfg(test)]
16mod tls_harness;
17pub(crate) mod tls_reload;
18use crate::config::parse_ok_status_code_range;
19pub use bundle::HttpBundle;
20pub use bundle::HttpStaticBundle;
21pub(crate) use client_cache::{
22    HttpComponentKind, PINNED_CLIENT_MAX_ENTRIES, PINNED_CLIENT_TTL, PinnedClientCache,
23};
24pub use config::HttpConfig;
25pub use health::HttpHealthCheck;
26use health::ListenerGate;
27pub use registry::HttpRouteRegistry;
28pub use static_config::HttpStaticConfig;
29pub use static_endpoint::{HttpStaticComponent, HttpStaticConsumer, HttpStaticEndpoint};
30#[cfg(test)]
31pub(crate) use tls::{
32    FORCE_FALLBACK_REBUILD_FAIL, FORCE_WEBPKI_FALLBACK, build_client_call_count,
33    build_client_fallback_count,
34};
35#[cfg(test)]
36// Doc-link shim: tls_harness.rs links `crate::FallbackTrigger::Forced` and
37// `crate::webpki_fallback_client` from doc comments only — no code use, so
38// the re-export must be allowed unused to keep those links resolving.
39#[allow(unused_imports)]
40pub(crate) use tls::{FallbackTrigger, webpki_fallback_client};
41pub(crate) use tls::{build_client, client_or_emergency, strict_tls_error};
42
43use std::collections::HashMap;
44use std::future::Future;
45use std::pin::Pin;
46
47use std::sync::{Arc, Mutex, OnceLock};
48use std::task::{Context, Poll};
49use std::time::Duration;
50
51use tokio::sync::OnceCell;
52use tower::Layer;
53use tower::Service;
54use tracing::debug;
55
56use axum::body::BodyDataStream;
57use camel_api::component_metadata::ComponentMetadata;
58use camel_auth::bearer_token_layer::BearerTokenLayer;
59use camel_auth::oauth2::TokenProvider;
60use camel_component_api::tls_source::ServerTlsSource;
61use camel_component_api::{Body, BoxProcessor, CamelError, Exchange, StreamBody, StreamMetadata};
62use camel_component_api::{Component, Consumer, Endpoint, ProducerContext, RuntimeObservability};
63use camel_component_api::{UriComponents, UriConfig, parse_uri, raw_query_pairs};
64use futures::StreamExt;
65use futures::TryStreamExt;
66use futures::stream::BoxStream;
67
68// ---------------------------------------------------------------------------
69// HttpEndpointConfig
70// ---------------------------------------------------------------------------
71
72/// Configuration for an HTTP client (producer) endpoint.
73///
74/// # Memory Limits
75///
76/// HTTP operations enforce conservative memory limits to prevent denial-of-service
77/// attacks from untrusted network sources. These limits are significantly lower than
78/// file component limits (100MB) because HTTP typically handles API responses rather
79/// than large file transfers, and clients may be untrusted.
80///
81/// ## Default Limits
82///
83/// - **HTTP client body**: 10MB (typical API responses)
84/// - **HTTP server request**: 2MB (untrusted network input - see `HttpServerConfig`)
85/// - **HTTP server response**: 10MB (same as client - see `HttpServerConfig`)
86///
87/// ## Rationale
88///
89/// The 10MB limit for HTTP client responses is appropriate for most API interactions
90/// while providing protection against:
91/// - Malicious servers sending oversized responses
92/// - Runaway processes generating unexpectedly large payloads
93/// - Memory exhaustion attacks
94///
95/// The 2MB server request limit is even more conservative because it handles input
96/// from potentially untrusted clients on the public internet.
97///
98/// ## Overriding Limits
99///
100/// Override the default client body limit using the `maxBodySize` URI parameter:
101///
102/// ```text
103/// http://api.example.com/large-data?maxBodySize=52428800
104/// ```
105///
106/// For server endpoints, use `maxRequestBody` and `maxResponseBody` parameters:
107///
108/// ```text
109/// http://0.0.0.0:8080/upload?maxRequestBody=52428800
110/// ```
111///
112/// ## Behavior When Exceeded
113///
114/// When a body exceeds the configured limit:
115/// - An error is returned immediately
116/// - No memory is exhausted - the limit is checked before allocation
117/// - The HTTP connection is terminated cleanly
118///
119/// ## Security Considerations
120///
121/// HTTP endpoints should be treated with more caution than file endpoints because:
122/// - Clients may be unknown and untrusted
123/// - Network traffic can be spoofed or malicious
124/// - DoS attacks often exploit unbounded resource consumption
125///
126/// Only increase limits when you control both ends of the connection or when
127/// business requirements demand larger payloads.
128#[derive(Clone)]
129pub struct HttpEndpointConfig {
130    pub base_url: String,
131    pub http_method: Option<String>,
132    pub throw_exception_on_failure: bool,
133    pub ok_status_code_range: (u16, u16),
134    pub response_timeout: Option<Duration>,
135    /// Programmatic query parameters, serialized in declaration order with
136    /// minimal RFC-3986 encoding (`%20`, never `+`). Never populated from
137    /// the endpoint URI — set by callers via config construction.
138    pub query_params: Vec<(String, String)>,
139    /// Authored query bytes from the endpoint URI, verbatim (no decode, no
140    /// re-encode, no `RAW(...)` unwrapping). `Some("")` preserves a bare
141    /// `?` marker. Sole carrier of URI-authored pairs; consumed option
142    /// keys are filtered out at serialization time.
143    pub raw_query: Option<String>,
144    pub allow_internal: bool,
145    /// Public-cleartext transport consent (ADR-0081): when `false` (the
146    /// default), cleartext `http://` requests to PUBLIC targets are
147    /// rejected. Independent of `allow_internal` — internal-network
148    /// consent and cleartext-transport consent are separate decisions, and
149    /// there is deliberately no global cleartext lever.
150    pub allow_cleartext: bool,
151    pub blocked_hosts: Vec<String>,
152    pub max_body_size: usize,
153    pub read_timeout_ms: u64,
154    pub max_response_bytes: usize,
155    pub auth: HttpAuth,
156    pub token_provider: Option<Arc<dyn TokenProvider>>,
157    pub user_agent: Option<String>,
158    pub bridge_endpoint: bool,
159    pub connection_close: bool,
160    pub skip_request_headers: Vec<String>,
161    pub skip_response_headers: Vec<String>,
162    pub follow_redirects: bool,
163    pub max_redirects: usize,
164    /// CamelHttpUri host fence (`allowedUriHosts`): `None` when the option
165    /// is absent (override behavior unchanged); `Some` arms the fail-closed
166    /// fence. Parsed entries only — never re-serialized into the outbound
167    /// query.
168    pub allowed_uri_hosts: Option<Vec<AllowedUriHost>>,
169}
170
171/// ADR-0051 redact-by-construction, ADR-0076 strictest-wins: query bytes
172/// (authored `raw_query` and programmatic `query_params`) may carry
173/// credentials. The display-surface Debug renders the raw view
174/// blanket-masked (mirroring `redact_url_for_diagnostics`) and programmatic
175/// values masked, mirroring `UriComponents`' sensitive-value masking.
176/// `base_url` routes through the canonical
177/// [`camel_api::redact::redact_url`] (string surgery, no `url::Url`
178/// roundtrip, so authored bytes are never WHATWG-normalized): userinfo is
179/// masked in every authority window, query and fragment bytes are dropped
180/// behind their sentinels, and the result is capped at 256 bytes (rc-yvjp3
181/// converged the former byte-preserving local variant). Wire fidelity is
182/// unaffected.
183impl std::fmt::Debug for HttpEndpointConfig {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        f.debug_struct("HttpEndpointConfig")
186            .field("base_url", &camel_api::redact::redact_url(&self.base_url))
187            .field("http_method", &self.http_method)
188            .field(
189                "throw_exception_on_failure",
190                &self.throw_exception_on_failure,
191            )
192            .field("ok_status_code_range", &self.ok_status_code_range)
193            .field("response_timeout", &self.response_timeout)
194            .field(
195                "query_params",
196                &self
197                    .query_params
198                    .iter()
199                    .map(|(key, _)| (key, "***"))
200                    .collect::<Vec<_>>(),
201            )
202            .field("raw_query", &self.raw_query.as_ref().map(|_| "?[redacted]"))
203            .field("allow_internal", &self.allow_internal)
204            .field("allow_cleartext", &self.allow_cleartext)
205            .field("blocked_hosts", &self.blocked_hosts)
206            .field("max_body_size", &self.max_body_size)
207            .field("read_timeout_ms", &self.read_timeout_ms)
208            .field("max_response_bytes", &self.max_response_bytes)
209            .field("auth", &self.auth)
210            .field("token_provider", &self.token_provider)
211            .field("user_agent", &self.user_agent)
212            .field("bridge_endpoint", &self.bridge_endpoint)
213            .field("connection_close", &self.connection_close)
214            .field("skip_request_headers", &self.skip_request_headers)
215            .field("skip_response_headers", &self.skip_response_headers)
216            .field("follow_redirects", &self.follow_redirects)
217            .field("max_redirects", &self.max_redirects)
218            .field("allowed_uri_hosts", &self.allowed_uri_hosts)
219            .finish()
220    }
221}
222
223#[derive(Clone, PartialEq)]
224pub enum HttpAuth {
225    None,
226    Basic { username: String, password: String },
227    Bearer { token: String },
228}
229
230impl std::fmt::Debug for HttpAuth {
231    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232        match self {
233            HttpAuth::None => f.write_str("None"),
234            HttpAuth::Basic { username, .. } => f
235                .debug_struct("Basic")
236                .field("username", username)
237                .field("password", &"***")
238                .finish(),
239            HttpAuth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
240        }
241    }
242}
243
244/// Whether `key` names a camel-http endpoint option consumed at parse time.
245///
246/// Single metadata-driven owner of OUTBOUND option filtering (ADR-0041):
247/// derived from the `#[uri_param]` metadata behind
248/// [`HttpEndpointConfig::uri_options`], so the raw query filter consumes
249/// exactly the keys the component documents — no duplicated handwritten
250/// key lists. `from_components`'s manual typed parsing stays direct and
251/// unchanged; this predicate never re-wires it.
252fn is_consumed_option(key: &str) -> bool {
253    HttpEndpointConfig::uri_options()
254        .iter()
255        .any(|option| option.name == key || option.aliases.iter().any(|alias| alias == key))
256}
257
258impl UriConfig for HttpEndpointConfig {
259    /// Returns "http" as the primary scheme (also accepts "https")
260    fn scheme() -> &'static str {
261        "http"
262    }
263
264    fn from_uri(uri: &str) -> Result<Self, CamelError> {
265        let parts = parse_uri(uri)?;
266        Self::from_components(parts)
267    }
268
269    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
270        // Validate scheme - accept both http and https
271        if parts.scheme != "http" && parts.scheme != "https" {
272            return Err(CamelError::InvalidUri(format!(
273                "expected scheme 'http' or 'https', got '{}'",
274                parts.scheme
275            )));
276        }
277
278        // Construct base_url from scheme + path
279        // e.g., "http://localhost:8080/api" from scheme "http" and path "//localhost:8080/api"
280        let base_url = format!("{}:{}", parts.scheme, parts.path);
281
282        let http_method = parts.params.get("httpMethod").cloned();
283
284        let throw_exception_on_failure = match parts.params.get("throwExceptionOnFailure") {
285            Some(v) => parse_bool_param_http(v).map_err(|e| {
286                CamelError::InvalidUri(format!("invalid value for throwExceptionOnFailure: {e}"))
287            })?,
288            None => true,
289        };
290
291        // Parse status code range from "start-end" format (e.g., "200-299")
292        let ok_status_code_range = match parts.params.get("okStatusCodeRange") {
293            Some(v) => parse_ok_status_code_range(v)?,
294            None => (200, 299),
295        };
296
297        let response_timeout = match parts.params.get("responseTimeout") {
298            Some(v) => Some(v.parse::<u64>().map(Duration::from_millis).map_err(|e| {
299                CamelError::InvalidUri(format!("invalid value for responseTimeout: {e}"))
300            })?),
301            None => None,
302        };
303
304        // SSRF protection settings
305        let allow_internal = match parts.params.get("allowInternal") {
306            Some(v) => parse_bool_param_http(v).map_err(|e| {
307                CamelError::InvalidUri(format!("invalid value for allowInternal: {e}"))
308            })?,
309            None => false, // Default: block private IPs
310        };
311
312        // Public-cleartext transport consent (ADR-0081) — per-endpoint
313        // only, deliberately never inherited from the global HttpConfig.
314        let allow_cleartext = match parts.params.get("allowCleartext") {
315            Some(v) => parse_bool_param_http(v).map_err(|e| {
316                CamelError::InvalidUri(format!("invalid value for allowCleartext: {e}"))
317            })?,
318            None => false, // Default: reject cleartext http:// to public targets
319        };
320
321        // Parse comma-separated blocked hosts
322        let blocked_hosts = parts
323            .params
324            .get("blockedHosts")
325            .map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
326            .unwrap_or_default();
327
328        let max_body_size = match parts.params.get("maxBodySize") {
329            Some(v) => v.parse::<usize>().map_err(|e| {
330                CamelError::InvalidUri(format!("invalid value for maxBodySize: {e}"))
331            })?,
332            None => 10 * 1024 * 1024, // Default: 10MB
333        };
334
335        let read_timeout_ms = match parts.params.get("readTimeout") {
336            Some(v) => v.parse::<u64>().map_err(|e| {
337                CamelError::InvalidUri(format!("invalid value for readTimeout: {e}"))
338            })?,
339            None => 30_000, // Default: 30s
340        };
341
342        let max_response_bytes = match parts.params.get("maxResponseBytes") {
343            Some(v) => v.parse::<usize>().map_err(|e| {
344                CamelError::InvalidUri(format!("invalid value for maxResponseBytes: {e}"))
345            })?,
346            None => 10 * 1024 * 1024, // Default: 10MB
347        };
348
349        let auth = parse_auth_from_params(&parts.params)?;
350
351        let user_agent = parts.params.get("userAgent").cloned();
352
353        if parts.params.contains_key("cookieHandling") {
354            return Err(CamelError::InvalidUri(
355                "cookieHandling is not supported".into(),
356            ));
357        }
358
359        let bridge_endpoint = match parts.params.get("bridgeEndpoint") {
360            Some(v) => parse_bool_param_http(v).map_err(|e| {
361                CamelError::InvalidUri(format!("invalid value for bridgeEndpoint: {e}"))
362            })?,
363            None => false,
364        };
365
366        let connection_close = match parts.params.get("connectionClose") {
367            Some(v) => parse_bool_param_http(v).map_err(|e| {
368                CamelError::InvalidUri(format!("invalid value for connectionClose: {e}"))
369            })?,
370            None => false,
371        };
372
373        let skip_request_headers = parts
374            .params
375            .get("skipRequestHeaders")
376            .map(|v| {
377                v.split(',')
378                    .map(str::trim)
379                    .filter(|s| !s.is_empty())
380                    .map(|s| s.to_ascii_lowercase())
381                    .collect::<Vec<_>>()
382            })
383            .unwrap_or_default();
384
385        let skip_response_headers = parts
386            .params
387            .get("skipResponseHeaders")
388            .map(|v| {
389                v.split(',')
390                    .map(str::trim)
391                    .filter(|s| !s.is_empty())
392                    .map(|s| s.to_ascii_lowercase())
393                    .collect::<Vec<_>>()
394            })
395            .unwrap_or_default();
396
397        let follow_redirects = match parts.params.get("followRedirects") {
398            Some(v) => parse_bool_param_http(v).map_err(|e| {
399                CamelError::InvalidUri(format!("invalid value for followRedirects: {e}"))
400            })?,
401            None => false,
402        };
403
404        let max_redirects = match parts.params.get("maxRedirects") {
405            Some(v) => v.parse::<usize>().map_err(|e| {
406                CamelError::InvalidUri(format!("invalid value for maxRedirects: {e}"))
407            })?,
408            None => 10,
409        };
410
411        // CamelHttpUri host fence: parsed eagerly so a malformed or empty
412        // allowlist fails endpoint creation (fail-closed), not resolution.
413        let allowed_uri_hosts = match parts.params.get("allowedUriHosts") {
414            Some(v) => Some(parse_allowed_uri_hosts(v)?),
415            None => None,
416        };
417
418        // Authored pairs ride raw_query verbatim (the sole carrier);
419        // query_params is programmatic-only — never auto-populated from
420        // URI leftovers. Consumed option keys are filtered at
421        // serialization time by `is_consumed_option`.
422        let raw_query = parts.raw_query.clone();
423
424        Ok(Self {
425            base_url,
426            http_method,
427            throw_exception_on_failure,
428            ok_status_code_range,
429            response_timeout,
430            query_params: Vec::new(),
431            raw_query,
432            allow_internal,
433            allow_cleartext,
434            blocked_hosts,
435            max_body_size,
436            read_timeout_ms,
437            max_response_bytes,
438            auth,
439            token_provider: None,
440            user_agent,
441            bridge_endpoint,
442            connection_close,
443            skip_request_headers,
444            skip_response_headers,
445            follow_redirects,
446            max_redirects,
447            allowed_uri_hosts,
448        })
449    }
450}
451
452/// Private container for macro-derived `uri_options()` and `metadata()`.
453///
454/// Mirrors the URI query parameters parsed by `HttpEndpointConfig::from_components`.
455/// `HttpEndpointConfig` holds typed fields (tuples, `Duration`, `HttpAuth`,
456/// `Arc<dyn TokenProvider>`) that the derive cannot represent, so metadata
457/// derivation targets this inner type whose fields are all URI-param-compatible.
458#[derive(Debug, Clone, UriConfig)]
459#[allow(dead_code)]
460#[uri_scheme = "http"]
461#[uri_config(
462    skip_impl,
463    metadata(
464        scheme = "http",
465        description = "HTTP client and server component",
466        producer,
467        consumer,
468        streaming
469    ),
470    crate = "camel_component_api"
471)]
472struct HttpEndpointUriConfig {
473    #[allow(dead_code)]
474    _base_url: String,
475
476    #[uri_param(
477        name = "httpMethod",
478        desc = "HTTP method. Defaults to CamelHttpMethod header or POST/GET"
479    )]
480    http_method: Option<String>,
481
482    #[uri_param(
483        name = "throwExceptionOnFailure",
484        default = "true",
485        desc = "Throw on non-2xx status"
486    )]
487    throw_exception_on_failure: bool,
488
489    #[uri_param(
490        name = "okStatusCodeRange",
491        default = "200-299",
492        desc = "Success status code range"
493    )]
494    ok_status_code_range: String,
495
496    #[uri_param(name = "responseTimeout", desc = "Response timeout in milliseconds")]
497    response_timeout: Option<u64>,
498
499    #[uri_param(
500        name = "connectTimeout",
501        desc = "Connection timeout in milliseconds (consumed option; effective timeout comes from the global http config)"
502    )]
503    connect_timeout: Option<u64>,
504
505    #[uri_param(
506        name = "allowInternal",
507        default = "false",
508        desc = "Allow private/internal network destinations (SSRF)"
509    )]
510    allow_internal: bool,
511
512    #[uri_param(
513        name = "allowCleartext",
514        default = "false",
515        desc = "Allow cleartext http:// to public targets (transport consent; ADR-0081)"
516    )]
517    allow_cleartext: bool,
518
519    #[uri_param(name = "blockedHosts", desc = "Comma-separated blocked host list")]
520    blocked_hosts: Option<String>,
521
522    #[uri_param(
523        name = "maxBodySize",
524        default = "10485760",
525        desc = "Max request/response body bytes"
526    )]
527    max_body_size: u64,
528
529    #[uri_param(name = "readTimeout", desc = "Socket read timeout in milliseconds")]
530    read_timeout: Option<u64>,
531
532    #[uri_param(name = "maxResponseBytes", desc = "Max response body bytes")]
533    max_response_bytes: Option<u64>,
534
535    #[uri_param(
536        name = "authMethod",
537        kind = "enum:None,Basic,Bearer",
538        desc = "Authentication method"
539    )]
540    auth_method: Option<String>,
541
542    #[uri_param(name = "authUsername", secret, desc = "Basic auth username")]
543    auth_username: Option<String>,
544
545    #[uri_param(name = "authPassword", secret, desc = "Basic auth password")]
546    auth_password: Option<String>,
547
548    #[uri_param(name = "authBearerToken", secret, desc = "Bearer auth token")]
549    auth_bearer_token: Option<String>,
550
551    #[uri_param(name = "userAgent", desc = "User-Agent header")]
552    user_agent: Option<String>,
553
554    #[uri_param(
555        name = "bridgeEndpoint",
556        default = "false",
557        desc = "Bridge endpoint mode"
558    )]
559    bridge_endpoint: bool,
560
561    #[uri_param(
562        name = "connectionClose",
563        default = "false",
564        desc = "Send Connection: close"
565    )]
566    connection_close: bool,
567
568    #[uri_param(
569        name = "skipRequestHeaders",
570        desc = "Comma-separated request headers to skip"
571    )]
572    skip_request_headers: Option<String>,
573
574    #[uri_param(
575        name = "skipResponseHeaders",
576        desc = "Comma-separated response headers to skip"
577    )]
578    skip_response_headers: Option<String>,
579
580    #[uri_param(
581        name = "followRedirects",
582        default = "false",
583        desc = "Follow HTTP redirects"
584    )]
585    follow_redirects: bool,
586
587    #[uri_param(name = "maxRedirects", default = "10", desc = "Max redirect hops")]
588    max_redirects: u64,
589
590    #[uri_param(
591        name = "allowedUriHosts",
592        desc = "Comma-separated allowlist of CamelHttpUri override hosts (host or host:port)"
593    )]
594    allowed_uri_hosts: Option<String>,
595}
596
597impl HttpEndpointConfig {
598    /// Component metadata for the http/https scheme, derived from the
599    /// `#[uri_param]` fields on `HttpEndpointUriConfig`.
600    pub fn metadata() -> ComponentMetadata {
601        HttpEndpointUriConfig::metadata()
602    }
603
604    /// URI option definitions, derived from `#[uri_param]` fields.
605    pub fn uri_options() -> Vec<camel_api::component_metadata::UriOption> {
606        HttpEndpointUriConfig::uri_options()
607    }
608}
609
610fn parse_auth_from_params(params: &HashMap<String, String>) -> Result<HttpAuth, CamelError> {
611    let Some(method) = params.get("authMethod") else {
612        return Ok(HttpAuth::None);
613    };
614
615    if method.eq_ignore_ascii_case("none") {
616        return Ok(HttpAuth::None);
617    }
618
619    if method.eq_ignore_ascii_case("basic") {
620        let username = params.get("authUsername").cloned().ok_or_else(|| {
621            CamelError::InvalidUri("authUsername is required for authMethod=Basic".to_string())
622        })?;
623        let password = params.get("authPassword").cloned().ok_or_else(|| {
624            CamelError::InvalidUri("authPassword is required for authMethod=Basic".to_string())
625        })?;
626        return Ok(HttpAuth::Basic { username, password });
627    }
628
629    if method.eq_ignore_ascii_case("bearer") {
630        let token = params.get("authBearerToken").cloned().ok_or_else(|| {
631            CamelError::InvalidUri("authBearerToken is required for authMethod=Bearer".to_string())
632        })?;
633        return Ok(HttpAuth::Bearer { token });
634    }
635
636    Err(CamelError::InvalidUri(format!(
637        "invalid value for authMethod: {method} (expected None, Basic, or Bearer)"
638    )))
639}
640
641fn parse_bool_param_http(value: &str) -> Result<bool, CamelError> {
642    match value.to_ascii_lowercase().as_str() {
643        "true" | "1" | "yes" => Ok(true),
644        "false" | "0" | "no" => Ok(false),
645        _ => Err(CamelError::InvalidUri(format!(
646            "invalid boolean value: '{value}'"
647        ))),
648    }
649}
650
651impl HttpEndpointConfig {
652    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
653        let parts = parse_uri(uri)?;
654        let mut endpoint = Self::from_components(parts.clone())?;
655        if endpoint.response_timeout.is_none() {
656            endpoint.response_timeout = Some(Duration::from_millis(config.response_timeout_ms));
657        }
658        if !parts.params.contains_key("allowInternal") {
659            endpoint.allow_internal = config.allow_internal;
660        }
661        if !parts.params.contains_key("blockedHosts") {
662            endpoint.blocked_hosts = config.blocked_hosts.clone();
663        }
664        if !parts.params.contains_key("maxBodySize") {
665            endpoint.max_body_size = config.max_body_size;
666        }
667        if !parts.params.contains_key("readTimeout") {
668            endpoint.read_timeout_ms = config.read_timeout_ms;
669        }
670        if !parts.params.contains_key("maxResponseBytes") {
671            endpoint.max_response_bytes = config.max_response_bytes;
672        }
673        if !parts.params.contains_key("okStatusCodeRange")
674            && let Some(range) = &config.ok_status_code_range
675        {
676            endpoint.ok_status_code_range = parse_ok_status_code_range(range)?;
677        }
678        if !parts.params.contains_key("followRedirects") {
679            endpoint.follow_redirects = config.follow_redirects;
680        }
681        if !parts.params.contains_key("maxRedirects") {
682            endpoint.max_redirects = config.max_redirects.unwrap_or(10);
683        }
684
685        Ok(endpoint)
686    }
687}
688
689// ---------------------------------------------------------------------------
690// HttpServerConfig
691// ---------------------------------------------------------------------------
692
693/// Configuration for an HTTP server (consumer) endpoint.
694#[derive(Debug, Clone)]
695pub struct HttpServerConfig {
696    /// URI scheme ("http" or "https") parsed from the endpoint URI.
697    pub scheme: String,
698    /// Bind address, e.g. "0.0.0.0" or "127.0.0.1".
699    pub host: String,
700    /// TCP port to listen on.
701    pub port: u16,
702    /// URL path this consumer handles, e.g. "/orders".
703    pub path: String,
704    /// Maximum request body size in bytes.
705    pub max_request_body: usize,
706    /// Maximum response body size for materializing streams in bytes.
707    pub max_response_body: usize,
708    /// Maximum number of in-flight requests handled concurrently by this server.
709    /// bd rc-ns3yc: values above `tokio::sync::Semaphore::MAX_PERMITS` are
710    /// rejected at parse, `create_consumer`, start, and `spawn_entry` (the
711    /// inflight semaphore panics above that bound); `0` remains a
712    /// representable reject-everything value.
713    pub max_inflight_requests: usize,
714    /// HTTP method this consumer handles (e.g. `"GET"`). When `Some`,
715    /// the consumer registers as a method-aware REST endpoint and the
716    /// path is treated as a template (e.g. `/users/{id}` is matched
717    /// against any `/users/<value>`). When `None`, the consumer
718    /// registers in the legacy path-only `api_routes` registry.
719    /// Extracted from the `httpMethod=` URI param at config build time.
720    pub method: Option<String>,
721    /// Server-side TLS config. Populated from `tlsCert`/`tlsKey` URI params.
722    /// `None` for plain HTTP servers.
723    pub tls_config: Option<crate::config::ServerTlsConfig>,
724}
725
726impl UriConfig for HttpServerConfig {
727    /// Returns "http" as the primary scheme (also accepts "https")
728    fn scheme() -> &'static str {
729        "http"
730    }
731
732    fn from_uri(uri: &str) -> Result<Self, CamelError> {
733        let parts = parse_uri(uri)?;
734        Self::from_components(parts)
735    }
736
737    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
738        // Validate scheme - accept both http and https
739        if parts.scheme != "http" && parts.scheme != "https" {
740            return Err(CamelError::InvalidUri(format!(
741                "expected scheme 'http' or 'https', got '{}'",
742                parts.scheme
743            )));
744        }
745
746        // parts.path is everything after the scheme colon, e.g. "//0.0.0.0:8080/orders"
747        // Strip leading "//"
748        let authority_and_path = parts.path.trim_start_matches('/');
749
750        // Split on the first "/" to separate "host:port" from "/path"
751        let (authority, path_suffix) = if let Some(idx) = authority_and_path.find('/') {
752            (&authority_and_path[..idx], &authority_and_path[idx..])
753        } else {
754            (authority_and_path, "/")
755        };
756
757        let path = if path_suffix.is_empty() {
758            "/"
759        } else {
760            path_suffix
761        }
762        .to_string();
763
764        // Parse host:port from authority
765        let (host, port) = if let Some(colon) = authority.rfind(':') {
766            let port_str = &authority[colon + 1..];
767            match port_str.parse::<u16>() {
768                Ok(p) => (authority[..colon].to_string(), p),
769                Err(_) => {
770                    return Err(CamelError::InvalidUri(format!(
771                        "invalid port '{}' in authority",
772                        port_str
773                    )));
774                }
775            }
776        } else {
777            // Default port based on scheme: 443 for https, 80 for http
778            let default_port = if parts.scheme == "https" { 443 } else { 80 };
779            (authority.to_string(), default_port)
780        };
781
782        let max_request_body = parts
783            .params
784            .get("maxRequestBody")
785            .and_then(|v| v.parse::<usize>().ok())
786            .unwrap_or(2 * 1024 * 1024); // Default: 2MB
787
788        let max_response_body = parts
789            .params
790            .get("maxResponseBody")
791            .and_then(|v| v.parse::<usize>().ok())
792            .unwrap_or(10 * 1024 * 1024); // Default: 10MB
793
794        let max_inflight_requests = parts
795            .params
796            .get("maxInflightRequests")
797            .and_then(|v| v.parse::<usize>().ok())
798            .unwrap_or(1024);
799        // bd rc-ns3yc: reject values above the semaphore primitive's bound
800        // at parse time — `Semaphore::new` panics above MAX_PERMITS
801        // (mirror of grpc consumer_concurrency_limit, commit 101327e5).
802        let max_inflight_requests = max_inflight_requests_limit(max_inflight_requests)?;
803
804        // Uppercase-normalize so a hand-written `httpMethod=get` matches the
805        // uppercase method the dispatcher compares against (axum's
806        // `req.method().to_string()` yields "GET"). Without this, a
807        // lower-case `httpMethod` would never match and silently 404.
808        // Review I5.
809        let method = parts.params.get("httpMethod").map(|m| m.to_uppercase());
810
811        Ok(Self {
812            scheme: parts.scheme,
813            host,
814            port,
815            path,
816            max_request_body,
817            max_response_body,
818            max_inflight_requests,
819            method,
820            tls_config: {
821                let cert = parts.params.get("tlsCert").cloned();
822                let key = parts.params.get("tlsKey").cloned();
823                match (cert, key) {
824                    (Some(c), Some(k)) => Some(crate::config::ServerTlsConfig {
825                        cert_path: c,
826                        key_path: k,
827                    }),
828                    (None, None) => None,
829                    _ => None, // partial — enforced in create_consumer, not here
830                }
831            },
832        })
833    }
834}
835
836impl HttpServerConfig {
837    pub fn from_uri_with_defaults(uri: &str, config: &HttpConfig) -> Result<Self, CamelError> {
838        let parts = parse_uri(uri)?;
839        let mut server = Self::from_components(parts.clone())?;
840        if !parts.params.contains_key("maxRequestBody") {
841            server.max_request_body = config.max_request_body;
842        }
843        if !parts.params.contains_key("maxResponseBody") {
844            // Default max_response_body is 10MB via HttpConfig::default().max_body_size.
845            server.max_response_body = config.max_body_size;
846        }
847        Ok(server)
848    }
849}
850
851// ---------------------------------------------------------------------------
852// RequestEnvelope / HttpReply
853// ---------------------------------------------------------------------------
854
855/// Body of the HTTP response: already-materialized bytes or a lazy stream.
856///
857/// **Internal plumbing** — subject to change without notice.
858pub enum HttpReplyBody {
859    Bytes(bytes::Bytes),
860    Stream(BoxStream<'static, Result<bytes::Bytes, CamelError>>),
861}
862
863/// An inbound HTTP request sent from the Axum dispatch handler to an
864/// `HttpConsumer` receive loop.
865///
866/// **Internal plumbing** — subject to change without notice.
867pub struct RequestEnvelope {
868    pub method: String,
869    pub path: String,
870    pub query: String,
871    pub headers: http::HeaderMap,
872    pub body: StreamBody,
873    /// Path parameters extracted from a REST template match, e.g.
874    /// `id=42` for a request to `/users/42` matched against
875    /// `/users/{id}`. Empty for non-REST requests or for literal
876    /// template matches. The consumer turns these into
877    /// `CamelHttpPath_<param>` headers on the Exchange (expert guidance E2).
878    pub path_params: std::collections::HashMap<String, String>,
879    pub reply_tx: tokio::sync::oneshot::Sender<HttpReply>,
880}
881
882/// The HTTP response that `HttpConsumer` sends back to the Axum handler.
883///
884/// **Internal plumbing** — subject to change without notice.
885pub struct HttpReply {
886    pub status: u16,
887    pub headers: Vec<(String, String)>,
888    pub body: HttpReplyBody,
889}
890
891// ---------------------------------------------------------------------------
892// HttpRouteRegistry / ServerRegistry
893// ---------------------------------------------------------------------------
894
895type ServerKey = (String, u16);
896
897/// Handle to a running Axum server on one interface/port.
898struct ServerHandle {
899    registry: HttpRouteRegistry,
900    /// Actual local address of the served listening socket (differs from the
901    /// configured `host:port` when spawning from a staged/pre-bound listener).
902    bound_addr: std::net::SocketAddr,
903    max_request_body: usize,
904    max_response_body: usize,
905    max_inflight_requests: usize,
906    is_tls: bool,
907    tls_cert_path: Option<String>,
908    tls_key_path: Option<String>,
909    /// JoinHandle for the monitor_axum_task wrapper. `is_finished()` is the
910    /// dead-server eviction signal in `get_or_spawn`.
911    monitor_task: tokio::task::JoinHandle<()>,
912    /// Abort handle for the Axum server task itself. The JoinHandle is
913    /// consumed by `monitor_axum_task`; this survives on the handle so
914    /// crashed-server tests (and future ops tooling) can deterministically
915    /// kill the shared transport to exercise the death path.
916    /// Test-only today — no production reader yet (rc-szmob).
917    #[allow(dead_code)]
918    server_abort: tokio::task::AbortHandle,
919    // Retained so the reload handler (Task 7) can call reload_from_config()
920    // to hot-swap certs without restarting the server.
921    tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
922    tls_source: Option<ServerTlsSource>,
923}
924
925/// Internal registry state: live server entries plus pre-bound listeners
926/// staged for consumption by the next spawn on the same key.
927#[derive(Default)]
928struct RegistryState {
929    entries: HashMap<ServerKey, Arc<OnceCell<ServerHandle>>>,
930    staged: HashMap<ServerKey, tokio::net::TcpListener>,
931}
932
933/// Process-global registry mapping (host, port) → running Axum server handle.
934pub struct ServerRegistry {
935    inner: Mutex<RegistryState>,
936}
937
938impl ServerRegistry {
939    /// Returns the global singleton.
940    pub fn global() -> &'static Self {
941        static INSTANCE: OnceLock<ServerRegistry> = OnceLock::new();
942        INSTANCE.get_or_init(|| ServerRegistry {
943            inner: Mutex::new(RegistryState::default()),
944        })
945    }
946
947    /// Returns route registry for `port`, spawning new Axum server if
948    /// none is running on that port yet.
949    #[allow(clippy::too_many_arguments)]
950    pub async fn get_or_spawn(
951        &'static self,
952        host: &str,
953        port: u16,
954        max_request_body: usize,
955        max_response_body: usize,
956        max_inflight_requests: usize,
957        runtime: Arc<dyn RuntimeObservability>,
958        route_id: String,
959        tls_config: Option<crate::config::ServerTlsConfig>,
960    ) -> Result<HttpRouteRegistry, CamelError> {
961        self.get_or_spawn_internal(
962            host,
963            port,
964            max_request_body,
965            max_response_body,
966            max_inflight_requests,
967            runtime,
968            route_id,
969            tls_config,
970            None,
971        )
972        .await
973    }
974
975    /// Like [`ServerRegistry::get_or_spawn`], but serves `listener` instead
976    /// of binding `host:port`. The registry key is derived from the listener's
977    /// actual local address, so callers must query that port afterwards. If an
978    /// entry for the key already holds a live server, the same compatibility
979    /// checks as `get_or_spawn` apply and the entry is reused; the passed
980    /// listener is simply dropped.
981    #[allow(clippy::too_many_arguments)]
982    pub async fn get_or_spawn_with_listener(
983        &'static self,
984        listener: tokio::net::TcpListener,
985        max_request_body: usize,
986        max_response_body: usize,
987        max_inflight_requests: usize,
988        runtime: Arc<dyn RuntimeObservability>,
989        route_id: String,
990        tls_config: Option<crate::config::ServerTlsConfig>,
991    ) -> Result<HttpRouteRegistry, CamelError> {
992        let addr = listener
993            .local_addr()
994            .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
995        self.get_or_spawn_internal(
996            &addr.ip().to_string(),
997            addr.port(),
998            max_request_body,
999            max_response_body,
1000            max_inflight_requests,
1001            runtime,
1002            route_id,
1003            tls_config,
1004            Some(listener),
1005        )
1006        .await
1007    }
1008
1009    /// Stage a pre-bound listener so the next `get_or_spawn` for its
1010    /// `(ip, port)` key serves this socket instead of binding a new one.
1011    ///
1012    /// The staged listener is consumed by exactly one spawn: the exact-key
1013    /// `get_or_spawn` takes it under the registry lock, eliminating the bind
1014    /// window between a port probe and server startup (itest-bound-ports).
1015    pub async fn stage_listener(
1016        &'static self,
1017        listener: tokio::net::TcpListener,
1018    ) -> Result<(), CamelError> {
1019        let addr = listener
1020            .local_addr()
1021            .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
1022        let host = addr.ip().to_string();
1023        use std::collections::hash_map::Entry;
1024        let mut guard = self.inner.lock().map_err(|_| {
1025            CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
1026        })?;
1027        match guard.staged.entry((host.clone(), addr.port())) {
1028            Entry::Occupied(_) => Err(CamelError::EndpointCreationFailed(format!(
1029                "listener already staged for {host}:{}",
1030                addr.port()
1031            ))),
1032            Entry::Vacant(slot) => {
1033                slot.insert(listener);
1034                Ok(())
1035            }
1036        }
1037    }
1038
1039    /// Returns the bound address of the live server entry for `(host, port)`,
1040    /// if one is initialized.
1041    pub fn bound_addr(&'static self, host: &str, port: u16) -> Option<std::net::SocketAddr> {
1042        let guard = self.inner.lock().ok()?;
1043        guard
1044            .entries
1045            .get(&(host.to_string(), port))
1046            .and_then(|cell| cell.get())
1047            .map(|handle| handle.bound_addr)
1048    }
1049
1050    #[allow(clippy::too_many_arguments)]
1051    async fn get_or_spawn_internal(
1052        &'static self,
1053        host: &str,
1054        port: u16,
1055        max_request_body: usize,
1056        max_response_body: usize,
1057        max_inflight_requests: usize,
1058        runtime: Arc<dyn RuntimeObservability>,
1059        route_id: String,
1060        tls_config: Option<crate::config::ServerTlsConfig>,
1061        provided: Option<tokio::net::TcpListener>,
1062    ) -> Result<HttpRouteRegistry, CamelError> {
1063        let host_owned = host.to_string();
1064        let key = (host.to_string(), port);
1065
1066        let cell = {
1067            let mut guard = self.inner.lock().map_err(|_| {
1068                CamelError::EndpointCreationFailed("ServerRegistry lock poisoned".into())
1069            })?;
1070            // Evict dead server so a fresh one can spawn (matches gRPC D-L2 pattern).
1071            // The monitor task awaits the server task, so monitor_task.is_finished()
1072            // is a reliable proxy for the server being gone (either crashed or aborted).
1073            if let Some(existing) = guard.entries.get(&key)
1074                && let Some(handle) = existing.get()
1075                && handle.monitor_task.is_finished()
1076            {
1077                // Deregister TLS reload handler so a respawned HTTPS server
1078                // doesn't reload stale cert config from the crashed handler.
1079                if handle.is_tls {
1080                    let scheme = if handle.is_tls { "https" } else { "http" };
1081                    camel_component_api::tls_source::TlsReloadRegistry::global()
1082                        .unregister(scheme, host, port);
1083                }
1084                guard.entries.remove(&key);
1085            }
1086            guard
1087                .entries
1088                .entry(key)
1089                .or_insert_with(|| Arc::new(OnceCell::new()))
1090                .clone()
1091        };
1092
1093        if let Some(existing) = cell.get()
1094            && existing.max_request_body != max_request_body
1095        {
1096            return Err(CamelError::EndpointCreationFailed(format!(
1097                "incompatible maxRequestBody for shared server (host={host}, port={port}): {} vs {}",
1098                existing.max_request_body, max_request_body
1099            )));
1100        }
1101
1102        if let Some(existing) = cell.get()
1103            && existing.max_response_body != max_response_body
1104        {
1105            return Err(CamelError::EndpointCreationFailed(format!(
1106                "incompatible maxResponseBody for shared server (host={host}, port={port}): {} vs {}",
1107                existing.max_response_body, max_response_body
1108            )));
1109        }
1110
1111        if let Some(existing) = cell.get()
1112            && existing.max_inflight_requests != max_inflight_requests
1113        {
1114            return Err(CamelError::EndpointCreationFailed(format!(
1115                "incompatible maxInflightRequests for shared server (host={host}, port={port}): {} vs {}",
1116                existing.max_inflight_requests, max_inflight_requests
1117            )));
1118        }
1119
1120        // TLS mode mismatch: plain vs TLS
1121        if let Some(existing) = cell.get()
1122            && existing.is_tls != tls_config.is_some()
1123        {
1124            return Err(CamelError::EndpointCreationFailed(format!(
1125                "incompatible TLS mode for shared server (host={host}, port={port}): existing is_tls={}, new has_tls={}",
1126                existing.is_tls,
1127                tls_config.is_some()
1128            )));
1129        }
1130
1131        // TLS cert/key mismatch: different cert on same TLS port
1132        if let (Some(existing), Some(new_tls)) = (cell.get(), &tls_config)
1133            && (existing.tls_cert_path.as_deref() != Some(&new_tls.cert_path)
1134                || existing.tls_key_path.as_deref() != Some(&new_tls.key_path))
1135        {
1136            return Err(CamelError::EndpointCreationFailed(format!(
1137                "incompatible TLS cert/key for shared server (host={host}, port={port}): routes on the same TLS port must use the same cert and key"
1138            )));
1139        }
1140
1141        let handle = cell
1142            .get_or_try_init(|| {
1143                let rt = Arc::clone(&runtime);
1144                let rid = route_id.clone();
1145                let key = (host_owned.clone(), port);
1146                async move {
1147                    // Resolve the listener source inside the init body so
1148                    // exactly one caller — the init winner — consumes a
1149                    // staged listener. Resolving it before the cell init let
1150                    // a racing caller strand the staged socket in the
1151                    // loser's hands: the winner then bound the same port and
1152                    // failed with EADDRINUSE. The sync registry lock here is
1153                    // never held across an await. Occupied cells never run
1154                    // this body, so they never touch the staged map.
1155                    let source = match provided {
1156                        Some(listener) => ListenerSource::Staged(listener),
1157                        None => {
1158                            let mut guard = self.inner.lock().map_err(|_| {
1159                                CamelError::EndpointCreationFailed(
1160                                    "ServerRegistry lock poisoned".into(),
1161                                )
1162                            })?;
1163                            match guard.staged.remove(&key) {
1164                                Some(listener) => ListenerSource::Staged(listener),
1165                                // Conflict check before any entry is
1166                                // initialized so the error leaves the staged
1167                                // slot untouched.
1168                                None => {
1169                                    if let Some((staged_host, _)) = guard
1170                                        .staged
1171                                        .keys()
1172                                        .find(|(_, staged_port)| *staged_port == port)
1173                                    {
1174                                        let staged_host = staged_host.clone();
1175                                        return Err(CamelError::EndpointCreationFailed(
1176                                            format!(
1177                                                "staged listener conflict on port {port}: staged under host {staged_host}, requested {host_owned}"
1178                                            ),
1179                                        ));
1180                                    }
1181                                    ListenerSource::Bind
1182                                }
1183                            }
1184                        }
1185                    };
1186                    spawn_entry(
1187                        key,
1188                        source,
1189                        max_request_body,
1190                        max_response_body,
1191                        max_inflight_requests,
1192                        rt,
1193                        rid,
1194                        tls_config,
1195                    )
1196                    .await
1197                    .and_then(|handle| {
1198                        // spawn_entry returns a freshly created Arc (refcount
1199                        // 1), so unwrapping it back into the owned handle for
1200                        // the cell always succeeds here.
1201                        Arc::try_unwrap(handle).map_err(|_| {
1202                            CamelError::EndpointCreationFailed(
1203                                "spawned server handle has dangling clones".into(),
1204                            )
1205                        })
1206                    })
1207                }
1208            })
1209            .await?;
1210
1211        Ok(handle.registry.clone())
1212    }
1213
1214    /// Unregister one consumer from a server. HTTP servers are process-lifetime:
1215    /// the server stays in the registry for potential restart. Path
1216    /// deregistration happens separately in the consumer's cleanup.
1217    pub async fn unregister(&self, host: &str, port: u16) {
1218        debug!(
1219            host = camel_api::redact::redact_host(host),
1220            port = port,
1221            "consumer unregistered from HTTP server"
1222        );
1223    }
1224
1225    /// Reset the global registry — **test-only**.
1226    ///
1227    /// Clears all registered server handles so that tests can start from a clean
1228    /// state. This is intentionally `#[cfg(test)]` because the registry is a
1229    /// process-global singleton in production and resetting it would break
1230    /// running servers.
1231    #[cfg(test)]
1232    pub fn reset() {
1233        let instance = Self::global();
1234        let mut guard = instance
1235            .inner
1236            .lock()
1237            .expect("ServerRegistry lock poisoned during test reset");
1238        guard.entries.clear();
1239        guard.staged.clear();
1240    }
1241}
1242
1243/// Where a spawned server's listening socket comes from: a fresh bind on
1244/// `key`, or a listener pre-bound (staged or passed) by the caller.
1245enum ListenerSource {
1246    Bind,
1247    Staged(tokio::net::TcpListener),
1248}
1249
1250/// Create the server handle for a vacant registry entry: serve `key` via a
1251/// freshly bound or caller-provided listener. This is the OnceCell init body
1252/// of `get_or_spawn`, extracted so the legacy and staged entry points share
1253/// one spawn path.
1254#[allow(clippy::too_many_arguments)]
1255async fn spawn_entry(
1256    key: ServerKey,
1257    source: ListenerSource,
1258    max_request_body: usize,
1259    max_response_body: usize,
1260    max_inflight_requests: usize,
1261    runtime: Arc<dyn RuntimeObservability>,
1262    route_id: String,
1263    tls_config: Option<crate::config::ServerTlsConfig>,
1264) -> Result<Arc<ServerHandle>, CamelError> {
1265    // bd rc-ns3yc: defense-in-depth — validate before any listener side
1266    // effect (fresh bind or staged-listener consumption), before the
1267    // registry/CancellationToken work, and immediately guarding the
1268    // `tokio::sync::Semaphore::new` construction below (panics above
1269    // MAX_PERMITS).
1270    max_inflight_requests_limit(max_inflight_requests)?;
1271    let rt = Arc::clone(&runtime);
1272    let rid = route_id.clone();
1273    let (host_owned, port) = key;
1274    let listener = match source {
1275        ListenerSource::Bind => {
1276            let addr = format!("{host_owned}:{port}");
1277            tokio::net::TcpListener::bind(&addr).await.map_err(|e| {
1278                CamelError::EndpointCreationFailed(format!("Failed to bind {addr}: {e}"))
1279            })?
1280        }
1281        ListenerSource::Staged(listener) => listener,
1282    };
1283    let bound_addr = listener
1284        .local_addr()
1285        .map_err(|e| CamelError::EndpointCreationFailed(format!("listener local_addr: {e}")))?;
1286    let server_exited = tokio_util::sync::CancellationToken::new();
1287    let registry = HttpRouteRegistry::new_with_server_exited(server_exited.clone());
1288    let inflight = Arc::new(tokio::sync::Semaphore::new(max_inflight_requests));
1289    // Constructed once in the TLS branch so they can be retained
1290    // on ServerHandle for the reload handler (Task 7).
1291    let tls_rustls_cfg: Option<axum_server::tls_rustls::RustlsConfig>;
1292    let tls_source: Option<ServerTlsSource>;
1293    let server_task = if let Some(ref tls) = tls_config {
1294        let rustls_config = load_tls_config(&tls.cert_path, &tls.key_path)?;
1295        let source = ServerTlsSource {
1296            cert_path: std::path::PathBuf::from(&tls.cert_path),
1297            key_path: std::path::PathBuf::from(&tls.key_path),
1298            client_ca_path: None,
1299        };
1300        // Build the RustlsConfig once — clone() is cheap (Arc
1301        // internally) and shares the ArcSwap the reload handler
1302        // will mutate via reload_from_config().
1303        let rustls_cfg =
1304            axum_server::tls_rustls::RustlsConfig::from_config(std::sync::Arc::new(rustls_config));
1305        tls_rustls_cfg = Some(rustls_cfg.clone());
1306        tls_source = Some(source);
1307        // Convert tokio listener to std for axum-server
1308        let std_listener = listener.into_std().map_err(|e| {
1309            CamelError::EndpointCreationFailed(format!("TLS listener conversion: {e}"))
1310        })?;
1311        tokio::spawn(run_axum_server_tls(
1312            std_listener,
1313            rustls_cfg,
1314            registry.clone(),
1315            max_request_body,
1316            max_response_body,
1317            Arc::clone(&inflight),
1318            Arc::clone(&rt),
1319            rid.clone(),
1320        ))
1321    } else {
1322        tls_rustls_cfg = None;
1323        tls_source = None;
1324        tokio::spawn(run_axum_server(
1325            listener,
1326            registry.clone(),
1327            max_request_body,
1328            max_response_body,
1329            Arc::clone(&inflight),
1330            Arc::clone(&rt),
1331            rid.clone(),
1332        ))
1333    };
1334    let addr_for_monitor = format!("{host_owned}:{port}");
1335    let server_abort = server_task.abort_handle();
1336    let monitor_task = tokio::spawn(monitor_axum_task(
1337        server_task,
1338        addr_for_monitor,
1339        Arc::clone(&rt),
1340        rid,
1341        server_exited,
1342    ));
1343    let handle = ServerHandle {
1344        registry,
1345        bound_addr,
1346        max_request_body,
1347        max_response_body,
1348        max_inflight_requests,
1349        is_tls: tls_config.is_some(),
1350        tls_cert_path: tls_config.as_ref().map(|t| t.cert_path.clone()),
1351        tls_key_path: tls_config.as_ref().map(|t| t.key_path.clone()),
1352        monitor_task,
1353        server_abort,
1354        tls_config: tls_rustls_cfg,
1355        tls_source,
1356    };
1357    // Register reload handler (exactly-once: inside OnceCell init closure).
1358    // Note: HTTP servers are process-lifetime (no release/eviction path),
1359    // so handlers are never unregistered. If eviction is added later,
1360    // add TlsReloadRegistry::global().unregister() there.
1361    if let (Some(tls_cfg), Some(source)) = (handle.tls_config.as_ref(), handle.tls_source.as_ref())
1362    {
1363        let handler = Arc::new(crate::tls_reload::HttpReloadHandler::new(
1364            tls_cfg.clone(),
1365            source.clone(),
1366            host_owned.clone(),
1367            port,
1368        ));
1369        camel_component_api::tls_source::TlsReloadRegistry::global().register(handler);
1370    }
1371    Ok(Arc::new(handle))
1372}
1373
1374// ---------------------------------------------------------------------------
1375// Axum server
1376// ---------------------------------------------------------------------------
1377
1378use axum::{
1379    Router,
1380    body::Body as AxumBody,
1381    extract::{Request, State},
1382    http::{Response, StatusCode},
1383    response::IntoResponse,
1384};
1385
1386#[derive(Clone)]
1387pub(crate) struct AppState {
1388    registry: HttpRouteRegistry,
1389    max_request_body: usize,
1390    max_response_body: usize,
1391    inflight: Arc<tokio::sync::Semaphore>,
1392}
1393
1394/// Hard wall-clock limit for one inbound request on the consumer side
1395/// (audit 2026-08-31, F2-1). A slow-drip client otherwise holds an
1396/// `inflight` semaphore permit (and its connection) indefinitely, starving
1397/// the consumer into 503s. 30s matches the documented component default
1398/// timeouts. Applies to the whole dispatch; streaming bodies are additionally
1399/// protected by the byte cap in `dispatch_handler`.
1400const CONSUMER_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
1401
1402async fn run_axum_server(
1403    listener: tokio::net::TcpListener,
1404    registry: HttpRouteRegistry,
1405    max_request_body: usize,
1406    max_response_body: usize,
1407    inflight: Arc<tokio::sync::Semaphore>,
1408    runtime: Arc<dyn RuntimeObservability>,
1409    route_id: String,
1410) {
1411    let state = AppState {
1412        registry,
1413        max_request_body,
1414        max_response_body,
1415        inflight,
1416    };
1417    let app = Router::new()
1418        .fallback(dispatch_handler)
1419        .with_state(state)
1420        .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1421            StatusCode::REQUEST_TIMEOUT,
1422            CONSUMER_REQUEST_TIMEOUT,
1423        ));
1424
1425    axum::serve(listener, app).await.unwrap_or_else(|e| {
1426        runtime
1427            .metrics()
1428            .increment_errors(&route_id, "e:http:accept");
1429        // log-policy: outside-contract
1430        tracing::error!(error = %e, "Axum server error");
1431    });
1432}
1433
1434#[allow(clippy::too_many_arguments)]
1435async fn run_axum_server_tls(
1436    listener: std::net::TcpListener,
1437    tls_cfg: axum_server::tls_rustls::RustlsConfig,
1438    registry: HttpRouteRegistry,
1439    max_request_body: usize,
1440    max_response_body: usize,
1441    inflight: Arc<tokio::sync::Semaphore>,
1442    runtime: Arc<dyn RuntimeObservability>,
1443    route_id: String,
1444) {
1445    let state = AppState {
1446        registry,
1447        max_request_body,
1448        max_response_body,
1449        inflight,
1450    };
1451    let app = Router::new()
1452        .fallback(dispatch_handler)
1453        .with_state(state)
1454        .layer(tower_http::timeout::TimeoutLayer::with_status_code(
1455            StatusCode::REQUEST_TIMEOUT,
1456            CONSUMER_REQUEST_TIMEOUT,
1457        ));
1458
1459    // RustlsConfig is now constructed once in get_or_spawn and retained on
1460    // ServerHandle so the reload handler can call reload_from_config() on it.
1461
1462    // axum-server 0.8: from_tcp_rustls is fallible (TLS acceptor setup).
1463    let server = match axum_server::from_tcp_rustls(listener, tls_cfg) {
1464        Ok(server) => server,
1465        Err(e) => {
1466            runtime
1467                .metrics()
1468                .increment_errors(&route_id, "e:http:accept-tls");
1469            // log-policy: outside-contract
1470            tracing::error!(error = %e, "Axum TLS server setup error");
1471            return;
1472        }
1473    };
1474
1475    server
1476        .serve(app.into_make_service())
1477        .await
1478        .unwrap_or_else(|e| {
1479            runtime
1480                .metrics()
1481                .increment_errors(&route_id, "e:http:accept-tls");
1482            // log-policy: outside-contract
1483            tracing::error!(error = %e, "Axum TLS server error");
1484        });
1485}
1486
1487/// Monitors the shared Axum server task of one (host, port).
1488///
1489/// On unexpected exit (panic or abort) it records the structured error
1490/// event and cancels the server's `server_exited` token. Every
1491/// `HttpConsumer` hosted on that server observes the cancellation in its
1492/// `start()` loop and returns `Err`, which camel-core's consumer watcher
1493/// turns into a per-route `CrashNotification` → `FailRoute` → supervision
1494/// backoff restart (ADR-0007). A clean exit (`Ok(())` — process shutdown)
1495/// cancels nothing: route stops own their termination.
1496async fn monitor_axum_task(
1497    handle: tokio::task::JoinHandle<()>,
1498    addr: String,
1499    runtime: Arc<dyn RuntimeObservability>,
1500    route_id: String,
1501    server_exited: tokio_util::sync::CancellationToken,
1502) {
1503    match handle.await {
1504        Ok(()) => {
1505            // Clean exit (process shutdown or normal stop)
1506        }
1507        Err(join_err) => {
1508            runtime
1509                .metrics()
1510                .increment_errors(&route_id, "e:http:server-task-exited");
1511            // log-policy: outside-contract
1512            tracing::error!(
1513                addr = %addr,
1514                error = %join_err,
1515                "Axum server task exited unexpectedly — all routes on this port are now dead"
1516            );
1517            // Fail every hosted route's consumer: each `start()` returns Err
1518            // and camel-core emits one CrashNotification per route (ADR-0007
1519            // parity with per-route transport death).
1520            server_exited.cancel();
1521        }
1522    }
1523}
1524
1525/// Load a rustls ServerConfig from PEM cert/key files.
1526/// Adapted from camel-ws lib.rs load_tls_config.
1527fn load_tls_config(
1528    cert_path: &str,
1529    key_path: &str,
1530) -> Result<tokio_rustls::rustls::ServerConfig, CamelError> {
1531    use std::fs::File;
1532    use std::io::BufReader;
1533
1534    let cert_file = File::open(cert_path)
1535        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert file error: {e}")))?;
1536    let key_file = File::open(key_path)
1537        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key file error: {e}")))?;
1538
1539    let certs: Vec<_> = rustls_pemfile::certs(&mut BufReader::new(cert_file))
1540        .collect::<Result<Vec<_>, _>>()
1541        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS cert parse error: {e}")))?;
1542
1543    let key = rustls_pemfile::private_key(&mut BufReader::new(key_file))
1544        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS key parse error: {e}")))?
1545        .ok_or_else(|| CamelError::EndpointCreationFailed("TLS: no private key found".into()))?;
1546
1547    tokio_rustls::rustls::ServerConfig::builder()
1548        .with_no_client_auth()
1549        .with_single_cert(certs, key)
1550        .map_err(|e| CamelError::EndpointCreationFailed(format!("TLS config error: {e}")))
1551}
1552
1553async fn dispatch_handler(State(state): State<AppState>, req: Request) -> impl IntoResponse {
1554    let path = req.uri().path().to_owned();
1555    let method = req.method().to_string();
1556
1557    // Dispatch precedence (spec §7.2 / ADR-0009):
1558    //   1. Exact API path match (legacy `http:` routes without httpMethod)
1559    //   2. Templated API path match (REST, method-aware, by specificity)
1560    //   3. Static mount longest-prefix
1561    //   4. SPA fallback
1562    //
1563    // Legacy exact runs first: it is a cheap HashMap get, and the two
1564    // registries are mutually exclusive per route — a legacy route carries
1565    // no `httpMethod` and lives only in `api_routes`, while a REST-lowered
1566    // route carries `httpMethod` and lives only in `rest_endpoints`. So an
1567    // exact hit can never shadow a REST route that should have matched,
1568    // and running exact-first honours the documented precedence (the prior
1569    // REST-first order let a templated `GET /api/{resource}` steal a
1570    // request meant for an exact `GET /api/users`). Intra-REST method
1571    // disambiguation is handled inside `match_endpoint`, not by this
1572    // ordering. Review C2.
1573    let api_sender = {
1574        let inner = state.registry.inner.read().await;
1575        inner.api_routes.get(&path).cloned()
1576    }; // lock released BEFORE any IO
1577
1578    let (rest_sender, path_params) = if api_sender.is_some() {
1579        // Exact legacy match won — skip the templated scan entirely.
1580        (None, Default::default())
1581    } else {
1582        let inner = state.registry.inner.read().await;
1583        match rest_match::match_endpoint(&method, &path, &inner.rest_endpoints) {
1584            rest_match::MatchOutcome::Found(m) => (Some(m.payload), m.path_params),
1585            rest_match::MatchOutcome::Ambiguous => {
1586                // Ambiguous registration should have been rejected at
1587                // lowering time (rest.rs). Reaching here means two
1588                // equal-specificity templates matched one request —
1589                // surface a loud error rather than a silent 404. Review C3.
1590                // log-policy: handler-owned
1591                tracing::warn!(
1592                    method = %method,
1593                    path = %path,
1594                    "ambiguous REST template match — returning 500"
1595                );
1596                return Response::builder()
1597                    .status(StatusCode::INTERNAL_SERVER_ERROR)
1598                    .body(AxumBody::from("Internal Server Error"))
1599                    .expect("infallible"); // allow-unwrap
1600            }
1601            rest_match::MatchOutcome::NotFound => (None, Default::default()),
1602        }
1603    }; // lock released BEFORE any IO
1604
1605    let sender = api_sender.or(rest_sender);
1606
1607    if let Some(sender) = sender {
1608        let query = req.uri().query().unwrap_or("").to_string();
1609        let headers = req.headers().clone();
1610
1611        // Check Content-Length against limit BEFORE opening the stream
1612        let content_length: Option<u64> = headers
1613            .get(http::header::CONTENT_LENGTH)
1614            .and_then(|v| v.to_str().ok())
1615            .and_then(|s| s.parse().ok());
1616
1617        if let Some(len) = content_length
1618            && len > state.max_request_body as u64
1619        {
1620            return Response::builder()
1621                .status(StatusCode::PAYLOAD_TOO_LARGE)
1622                .body(AxumBody::from("Request body exceeds configured limit"))
1623                .expect("infallible"); // allow-unwrap
1624        }
1625
1626        let _permit = match Arc::clone(&state.inflight).try_acquire_owned() {
1627            Ok(permit) => permit,
1628            Err(_) => {
1629                return Response::builder()
1630                    .status(StatusCode::SERVICE_UNAVAILABLE)
1631                    .body(AxumBody::from("Service Unavailable"))
1632                    .expect("infallible"); // allow-unwrap
1633            }
1634        };
1635
1636        // Build StreamBody from Axum body WITHOUT materializing.
1637        // SECURITY (audit 2026-08-31, F2-1): the Content-Length pre-check above
1638        // cannot see chunked/no-length requests. Wrap the stream with a hard
1639        // byte cap so ANY downstream consumption fails closed once
1640        // max_request_body is exceeded — the cap travels with the body.
1641        let content_type = headers
1642            .get(http::header::CONTENT_TYPE)
1643            .and_then(|v| v.to_str().ok())
1644            .map(|s| s.to_string());
1645
1646        let data_stream: BodyDataStream = req.into_body().into_data_stream();
1647        let max_body = state.max_request_body;
1648        let mut seen: u64 = 0;
1649        let capped_stream =
1650            data_stream
1651                .map_err(|e| CamelError::Io(e.to_string()))
1652                .map(move |chunk| match chunk {
1653                    Ok(bytes) => {
1654                        seen = seen.saturating_add(bytes.len() as u64);
1655                        if seen > max_body as u64 {
1656                            Err(CamelError::ProcessorError(format!(
1657                                "Request body exceeds configured limit of {max_body} bytes"
1658                            )))
1659                        } else {
1660                            Ok(bytes)
1661                        }
1662                    }
1663                    Err(e) => Err(e),
1664                });
1665        let boxed: BoxStream<'static, Result<bytes::Bytes, CamelError>> = Box::pin(capped_stream);
1666
1667        let stream_body = StreamBody {
1668            stream: Arc::new(tokio::sync::Mutex::new(Some(boxed))),
1669            metadata: StreamMetadata {
1670                size_hint: content_length,
1671                content_type,
1672                origin: None,
1673            },
1674        };
1675
1676        let (reply_tx, reply_rx) = tokio::sync::oneshot::channel::<HttpReply>();
1677        let envelope = RequestEnvelope {
1678            method,
1679            path,
1680            query,
1681            headers,
1682            body: stream_body,
1683            path_params,
1684            reply_tx,
1685        };
1686
1687        if sender.send(envelope).await.is_err() {
1688            return Response::builder()
1689                .status(StatusCode::SERVICE_UNAVAILABLE)
1690                .body(AxumBody::from("Consumer unavailable"))
1691                .expect("infallible"); // allow-unwrap
1692        }
1693
1694        match reply_rx.await {
1695            Ok(reply) => {
1696                let reply = match reply.body {
1697                    HttpReplyBody::Bytes(b)
1698                        if exceeds_max_response_body(b.len(), state.max_response_body) =>
1699                    {
1700                        HttpReply {
1701                            status: 500,
1702                            headers: vec![],
1703                            body: HttpReplyBody::Bytes(bytes::Bytes::from(
1704                                "Response body exceeds configured limit",
1705                            )),
1706                        }
1707                    }
1708                    _ => reply,
1709                };
1710
1711                let status =
1712                    StatusCode::from_u16(reply.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
1713                let mut builder = Response::builder().status(status);
1714                for (k, v) in &reply.headers {
1715                    builder = builder.header(k.as_str(), v.as_str());
1716                }
1717                match reply.body {
1718                    HttpReplyBody::Bytes(b) => {
1719                        builder.body(AxumBody::from(b)).unwrap_or_else(|_| {
1720                            Response::builder()
1721                                .status(StatusCode::INTERNAL_SERVER_ERROR)
1722                                .body(AxumBody::from("Invalid response headers from consumer"))
1723                                .expect("infallible") // allow-unwrap
1724                        })
1725                    }
1726                    HttpReplyBody::Stream(stream) => builder
1727                        .body(AxumBody::from_stream(stream))
1728                        .unwrap_or_else(|_| {
1729                            Response::builder()
1730                                .status(StatusCode::INTERNAL_SERVER_ERROR)
1731                                .body(AxumBody::from("Invalid response headers from consumer"))
1732                                .expect("infallible") // allow-unwrap
1733                        }),
1734                }
1735            }
1736            Err(_) => Response::builder()
1737                .status(StatusCode::INTERNAL_SERVER_ERROR)
1738                .body(AxumBody::from("Pipeline error"))
1739                .expect("infallible"), // allow-unwrap
1740        }
1741    } else {
1742        // No API route matched — try static mounts
1743        static_dispatch::dispatch_static(&state, req, &path).await
1744    }
1745}
1746
1747fn exceeds_max_response_body(len: usize, max: usize) -> bool {
1748    len > max
1749}
1750
1751fn title_case_header(name: &str) -> String {
1752    name.split('-')
1753        .map(|part| {
1754            let mut chars = part.chars();
1755            match chars.next() {
1756                None => String::new(),
1757                Some(first) => first.to_uppercase().chain(chars.as_str().chars()).collect(),
1758            }
1759        })
1760        .collect::<Vec<_>>()
1761        .join("-")
1762}
1763
1764// ---------------------------------------------------------------------------
1765// HttpConsumer
1766// ---------------------------------------------------------------------------
1767
1768/// Kernel authentication state captured from a route's [`SecurityContext`]
1769/// (`unify-transport-auth`, Task 2.9).
1770///
1771/// Same construction-order lifecycle as gRPC's `GrpcKernelAuth` (Task 2.1):
1772/// the compiled plan and the provider registry arrive via
1773/// `Consumer::set_security_context` before `start()` accepts requests. A
1774/// context lacking either piece keeps `kernel = None` — a plan without
1775/// providers can never mint a principal (fail-closed, never a silently
1776/// unauthenticated route: the controller's strict-mode dispatch check then
1777/// denies carrier-less Exchanges on non-Public plans).
1778pub(crate) struct HttpKernelAuth {
1779    pub(crate) plan: camel_api::security_policy::RouteSecurityPlan,
1780    pub(crate) providers: Arc<camel_auth::ProviderRegistry>,
1781}
1782
1783impl HttpKernelAuth {
1784    /// Capture the kernel state from a route's security context.
1785    ///
1786    /// `None` unless both the compiled plan and the provider registry are
1787    /// present.
1788    pub(crate) fn from_security_context(
1789        ctx: &camel_component_api::SecurityContext,
1790    ) -> Option<Self> {
1791        Some(Self {
1792            plan: ctx.plan.clone()?,
1793            providers: ctx.providers.clone()?,
1794        })
1795    }
1796}
1797
1798/// Capacity for the per-route RequestEnvelope channel.
1799///
1800/// Each in-flight request holds exactly one `maxInflightRequests` semaphore
1801/// permit from before `send()` until its reply, so at most N envelopes can be
1802/// outstanding at any time. A buffer of N therefore can never fill before the
1803/// semaphore exhausts: dispatcher `send()` calls never park on a full buffer
1804/// and the semaphore stays the single, URI-configurable backpressure point.
1805/// A hardcoded smaller buffer would act as a second, hidden inflight cap
1806/// (rc-3y6j: 64 vs default 1024 permits).
1807///
1808/// `max(1)`: `maxInflightRequests=0` is a representable "reject everything"
1809/// configuration, but `tokio::sync::mpsc::channel(0)` panics — keep consumer
1810/// start panic-free (the empty semaphore still 503s every request).
1811fn envelope_channel_capacity(max_inflight_requests: usize) -> usize {
1812    max_inflight_requests.max(1)
1813}
1814
1815/// Upper bound check for `maxInflightRequests`.
1816///
1817/// bd rc-ns3yc: `tokio::sync::Semaphore::new` panics for permit counts above
1818/// [`tokio::sync::Semaphore::MAX_PERMITS`], so any representable `usize`
1819/// above that bound must be rejected fail-closed with a typed configuration
1820/// error naming the parameter, the configured value, and the limit — before
1821/// the semaphore primitive is ever constructed. Enforced at URI parse,
1822/// `create_consumer`, consumer start, and `spawn_entry` (defense-in-depth);
1823/// mirror of the grpc `consumer_concurrency_limit` fix (commit 101327e5).
1824///
1825/// `0` is deliberately NOT normalized here (rc-3y6j): it remains a
1826/// representable reject-everything value (`envelope_channel_capacity`
1827/// separately guards the one primitive — the envelope channel — that
1828/// cannot take 0).
1829pub(crate) fn max_inflight_requests_limit(configured: usize) -> Result<usize, CamelError> {
1830    if configured > tokio::sync::Semaphore::MAX_PERMITS {
1831        return Err(CamelError::Config(format!(
1832            "maxInflightRequests {configured} exceeds the supported upper bound {} (tokio::sync::Semaphore::MAX_PERMITS)",
1833            tokio::sync::Semaphore::MAX_PERMITS
1834        )));
1835    }
1836    Ok(configured)
1837}
1838
1839pub struct HttpConsumer {
1840    config: HttpServerConfig,
1841    /// Runtime observability handle for ADR-0012 metrics and health calls.
1842    runtime: Arc<dyn RuntimeObservability>,
1843    /// Kernel authentication state (plan + providers), set via
1844    /// `set_security_context` before `start()` (Task 2.9). `None` for routes
1845    /// without route-level security (Public under the per-bind gate).
1846    kernel: Option<Arc<HttpKernelAuth>>,
1847}
1848
1849impl HttpConsumer {
1850    pub fn new(config: HttpServerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1851        Self {
1852            config,
1853            runtime,
1854            kernel: None,
1855        }
1856    }
1857}
1858
1859#[async_trait::async_trait]
1860impl Consumer for HttpConsumer {
1861    async fn start(&mut self, ctx: camel_component_api::ConsumerContext) -> Result<(), CamelError> {
1862        use camel_component_api::{Body, Exchange, Message};
1863
1864        // bd rc-ns3yc: fail-closed BEFORE shared-server registry
1865        // interaction, listener binding, envelope-channel construction, or
1866        // inflight-semaphore construction — an oversized value must never
1867        // reach Semaphore::new (panic).
1868        max_inflight_requests_limit(self.config.max_inflight_requests)?;
1869
1870        let registry = ServerRegistry::global()
1871            .get_or_spawn(
1872                &self.config.host,
1873                self.config.port,
1874                self.config.max_request_body,
1875                self.config.max_response_body,
1876                self.config.max_inflight_requests,
1877                self.runtime.clone(),
1878                ctx.route_id().to_string(),
1879                self.config.tls_config.clone(),
1880            )
1881            .await?;
1882
1883        // Create channel for this path and register it. Capacity matches the
1884        // dispatcher's inflight semaphore (see envelope_channel_capacity) so
1885        // the channel can never become a second backpressure point.
1886        let (env_tx, mut env_rx) = tokio::sync::mpsc::channel::<RequestEnvelope>(
1887            envelope_channel_capacity(self.config.max_inflight_requests),
1888        );
1889        // When the from-URI carries `httpMethod=...` (REST-lowered
1890        // route), register the consumer as a method-aware REST endpoint
1891        // so the dispatcher can route by (method, path template).
1892        // Otherwise fall back to the legacy path-only api_routes
1893        // registry. The two registries never overlap for the same
1894        // route: each consumer registers in exactly one of them.
1895        if let Some(method) = self.config.method.clone() {
1896            let segments = rest_match::parse_path_template(&self.config.path);
1897            registry
1898                .register_rest_endpoint(method, segments, env_tx)
1899                .await?;
1900        } else {
1901            registry
1902                .register_api_route(self.config.path.clone(), env_tx)
1903                .await?;
1904        }
1905
1906        // rc-w1u9: Signal readiness AFTER (1) TcpListener::bind succeeded
1907        // (inside get_or_spawn above), (2) the axum server task was spawned,
1908        // and (3) this route's path/REST endpoint was registered. At this
1909        // point the listener is genuinely accepting connections and any
1910        // request to this route will be dispatched (not 404'd). The runtime
1911        // uses this signal to publish RouteStarted and to release
1912        // ctx.start() so external benchmarks can emit a reliable
1913        // listener-bound marker.
1914        ctx.mark_ready();
1915
1916        // rc-nftni (drainclaim): capture the context-global counter once;
1917        // every envelope this raw-sender consumer constructs carries a
1918        // claim minted at the acceptance dequeue below.
1919        let in_flight = ctx.in_flight_counter();
1920
1921        let path = self.config.path.clone();
1922        let registry_for_cleanup = registry.clone();
1923        let server_exited = registry.server_exited.clone();
1924        let cancel_token = ctx.cancel_token();
1925        let kernel = self.kernel.clone();
1926        // Set when the loop exits because the shared server died. The
1927        // post-loop cleanup still runs, then `start()` returns Err so
1928        // camel-core's consumer watcher emits a CrashNotification for THIS
1929        // route and supervision backoff engages (ADR-0007).
1930        let mut server_died = false;
1931        loop {
1932            tokio::select! {
1933                _ = ctx.cancelled() => {
1934                    break;
1935                }
1936                _ = server_exited.cancelled() => {
1937                    // Shared transport death: this route's consumer cannot
1938                    // continue. Fail (do NOT hang in Running) — parity with
1939                    // per-route transport death, which also surfaces as a
1940                    // consumer-task error.
1941                    server_died = true;
1942                    break;
1943                }
1944                 envelope = env_rx.recv() => {
1945                    let Some(envelope) = envelope else { break; };
1946
1947                    // rc-nftni: mint at acceptance — the dequeue of the
1948                    // dispatcher's RequestEnvelope is where this consumer
1949                    // takes ownership of the wire request. The claim is held
1950                    // across the authn await, the route channel, and the
1951                    // pipeline; every early exit in the per-request task
1952                    // (cancel-503, auth denial) drops it, and a failed push
1953                    // rolls it back with the dropped envelope (RAII).
1954                    let claim =
1955                        in_flight.as_ref().map(camel_component_api::InFlightClaim::attach);
1956
1957                    // Build Exchange from HTTP request
1958                    let mut msg = Message::default();
1959
1960                    // Set standard Camel HTTP headers
1961                    msg.set_header("CamelHttpMethod",
1962                        serde_json::Value::String(envelope.method.clone()));
1963                    msg.set_header("CamelHttpPath",
1964                        serde_json::Value::String(envelope.path.clone()));
1965                    msg.set_header("CamelHttpQuery",
1966                        serde_json::Value::String(envelope.query.clone()));
1967
1968                    // Set path-parameter headers from REST template
1969                    // match. Expert guidance E2: the consumer is
1970                    // responsible for translating the dispatcher's
1971                    // matched params into `CamelHttpPath_<param>`
1972                    // headers on the Exchange, matching the convention
1973                    // used by Camel HTTP for templated routes.
1974                    for (param_name, param_value) in &envelope.path_params {
1975                        msg.set_header(
1976                            format!("CamelHttpPath_{param_name}"),
1977                            serde_json::Value::String(param_value.clone()),
1978                        );
1979                    }
1980
1981                    // Forward HTTP headers with Title-Case names (hyper lowercases them)
1982                    for (k, v) in &envelope.headers {
1983                        if let Ok(val_str) = v.to_str() {
1984                            msg.set_header(
1985                                title_case_header(k.as_str()),
1986                                serde_json::Value::String(val_str.to_string()),
1987                            );
1988                        }
1989                    }
1990
1991                    // Body: always arrives as Body::Stream (native streaming)
1992                    // Routes can call into_bytes() if they need to materialize
1993                    msg.body = Body::Stream(envelope.body);
1994
1995                    #[allow(unused_mut)]
1996                    let mut exchange = Exchange::new(msg);
1997
1998                    // Extract W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
1999                    #[cfg(feature = "otel")]
2000                    {
2001                        let headers: HashMap<String, String> = envelope
2002                            .headers
2003                            .iter()
2004                            .filter_map(|(k, v)| {
2005                                Some((k.as_str().to_lowercase(), v.to_str().ok()?.to_string()))
2006                            })
2007                            .collect();
2008                        camel_otel::extract_into_exchange(&mut exchange, &headers);
2009                    }
2010
2011                    let reply_tx = envelope.reply_tx;
2012                    let sender = ctx.sender().clone();
2013                    let path_clone = path.clone();
2014                    let cancel = cancel_token.clone();
2015                    // Task 2.9 boundary-auth inputs: the raw header map and
2016                    // the request URI (path + query) feed kernel credential
2017                    // extraction inside the per-request task.
2018                    let auth_headers = envelope.headers.clone();
2019                    let auth_uri: http::Uri = {
2020                        let full = if envelope.query.is_empty() {
2021                            envelope.path.clone()
2022                        } else {
2023                            format!("{}?{}", envelope.path, envelope.query)
2024                        };
2025                        // A malformed path cannot become a valid `Uri`; the
2026                        // empty default then carries no credentials, so
2027                        // extraction finds nothing and authn fails closed.
2028                        full.parse().unwrap_or_default()
2029                    };
2030                    let kernel = kernel.clone();
2031
2032                    // Spawn a task to handle this request concurrently
2033                    //
2034                    // NOTE: This spawns a separate tokio task for each incoming HTTP request to enable
2035                    // true concurrent request processing. This change was introduced as part of the
2036                    // pipeline concurrency feature and was NOT part of the original HttpConsumer design.
2037                    //
2038                    // Rationale:
2039                    // 1. Without spawning per-request tasks, the send_and_wait() operation would block
2040                    //    the consumer's main loop until the pipeline processing completes
2041                    // 2. This blocking would prevent multiple HTTP requests from being processed
2042                    //    concurrently, even when ConcurrencyModel::Concurrent is enabled on the pipeline
2043                    // 3. The channel would never have multiple exchanges buffered simultaneously,
2044                    //    defeating the purpose of pipeline-side concurrency
2045                    // 4. By spawning a task per request, we allow the consumer loop to continue
2046                    //    accepting new requests while existing ones are processed in the pipeline
2047                    //
2048                    // This approach effectively decouples request acceptance from pipeline processing,
2049                    // allowing the channel to buffer multiple exchanges that can be processed concurrently
2050                    // by the pipeline when ConcurrencyModel::Concurrent is active.
2051                    tokio::spawn(async move {
2052                        // Check for cancellation before sending to pipeline.
2053                        // Returns 503 (Service Unavailable) instead of letting the request
2054                        // enter a shutting-down pipeline. This is a behavioral change from
2055                        // the pre-concurrency implementation where cancellation during
2056                        // processing would result in a 500 (Internal Server Error).
2057                        // 503 is more semantically correct: the server is temporarily
2058                        // unable to handle the request due to shutdown.
2059                        if cancel.is_cancelled() {
2060                            let _ = reply_tx.send(HttpReply {
2061                                status: 503,
2062                                headers: vec![],
2063                                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
2064                            });
2065                            return;
2066                        }
2067
2068                        // ADR-0061 Task 2.9: kernel authentication at the
2069                        // request boundary. A `Public` plan passes through
2070                        // with no extraction; any other mode extracts per
2071                        // the plan's sources, authenticates through the
2072                        // kernel, and installs the typed carrier BEFORE the
2073                        // pipeline runs. A denial renders in the HTTP idiom
2074                        // (401 via `pipeline_error_to_reply`) and the route
2075                        // body never sees the request.
2076                        if let Some(kernel) = kernel.as_ref()
2077                            && !matches!(
2078                                kernel.plan.access_mode,
2079                                camel_api::security_policy::AccessMode::Public
2080                            )
2081                        {
2082                            let principal = match camel_auth::extract_token_multi(
2083                                &auth_headers,
2084                                &auth_uri,
2085                                &kernel.plan.credential_sources,
2086                            ) {
2087                                Some(extracted) => {
2088                                    match camel_auth::kernel_authenticate(
2089                                        &kernel.plan,
2090                                        &kernel.providers,
2091                                        &extracted,
2092                                    )
2093                                    .await
2094                                    {
2095                                        Ok(principal) => principal,
2096                                        Err(e) => {
2097                                            // log-policy: handler-owned
2098                                            tracing::warn!(
2099                                                path = %path_clone,
2100                                                error = %e,
2101                                                "HTTP request authentication failed"
2102                                            );
2103                                            let _ = reply_tx.send(pipeline_error_to_reply(
2104                                                e,
2105                                                &path_clone,
2106                                            ));
2107                                            return;
2108                                        }
2109                                    }
2110                                }
2111                                None => {
2112                                    // log-policy: handler-owned
2113                                    tracing::warn!(
2114                                        path = %path_clone,
2115                                        "HTTP request rejected: no credential found in any source"
2116                                    );
2117                                    let _ = reply_tx.send(pipeline_error_to_reply(
2118                                        CamelError::Unauthenticated(
2119                                            "no credential found in any source".to_string(),
2120                                        ),
2121                                        &path_clone,
2122                                    ));
2123                                    return;
2124                                }
2125                            };
2126                            camel_auth::install_carrier(&mut exchange, &principal);
2127                        }
2128
2129                        // Send through pipeline and await result
2130                        let (tx, rx) = tokio::sync::oneshot::channel();
2131                        let envelope = camel_component_api::consumer::ExchangeEnvelope {
2132                            exchange,
2133                            reply_tx: Some(tx),
2134                            // rc-nftni: the acceptance-minted claim rides the
2135                            // envelope; the pipeline drain sites take it and
2136                            // hold it across the pipeline (release at
2137                            // completion; rejection paths above already
2138                            // dropped it).
2139                            in_flight_claim: claim,
2140                        };
2141
2142                        let result = match sender.send(envelope).await {
2143                            Ok(()) => rx.await.map_err(|_| camel_component_api::CamelError::ChannelClosed),
2144                            Err(_) => Err(camel_component_api::CamelError::ChannelClosed),
2145                        }
2146                        .and_then(|r| r);
2147
2148                        let reply = match result {
2149                            Ok(out) => {
2150                                let status = out
2151                                    .input
2152                                    .header("CamelHttpResponseCode")
2153                                    .and_then(|v| {
2154                                        let raw = v.as_u64()
2155                                            .or_else(|| v.as_str().and_then(|s| s.parse().ok()))?;
2156                                        let code = raw as u16;
2157                                        (100..1000).contains(&code).then_some(code)
2158                                    })
2159                                    .unwrap_or(200);
2160
2161                                let user_content_type = out
2162                                    .input
2163                                    .header("Content-Type")
2164                                    .and_then(|v| v.as_str().map(|s| s.to_string()));
2165
2166                                let (reply_body, inferred_content_type): (HttpReplyBody, Option<String>) = match out.input.body {
2167                                    Body::Bytes(b) => (HttpReplyBody::Bytes(b), None),
2168                                    Body::Text(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("text/plain; charset=utf-8".to_string())),
2169                                    Body::Xml(s) => (HttpReplyBody::Bytes(bytes::Bytes::from(s.into_bytes())), Some("application/xml".to_string())),
2170                                    Body::Json(v) => (HttpReplyBody::Bytes(bytes::Bytes::from(
2171                                        v.to_string().into_bytes(),
2172                                    )), Some("application/json".to_string())),
2173                                    Body::Stream(s) => {
2174                                        let ct = s.metadata.content_type.clone();
2175                                        match s.stream.lock().await.take() {
2176                                            Some(stream) => (
2177                                                HttpReplyBody::Stream(stream),
2178                                                ct,
2179                                            ),
2180                                            None => {
2181                                                // log-policy: system-broken
2182                                                tracing::error!(
2183                                                    "Body::Stream already consumed before HTTP reply — returning 500"
2184                                                );
2185                                                let error_reply = HttpReply {
2186                                                    status: 500,
2187                                                    headers: vec![],
2188                                                    body: HttpReplyBody::Bytes(bytes::Bytes::new()),
2189                                                };
2190                                                if reply_tx.send(error_reply).is_err() {
2191                                                    debug!("reply_tx dropped before error reply could be sent");
2192                                                }
2193                                                return;
2194                                            }
2195                                        }
2196                                    }
2197                                    // Empty and future variants produce an empty reply body.
2198                                    _ => (HttpReplyBody::Bytes(bytes::Bytes::new()), None),
2199                                };
2200
2201                                let resp_headers = select_response_headers(
2202                                    &out.input.headers,
2203                                    user_content_type,
2204                                    inferred_content_type,
2205                                );
2206
2207                                HttpReply {
2208                                    status,
2209                                    headers: resp_headers,
2210                                    body: reply_body,
2211                                }
2212                            }
2213                            Err(e) => {
2214                                pipeline_error_to_reply(e, &path_clone)
2215                            }
2216                        };
2217
2218                        // Reply to Axum handler (ignore error if client disconnected)
2219                        let _ = reply_tx.send(reply);
2220                    });
2221                }
2222            }
2223        }
2224
2225        // Deregister this consumer. Mirror the registration choice:
2226        // REST-registered consumers remove their (method, path) endpoint
2227        // WITHOUT touching sibling verbs on the same template (review C1);
2228        // legacy consumers clean up api_routes.
2229        if let Some(method) = &self.config.method {
2230            registry_for_cleanup
2231                .unregister_rest_endpoint(method, &path)
2232                .await;
2233        } else {
2234            registry_for_cleanup.unregister_api_route(&path).await;
2235        }
2236
2237        // Leave the shared-server entry: `unregister` is a no-op today (no
2238        // refcount exists — stale D-L10 wording removed, rc-szmob review).
2239        // Dead servers are evicted lazily by `get_or_spawn_internal`, which
2240        // checks `monitor_task.is_finished()` and rebinds on the next spawn
2241        // (e.g. a supervision restart after this consumer's Err).
2242        ServerRegistry::global()
2243            .unregister(&self.config.host, self.config.port)
2244            .await;
2245
2246        if server_died {
2247            // log-policy: system-broken
2248            tracing::error!(
2249                host = %camel_api::redact::redact_host(&self.config.host),
2250                port = self.config.port,
2251                path = %path,
2252                "Shared HTTP server exited — failing consumer to engage route supervision (ADR-0007)"
2253            );
2254            // The error value is logged upstream by supervision (ADR-0076):
2255            // the host must ride the canonical masker, message structure
2256            // unchanged (bd rc-8bxeo item 3).
2257            return Err(CamelError::RouteError(format!(
2258                "shared HTTP server for {}:{} exited unexpectedly; route transport is dead",
2259                camel_api::redact::redact_host(&self.config.host),
2260                self.config.port
2261            )));
2262        }
2263
2264        Ok(())
2265    }
2266
2267    async fn stop(&mut self) -> Result<(), CamelError> {
2268        Ok(())
2269    }
2270
2271    fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
2272        camel_component_api::ConcurrencyModel::Concurrent { max: None }
2273    }
2274
2275    // rc-w1u9: HTTP consumer binds a TcpListener inside start() (via
2276    // ServerRegistry::get_or_spawn) and only THEN can it accept connections.
2277    // Opting into Explicit startup makes ctx.start() await the bind+register
2278    // completion so listeners fail fast on bind errors (previously a silent
2279    // background log) and external markers can reliably detect listener-bound
2280    // state.
2281    fn startup_mode(&self) -> camel_component_api::ConsumerStartupMode {
2282        camel_component_api::ConsumerStartupMode::Explicit
2283    }
2284
2285    // Task 2.9: capture the kernel state (compiled plan + provider registry)
2286    // wired by the route controller before start(). See `HttpKernelAuth`.
2287    fn set_security_context(&mut self, ctx: camel_component_api::SecurityContext) {
2288        self.kernel = HttpKernelAuth::from_security_context(&ctx).map(Arc::new);
2289    }
2290}
2291
2292// ---------------------------------------------------------------------------
2293// HttpComponent / HttpsComponent
2294// ---------------------------------------------------------------------------
2295
2296pub struct HttpComponent {
2297    config: HttpConfig,
2298    pinned_cache: std::sync::Arc<PinnedClientCache>,
2299    client: reqwest::Client,
2300    /// Set at construction when `tls.strict` is on and the configured
2301    /// material fails to load; surfaced as an endpoint-creation failure
2302    /// (rc-ayrwk).
2303    strict_tls_error: Option<CamelError>,
2304}
2305
2306impl HttpComponent {
2307    pub fn new() -> Self {
2308        let config = HttpConfig::default();
2309        let strict_err = strict_tls_error(&config);
2310        let (client, build_err) = client_or_emergency(&config);
2311        Self {
2312            client,
2313            config,
2314            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2315                PINNED_CLIENT_TTL,
2316                PINNED_CLIENT_MAX_ENTRIES,
2317            )),
2318            strict_tls_error: strict_err.or(build_err),
2319        }
2320    }
2321
2322    pub fn with_config(config: HttpConfig) -> Self {
2323        let strict_err = strict_tls_error(&config);
2324        let (client, build_err) = client_or_emergency(&config);
2325        Self {
2326            client,
2327            config,
2328            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2329                PINNED_CLIENT_TTL,
2330                PINNED_CLIENT_MAX_ENTRIES,
2331            )),
2332            strict_tls_error: strict_err.or(build_err),
2333        }
2334    }
2335
2336    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2337        match config {
2338            Some(cfg) => Self::with_config(cfg),
2339            None => Self::new(),
2340        }
2341    }
2342}
2343
2344impl Default for HttpComponent {
2345    fn default() -> Self {
2346        Self::new()
2347    }
2348}
2349
2350impl Component for HttpComponent {
2351    fn scheme(&self) -> &str {
2352        "http"
2353    }
2354
2355    fn metadata(&self) -> ComponentMetadata {
2356        HttpEndpointConfig::metadata()
2357    }
2358
2359    fn create_endpoint(
2360        &self,
2361        uri: &str,
2362        ctx: &dyn camel_component_api::ComponentContext,
2363    ) -> Result<Box<dyn Endpoint>, CamelError> {
2364        if let Some(err) = &self.strict_tls_error {
2365            return Err(err.clone());
2366        }
2367        self.config.validate()?;
2368        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2369        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2370        // Inert until `create_consumer` opens the gate (rc-hcica).
2371        let listener_gate = ListenerGate::default();
2372        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::gated(
2373            server_config.host.clone(),
2374            server_config.port,
2375            listener_gate.clone(),
2376        )));
2377        self.pinned_cache
2378            .wire(HttpComponentKind::Http, ctx.metrics());
2379        Ok(Box::new(HttpEndpoint {
2380            uri: uri.to_string(),
2381            config,
2382            server_config,
2383            client: self.client.clone(),
2384            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2385            http_config: self.config.clone(),
2386            listener_gate,
2387        }))
2388    }
2389}
2390
2391pub struct HttpsComponent {
2392    config: HttpConfig,
2393    pinned_cache: std::sync::Arc<PinnedClientCache>,
2394    client: reqwest::Client,
2395    /// Set at construction when `tls.strict` is on and the configured
2396    /// material fails to load; surfaced as an endpoint-creation failure
2397    /// (rc-ayrwk).
2398    strict_tls_error: Option<CamelError>,
2399}
2400
2401impl HttpsComponent {
2402    pub fn new() -> Self {
2403        let config = HttpConfig::default();
2404        let strict_err = strict_tls_error(&config);
2405        let (client, build_err) = client_or_emergency(&config);
2406        Self {
2407            client,
2408            config,
2409            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2410                PINNED_CLIENT_TTL,
2411                PINNED_CLIENT_MAX_ENTRIES,
2412            )),
2413            strict_tls_error: strict_err.or(build_err),
2414        }
2415    }
2416
2417    pub fn with_config(config: HttpConfig) -> Self {
2418        let strict_err = strict_tls_error(&config);
2419        let (client, build_err) = client_or_emergency(&config);
2420        Self {
2421            client,
2422            config,
2423            pinned_cache: std::sync::Arc::new(PinnedClientCache::new(
2424                PINNED_CLIENT_TTL,
2425                PINNED_CLIENT_MAX_ENTRIES,
2426            )),
2427            strict_tls_error: strict_err.or(build_err),
2428        }
2429    }
2430
2431    pub fn with_optional_config(config: Option<HttpConfig>) -> Self {
2432        match config {
2433            Some(cfg) => Self::with_config(cfg),
2434            None => Self::new(),
2435        }
2436    }
2437}
2438
2439impl Default for HttpsComponent {
2440    fn default() -> Self {
2441        Self::new()
2442    }
2443}
2444
2445impl Component for HttpsComponent {
2446    fn scheme(&self) -> &str {
2447        "https"
2448    }
2449
2450    fn metadata(&self) -> ComponentMetadata {
2451        // HTTPS shares the same URI option surface and capabilities as HTTP.
2452        // Only the scheme and description differ.
2453        let mut meta = HttpEndpointConfig::metadata();
2454        meta.scheme = "https".to_string();
2455        meta.description = "HTTPS client and server component (TLS over HTTP)".to_string();
2456        meta
2457    }
2458
2459    fn create_endpoint(
2460        &self,
2461        uri: &str,
2462        ctx: &dyn camel_component_api::ComponentContext,
2463    ) -> Result<Box<dyn Endpoint>, CamelError> {
2464        if let Some(err) = &self.strict_tls_error {
2465            return Err(err.clone());
2466        }
2467        self.config.validate()?;
2468        let config = HttpEndpointConfig::from_uri_with_defaults(uri, &self.config)?;
2469        let server_config = HttpServerConfig::from_uri_with_defaults(uri, &self.config)?;
2470        // Inert until `create_consumer` opens the gate (rc-hcica).
2471        let listener_gate = ListenerGate::default();
2472        ctx.register_current_route_health_check(Arc::new(HttpHealthCheck::gated(
2473            server_config.host.clone(),
2474            server_config.port,
2475            listener_gate.clone(),
2476        )));
2477        self.pinned_cache
2478            .wire(HttpComponentKind::Https, ctx.metrics());
2479        Ok(Box::new(HttpEndpoint {
2480            uri: uri.to_string(),
2481            config,
2482            server_config,
2483            client: self.client.clone(),
2484            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2485            http_config: self.config.clone(),
2486            listener_gate,
2487        }))
2488    }
2489}
2490
2491// ---------------------------------------------------------------------------
2492// HttpEndpoint
2493// ---------------------------------------------------------------------------
2494
2495struct HttpEndpoint {
2496    uri: String,
2497    config: HttpEndpointConfig,
2498    server_config: HttpServerConfig,
2499    client: reqwest::Client,
2500    pinned_cache: std::sync::Arc<PinnedClientCache>,
2501    http_config: HttpConfig,
2502    /// Opened by `create_consumer`; gates the listener health check.
2503    listener_gate: ListenerGate,
2504}
2505
2506impl Endpoint for HttpEndpoint {
2507    fn uri(&self) -> &str {
2508        &self.uri
2509    }
2510
2511    fn create_consumer(
2512        &self,
2513        rt: Arc<dyn camel_component_api::RuntimeObservability>,
2514    ) -> Result<Box<dyn Consumer>, CamelError> {
2515        // Scheme/config consistency check (spec §5) — uses parsed scheme
2516        // from HttpServerConfig, not a fragile port-443 heuristic.
2517        let scheme_is_https = self.server_config.scheme == "https";
2518        let has_tls = self.server_config.tls_config.is_some();
2519
2520        if scheme_is_https && !has_tls {
2521            return Err(CamelError::EndpointCreationFailed(
2522                "https:// consumer requires tlsCert and tlsKey parameters".to_string(),
2523            ));
2524        }
2525        if !scheme_is_https && has_tls {
2526            return Err(CamelError::EndpointCreationFailed(
2527                "http:// is incompatible with tlsCert/tlsKey — use https:// for TLS".to_string(),
2528            ));
2529        }
2530        // bd rc-ns3yc: fail-closed BEFORE any HttpConsumer is constructed —
2531        // a directly-built HttpServerConfig never passed the parse seam, and
2532        // the inflight semaphore panics above the primitive bound
2533        // (mirror of grpc consumer_concurrency_limit, commit 101327e5).
2534        max_inflight_requests_limit(self.server_config.max_inflight_requests)?;
2535        self.listener_gate.open();
2536        Ok(Box::new(HttpConsumer::new(self.server_config.clone(), rt)))
2537    }
2538
2539    fn create_producer(
2540        &self,
2541        rt: Arc<dyn camel_component_api::RuntimeObservability>,
2542        _ctx: &ProducerContext,
2543    ) -> Result<BoxProcessor, CamelError> {
2544        let producer = HttpProducer {
2545            config: Arc::new(self.config.clone()),
2546            client: self.client.clone(),
2547            pinned_cache: std::sync::Arc::clone(&self.pinned_cache),
2548            http_config: Arc::new(self.http_config.clone()),
2549            runtime: rt,
2550        };
2551        if let Some(ref provider) = self.config.token_provider {
2552            let layer = BearerTokenLayer::new(Arc::clone(provider));
2553            Ok(BoxProcessor::new(layer.layer(producer)))
2554        } else {
2555            Ok(BoxProcessor::new(producer))
2556        }
2557    }
2558}
2559
2560// ---------------------------------------------------------------------------
2561// HttpProducer
2562// ---------------------------------------------------------------------------
2563
2564#[derive(Clone)]
2565struct HttpProducer {
2566    config: Arc<HttpEndpointConfig>,
2567    client: reqwest::Client,
2568    pinned_cache: std::sync::Arc<PinnedClientCache>,
2569    http_config: Arc<HttpConfig>,
2570    /// Runtime observability handle powering the component-ops facade at
2571    /// the request boundary (`("http","request")`, dashboard-observability
2572    /// Task 4.3). The retained `e:http:accept*` labels are consumer-side
2573    /// (server accept loop) — different boundary, no collision with
2574    /// `e:http:request`.
2575    runtime: Arc<dyn RuntimeObservability>,
2576}
2577
2578impl HttpProducer {
2579    fn resolve_method(exchange: &Exchange, config: &HttpEndpointConfig) -> String {
2580        if let Some(ref method) = config.http_method {
2581            return method.to_uppercase();
2582        }
2583        if let Some(method) = exchange
2584            .input
2585            .header("CamelHttpMethod")
2586            .and_then(|v| v.as_str())
2587        {
2588            return method.to_uppercase();
2589        }
2590        if !exchange.input.body.is_empty() {
2591            return "POST".to_string();
2592        }
2593        "GET".to_string()
2594    }
2595
2596    fn resolve_url(exchange: &Exchange, config: &HttpEndpointConfig) -> Result<String, CamelError> {
2597        // bridgeEndpoint=true: exchange URL headers (CamelHttpUri,
2598        // CamelHttpPath, CamelHttpQuery) are ignored per Apache Camel
2599        // bridging semantics. The endpoint's own query still rides: the
2600        // same raw-preserving, consumed-option-filtered query as the
2601        // non-bridge path (bridgeEndpoint itself is a consumed option),
2602        // with programmatic query_params appending absent keys after the
2603        // raw base. This check MUST come before the CamelHttpUri override
2604        // so bridging wins over that header.
2605        if config.bridge_endpoint {
2606            let Some(query) = resolve_endpoint_query(config)? else {
2607                return Ok(config.base_url.clone());
2608            };
2609            // Validation only (rc-ph7z2): a malformed base still errors
2610            // through the redacted-diagnostic path below. The parsed value
2611            // is NEVER re-emitted — assembly is verbatim string
2612            // composition, authored bytes end-to-end: no WHATWG
2613            // normalization (dot-segment collapse, default-port strip,
2614            // scheme/host lowercasing), matching every other arm (Papal
2615            // Direction A).
2616            let _: url::Url = url::Url::parse(&config.base_url).map_err(|e| {
2617                CamelError::ProcessorError(format!(
2618                    "invalid base URL '{}': {e}",
2619                    redact_url_for_diagnostics(&config.base_url)
2620                ))
2621            })?;
2622            let mut url = config.base_url.clone();
2623            url.push('?');
2624            url.push_str(&query);
2625            return Ok(url);
2626        }
2627
2628        if let Some(uri) = exchange
2629            .input
2630            .header("CamelHttpUri")
2631            .and_then(|v| v.as_str())
2632        {
2633            // Host fence (allowedUriHosts): opt-in, fail-closed. Evaluated
2634            // on the raw override before any path/query assembly; a
2635            // rejection renders the URL only through the diagnostics
2636            // redaction path (ADR-0051).
2637            if let Some(fence) = &config.allowed_uri_hosts
2638                && !uri_host_allowed(uri, fence)?
2639            {
2640                return Err(CamelError::ProcessorError(format!(
2641                    "CamelHttpUri host not allowed by allowedUriHosts fence: {}",
2642                    redact_url_for_diagnostics(uri)
2643                )));
2644            }
2645            // The override replaces the base URL; its own query is the
2646            // higher-precedence source for composition (ADR-0071) — the
2647            // endpoint base query does not ride an override. Split at the
2648            // first `?` so CamelHttpPath applies to the path component
2649            // and the queries merge at pair level, never a second `?`
2650            // marker.
2651            let (base, override_query) = match uri.split_once('?') {
2652                Some((base, query)) => (base, Some(query)),
2653                None => (uri, None),
2654            };
2655            // Resolve-time span validation for the override URI's own query
2656            // (rc-m4xk1): a forbidden byte is a resolve error naming the
2657            // byte, never a verbatim ride that later surfaces as a reqwest
2658            // send error. Covers both downstream arms — the verbatim push
2659            // and merge_header_query, which validates only the header side.
2660            if let Some(query) = override_query {
2661                for (_key, span) in raw_query_pairs(query)? {
2662                    validate_raw_query_span(span)?;
2663                }
2664            }
2665            let mut url = base.to_string();
2666            if let Some(path) = exchange
2667                .input
2668                .header("CamelHttpPath")
2669                .and_then(|v| v.as_str())
2670            {
2671                if !url.ends_with('/') && !path.starts_with('/') {
2672                    url.push('/');
2673                }
2674                url.push_str(path);
2675            }
2676            if let Some(query) = exchange
2677                .input
2678                .header("CamelHttpQuery")
2679                .and_then(|v| v.as_str())
2680            {
2681                if let Some(merged) = merge_header_query(override_query, query)? {
2682                    url.push('?');
2683                    url.push_str(&merged);
2684                }
2685                return Ok(url);
2686            }
2687            if let Some(query) = override_query {
2688                url.push('?');
2689                url.push_str(query);
2690            }
2691            return Ok(url);
2692        }
2693
2694        let mut url = config.base_url.clone();
2695
2696        if let Some(path) = exchange
2697            .input
2698            .header("CamelHttpPath")
2699            .and_then(|v| v.as_str())
2700        {
2701            if !url.ends_with('/') && !path.starts_with('/') {
2702                url.push('/');
2703            }
2704            url.push_str(path);
2705        }
2706
2707        if let Some(query) = exchange
2708            .input
2709            .header("CamelHttpQuery")
2710            .and_then(|v| v.as_str())
2711        {
2712            // Compose: the endpoint query (raw-preserving,
2713            // consumed-option-filtered) comes first and wins collisions;
2714            // header pairs append verbatim for absent keys (ADR-0071).
2715            // An empty header leaves the endpoint query unchanged.
2716            if let Some(merged) =
2717                merge_header_query(resolve_endpoint_query(config)?.as_deref(), query)?
2718            {
2719                url.push('?');
2720                url.push_str(&merged);
2721            }
2722            return Ok(url);
2723        }
2724
2725        if let Some(query) = resolve_endpoint_query(config)? {
2726            url.push('?');
2727            url.push_str(&query);
2728        }
2729
2730        Ok(url)
2731    }
2732
2733    fn is_ok_status(status: u16, range: (u16, u16)) -> bool {
2734        status >= range.0 && status <= range.1
2735    }
2736}
2737
2738/// One allowlist entry of the `CamelHttpUri` host fence (`allowedUriHosts`
2739/// endpoint option). DNS hosts are stored ASCII-lowercased; IPv6 literals
2740/// in bracketed canonical form (the `url` crate's host serialization). A
2741/// `port` of `None` is a host-only entry and permits any port.
2742#[derive(Clone, Debug, PartialEq, Eq)]
2743pub struct AllowedUriHost {
2744    /// Canonical host: lowercased DNS name or bracketed IPv6 literal.
2745    pub host: String,
2746    /// `Some` pins the entry to one effective port; `None` permits any.
2747    pub port: Option<u16>,
2748}
2749
2750/// Parse the `allowedUriHosts` option value: comma-separated `host` or
2751/// `host:port` entries. Bracketed IPv6 is supported (`[::1]:8443`, bare
2752/// `[::1]` host-only). Empty segments are dropped. Segments are parsed
2753/// through the `url` crate (with an `http://` scheme injected) so DNS
2754/// names are lowercased and ports range-checked; anything it rejects is a
2755/// malformed entry. A value yielding zero valid entries is also an error.
2756/// Both failure modes fail endpoint creation (fail-closed).
2757fn parse_allowed_uri_hosts(raw: &str) -> Result<Vec<AllowedUriHost>, CamelError> {
2758    let mut entries = Vec::new();
2759    for segment in raw.split(',') {
2760        let segment = segment.trim();
2761        if segment.is_empty() {
2762            continue;
2763        }
2764        let parsed = url::Url::parse(&format!("http://{segment}"))
2765            .map_err(|_| invalid_allowed_uri_host_entry(segment))?;
2766        // A segment carrying a path or userinfo is a typo'd entry — the
2767        // spec's "any other malformed entry" clause. Silently narrowing it
2768        // to its hostname would widen or skew the fence.
2769        if parsed.path() != "/" || !parsed.username().is_empty() || parsed.password().is_some() {
2770            return Err(invalid_allowed_uri_host_entry(segment));
2771        }
2772        let Some(host) = parsed.host_str() else {
2773            return Err(invalid_allowed_uri_host_entry(segment));
2774        };
2775        entries.push(AllowedUriHost {
2776            host: host.to_string(),
2777            port: parsed.port(),
2778        });
2779    }
2780    if entries.is_empty() {
2781        return Err(CamelError::InvalidUri(
2782            "allowedUriHosts declares no valid host entries".to_string(),
2783        ));
2784    }
2785    Ok(entries)
2786}
2787
2788fn invalid_allowed_uri_host_entry(segment: &str) -> CamelError {
2789    CamelError::InvalidUri(format!("invalid allowedUriHosts entry '{segment}'"))
2790}
2791
2792/// Whether `url_str` matches the fence. Parse failure or a host-less URL
2793/// is fail-closed (`Ok(false)`). DNS hosts compare case-insensitively
2794/// (both sides are lowercased by the `url` crate); IPv6 compares in
2795/// bracketed canonical form. A host-only entry permits any port; a
2796/// `host:port` entry matches only the effective port — the explicit port
2797/// or the scheme default (443 for https, 80 for http).
2798pub(crate) fn uri_host_allowed(
2799    url_str: &str,
2800    fence: &[AllowedUriHost],
2801) -> Result<bool, CamelError> {
2802    let Ok(parsed) = url::Url::parse(url_str) else {
2803        return Ok(false);
2804    };
2805    let Some(host) = parsed.host_str() else {
2806        return Ok(false);
2807    };
2808    let effective_port = parsed.port().or(match parsed.scheme() {
2809        "https" => Some(443_u16),
2810        "http" => Some(80),
2811        _ => None,
2812    });
2813    Ok(fence.iter().any(|entry| {
2814        entry.host == host
2815            && match entry.port {
2816                None => true,
2817                Some(port) => effective_port == Some(port),
2818            }
2819    }))
2820}
2821
2822/// Serialize the outbound query for the endpoint base.
2823///
2824/// Authored raw pairs come first, byte-for-byte minus consumed option keys
2825/// (order, separators and authored escapes — including `RAW(...)` text —
2826/// preserved); then programmatic `query_params` entries whose key is absent
2827/// from the authored pairs, in declaration order with minimal RFC-3986
2828/// encoding (`%20`, never `+`). Authored keys always win — no duplication,
2829/// no override.
2830///
2831/// Returns `Ok(None)` when no query component is emitted: no pairs at all,
2832/// or a non-empty raw query whose every pair was consumed. A bare `?`
2833/// marker (`raw_query == Some("")`) always emits the query component.
2834fn resolve_endpoint_query(config: &HttpEndpointConfig) -> Result<Option<String>, CamelError> {
2835    let mut parts: Vec<String> = Vec::new();
2836    let mut authored_keys = std::collections::HashSet::new();
2837
2838    if let Some(raw) = config.raw_query.as_deref() {
2839        for (key, span) in raw_query_pairs(raw)? {
2840            authored_keys.insert(key.clone());
2841            if is_consumed_option(&key) {
2842                continue;
2843            }
2844            validate_raw_query_span(span)?;
2845            parts.push(span.to_string());
2846        }
2847    }
2848
2849    for (key, value) in &config.query_params {
2850        if !authored_keys.contains(key.as_str()) {
2851            parts.push(format!(
2852                "{}={}",
2853                encode_query_component(key),
2854                encode_query_component(value)
2855            ));
2856        }
2857    }
2858
2859    if parts.is_empty() && config.raw_query.as_deref() != Some("") {
2860        return Ok(None);
2861    }
2862    Ok(Some(parts.join("&")))
2863}
2864
2865/// Compose the outbound query when a `CamelHttpQuery` exchange header is
2866/// present (ADR-0071). `higher_precedence` — the endpoint query in the
2867/// base arm, the override URI's own query in the override arm — comes
2868/// first and wins any key collision; header pairs append verbatim for
2869/// absent keys only. An empty header leaves the higher-precedence query
2870/// unchanged (no additional `?` marker). Header spans are validated, not
2871/// re-encoded: a byte forbidden in a query component is a resolve error
2872/// naming the byte (Wave-A law).
2873fn merge_header_query(
2874    higher_precedence: Option<&str>,
2875    header_query: &str,
2876) -> Result<Option<String>, CamelError> {
2877    if header_query.is_empty() {
2878        return Ok(higher_precedence.map(str::to_string));
2879    }
2880    let mut parts: Vec<String> = Vec::new();
2881    let mut higher_keys = std::collections::HashSet::new();
2882    for (key, span) in raw_query_pairs(higher_precedence.unwrap_or(""))? {
2883        higher_keys.insert(key);
2884        parts.push(span.to_string());
2885    }
2886    for (key, span) in raw_query_pairs(header_query)? {
2887        validate_raw_query_span(span)?;
2888        if !higher_keys.contains(key.as_str()) {
2889            parts.push(span.to_string());
2890        }
2891    }
2892    if parts.is_empty() {
2893        return Ok(None);
2894    }
2895    Ok(Some(parts.join("&")))
2896}
2897
2898/// Bytes that may appear unescaped in a URI query component. RFC 3986
2899/// (`query = *( pchar / "/" / "?" )`) admits unreserved, sub-delims, `:`,
2900/// `@`, `/`, `?`, and `%` — with ONE deliberate exclusion from the RFC set:
2901/// the apostrophe (`'`, 0x27). reqwest's WHATWG URL parser re-encodes 0x27
2902/// to `%27` in the special-query percent-encode set (http/https), so an
2903/// authored apostrophe can never ride the wire verbatim; admitting it would
2904/// silently normalize authored bytes (rc-nmupb). Authors write `%27`
2905/// explicitly when they mean the byte on the wire. The WHATWG set's other
2906/// extras (`"`, `` ` ``, `<`, `>`) are already rejected here — they are not
2907/// RFC 3986 query-legal bytes, so no special exclusion is needed for them.
2908fn is_legal_query_byte(byte: u8) -> bool {
2909    matches!(byte,
2910        b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z'
2911        | b'-' | b'.' | b'_' | b'~'
2912        | b'!' | b'$' | b'&' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'='
2913        | b':' | b'@' | b'/' | b'?'
2914        | b'%')
2915}
2916
2917/// Reject an authored raw pair carrying a byte that is not legal in a query
2918/// component (e.g. literal space, `#`, non-ASCII). The serializer never
2919/// silently re-encodes operator-authored bytes: "byte-for-byte" is bounded
2920/// to wire-legal bytes, and the check fires before the resolved string
2921/// reaches any consumer (SSRF pre-check, diagnostics redaction).
2922fn validate_raw_query_span(span: &str) -> Result<(), CamelError> {
2923    for &byte in span.as_bytes() {
2924        if !is_legal_query_byte(byte) {
2925            return Err(CamelError::ProcessorError(format!(
2926                "raw query pair '{span}' contains byte 0x{byte:02X}, which is not legal in a URL query component"
2927            )));
2928        }
2929    }
2930    Ok(())
2931}
2932
2933/// Minimal RFC-3986 percent-encoding for one programmatic query component:
2934/// unreserved bytes pass through, every other byte encodes as uppercase
2935/// hex. A space encodes as `%20`, never `+`.
2936fn encode_query_component(component: &str) -> String {
2937    const HEX: &[u8; 16] = b"0123456789ABCDEF";
2938    let mut out = String::with_capacity(component.len());
2939    for &byte in component.as_bytes() {
2940        match byte {
2941            b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z' | b'-' | b'.' | b'_' | b'~' => {
2942                out.push(byte as char);
2943            }
2944            _ => {
2945                out.push('%');
2946                out.push(HEX[(byte >> 4) as usize] as char);
2947                out.push(HEX[(byte & 0x0f) as usize] as char);
2948            }
2949        }
2950    }
2951    out
2952}
2953
2954/// Redact credentials from a URL before it reaches logs or error values
2955/// (ADR-0051 redact-by-construction). Masks userinfo (`user:pass@`) and
2956/// the query string (which commonly carries API keys/tokens). Host and
2957/// path stay visible for diagnosability. Fragments are never echoed: a
2958/// fragment (OAuth2 callback tokens such as `#access_token=...`) is
2959/// dropped and replaced with the `#[redacted]` sentinel in both the
2960/// parsed arm and the unparseable arm. Fail-closed: when the parse fails
2961/// and any authority window contains `@`, only the `[redacted]`
2962/// sentinel is returned. Every authority window is scanned: windows are
2963/// enumerated over maximal runs of `/` and `\` — pure-slash runs of two
2964/// or more characters, backslash-bearing runs only behind an RFC 3986
2965/// scheme prefix (see [`camel_api::redact`] for the canonical window
2966/// rule) — each window starts immediately after the run (so evaders like
2967/// `scheme:////user:pass@evil/` cannot hide a `@` behind a slash run)
2968/// and ends at the next `/`, `?`, or `#`; scanning all windows keeps
2969/// later `//user:pass@` substrings from hiding behind a benign first
2970/// window.
2971///
2972/// The parsed arm keeps `url::Url::parse` (the authority can only be
2973/// judged by the parser) and masks the real authority accessors, then
2974/// delegates wholesale to the canonical string surgery in
2975/// [`camel_api::redact::redact_url`]: rust-url can park later-window
2976/// userinfo bytes in the path (`https://h//user:pass@evil/`), and the
2977/// canonical helper owns window masking, `?`/`#` sentinel composition
2978/// (one per distinct introducer, first-occurrence order), and the
2979/// 256-byte UTF-8 cap. The unparseable arm delegates to
2980/// [`camel_api::redact::redact_url_fail_closed`].
2981pub(crate) fn redact_url_for_diagnostics(raw: &str) -> String {
2982    match url::Url::parse(raw) {
2983        Ok(mut u) => {
2984            // Fail closed when an authority marker was accepted but no
2985            // host was stored: userinfo-shaped bytes can hide in the path
2986            // behind the marker, and empty-host schemes (`file:///us@r/x`,
2987            // `unix:///@socket`) can put a `@` in that window too. Such
2988            // inputs are sentineled wholesale — deliberate fail-closed
2989            // over-redaction per ADR-0051.
2990            if !u.cannot_be_a_base()
2991                && u.host_str().is_none()
2992                && camel_api::redact::window_has_at_sign(raw)
2993            {
2994                return "[redacted]".to_string();
2995            }
2996            if !u.username().is_empty() || u.password().is_some() {
2997                let _ = u.set_username("***");
2998                let _ = u.set_password(None);
2999            }
3000            // Query and fragment stay on the rendered URL; the canonical
3001            // redactor drops them and composes the sentinels.
3002            let s = u.to_string();
3003            camel_api::redact::redact_url(&s)
3004        }
3005        Err(_) => camel_api::redact::redact_url_fail_closed(raw),
3006    }
3007}
3008
3009/// Maximum bytes of an upstream error response body embedded into
3010/// `CamelError::HttpOperationFailed`. The body is attacker-controllable (a
3011/// malicious or compromised upstream), so it is truncated and lossy-decoded to
3012/// bound log injection / DLQ payload size.
3013const MAX_ERROR_RESPONSE_BODY_BYTES: usize = 4096;
3014
3015fn truncate_error_body(body: &[u8]) -> String {
3016    if body.len() <= MAX_ERROR_RESPONSE_BODY_BYTES {
3017        String::from_utf8_lossy(body).into_owned()
3018    } else {
3019        let mut s = String::from_utf8_lossy(&body[..MAX_ERROR_RESPONSE_BODY_BYTES]).into_owned();
3020        s.push_str("...[truncated]");
3021        s
3022    }
3023}
3024
3025impl HttpProducer {
3026    /// Whether the HTTP method is entity-enclosing (may carry a request
3027    /// body). Follows Apache Camel's `HttpMethods` set: POST, PUT, PATCH are
3028    /// entity-enclosing; GET, HEAD, DELETE, OPTIONS, TRACE are not (RFC 9110
3029    /// §9.3.1/§9.3.2).
3030    fn is_entity_enclosing(method: &str) -> bool {
3031        matches!(method, "POST" | "PUT" | "PATCH")
3032    }
3033}
3034
3035impl Service<Exchange> for HttpProducer {
3036    type Response = Exchange;
3037    type Error = CamelError;
3038    type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
3039
3040    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
3041        Poll::Ready(Ok(()))
3042    }
3043
3044    fn call(&mut self, exchange: Exchange) -> Self::Future {
3045        let config = self.config.clone();
3046        let shared_client = self.client.clone();
3047        let pinned_cache = std::sync::Arc::clone(&self.pinned_cache);
3048        let http_config = self.http_config.clone();
3049        let component_metrics = self.runtime.component_metrics();
3050
3051        Box::pin(async move {
3052            let mut exchange = exchange;
3053            let outcome = async {
3054                let method_str = HttpProducer::resolve_method(&exchange, &config);
3055                // Entity-enclosing gate (RFC 9110 §9.3.1/§9.3.2): only POST, PUT
3056                // and PATCH may carry a request body. Any other resolved method
3057                // drops the exchange body before the request is built (Apache
3058                // Camel `HttpMethods` parity).
3059                let suppress_body = !HttpProducer::is_entity_enclosing(&method_str);
3060                let url = HttpProducer::resolve_url(&exchange, &config)?;
3061
3062                // SECURITY: Validate URL for SSRF
3063                ssrf::validate_url_for_ssrf(&url, &config)?;
3064
3065                // Resolve hostname and pin validated IPs to prevent DNS-rebinding TOCTOU
3066                // (L-H2). When the URL uses a domain name and SSRF protection is active,
3067                // reuse the endpoint's cached DNS-pinned client for that validated
3068                // (host, addrs) pair — built once with resolve_to_addrs, then shared so
3069                // repeated requests keep one connection pool without re-resolving DNS.
3070                // Per-request SSRF validation and DNS pinning are unchanged. IP-literal
3071                // URLs use the endpoint's unpinned shared client.
3072                let resolved = ssrf::resolve_initial_url_for_ssrf(
3073                    &url,
3074                    config.allow_internal,
3075                    config.allow_cleartext,
3076                )
3077                .await?;
3078                let client: reqwest::Client = if let Some((ref host, ref addrs)) = resolved {
3079                    // A failed pinned build (strict fail-closed material
3080                    // conflict) propagates into the request error path —
3081                    // no degraded client is built or served.
3082                    pinned_cache
3083                        .get_or_build(host.as_str(), addrs, || {
3084                            build_client(&http_config, Some((host.as_str(), addrs)))
3085                        })
3086                        .await?
3087                } else {
3088                    shared_client.clone()
3089                };
3090
3091                debug!(
3092                    correlation_id = %exchange.correlation_id(),
3093                    method = %method_str,
3094                    url = %redact_url_for_diagnostics(&url),
3095                    "HTTP request"
3096                );
3097
3098                let method = method_str.parse::<reqwest::Method>().map_err(|e| {
3099                    CamelError::ProcessorError(format!(
3100                        "Invalid HTTP method '{}': {}",
3101                        method_str, e
3102                    ))
3103                })?;
3104
3105                // Collect headers for potential redirect replay
3106                let mut collected_headers: Vec<(
3107                    reqwest::header::HeaderName,
3108                    reqwest::header::HeaderValue,
3109                )> = Vec::new();
3110
3111                if let Some(user_agent) = &config.user_agent
3112                    && !config.bridge_endpoint
3113                {
3114                    match constructed_header("user-agent", user_agent) {
3115                        Ok((_, val)) => {
3116                            collected_headers.push((reqwest::header::USER_AGENT, val));
3117                        }
3118                        Err(drop) => debug!(
3119                            correlation_id = %exchange.correlation_id(),
3120                            header = %drop.name,
3121                            "outbound header dropped: {}",
3122                            drop.reason
3123                        ),
3124                    }
3125                }
3126
3127                // Inject W3C TraceContext headers for distributed tracing (opt-in via "otel" feature)
3128                #[cfg(feature = "otel")]
3129                let should_inject_otel = !config.bridge_endpoint;
3130                #[cfg(feature = "otel")]
3131                if should_inject_otel {
3132                    let mut otel_headers = HashMap::new();
3133                    camel_otel::inject_from_exchange(&exchange, &mut otel_headers);
3134                    for (k, v) in otel_headers {
3135                        match constructed_header(&k, &v) {
3136                            Ok((name, val)) => collected_headers.push((name, val)),
3137                            Err(drop) => debug!(
3138                                correlation_id = %exchange.correlation_id(),
3139                                header = %drop.name,
3140                                "outbound header dropped: {}",
3141                                drop.reason
3142                            ),
3143                        }
3144                    }
3145                }
3146
3147                let conn_tokens = header_policy::connection_tokens(
3148                    exchange
3149                        .input
3150                        .headers
3151                        .iter()
3152                        .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3153                        .filter_map(|(_, v)| v.as_str()),
3154                );
3155
3156                let outbound = select_outbound_headers(
3157                    &exchange.input.headers,
3158                    &config.skip_request_headers,
3159                    &conn_tokens,
3160                );
3161                for drop in &outbound.drops {
3162                    if let Some(value_kind) = drop.value_kind {
3163                        debug!(
3164                            correlation_id = %exchange.correlation_id(),
3165                            header = %drop.name,
3166                            value_kind = value_kind,
3167                            "outbound header dropped: {}",
3168                            drop.reason
3169                        );
3170                    } else {
3171                        debug!(
3172                            correlation_id = %exchange.correlation_id(),
3173                            header = %drop.name,
3174                            "outbound header dropped: {}",
3175                            drop.reason
3176                        );
3177                    }
3178                }
3179                collected_headers.extend(outbound.accepted);
3180
3181                // Auth headers
3182                if !config.bridge_endpoint {
3183                    match &config.auth {
3184                        HttpAuth::None => {}
3185                        HttpAuth::Basic { username, password } => {
3186                            use base64::Engine;
3187                            // allow-secret: credentials combined for base64 Basic auth header
3188                            let credentials = format!("{username}:{password}");
3189                            let encoded =
3190                                base64::engine::general_purpose::STANDARD.encode(credentials);
3191                            // Base64 output is always header-safe; the guard is kept
3192                            // for uniformity with Bearer.
3193                            match constructed_header("authorization", &format!("Basic {encoded}")) {
3194                                Ok((_, val)) => {
3195                                    collected_headers.push((reqwest::header::AUTHORIZATION, val));
3196                                }
3197                                Err(drop) => debug!(
3198                                    correlation_id = %exchange.correlation_id(),
3199                                    header = %drop.name,
3200                                    "outbound header dropped: {}",
3201                                    drop.reason
3202                                ),
3203                            }
3204                        }
3205                        HttpAuth::Bearer { token } => {
3206                            // allow-secret: Bearer token in Authorization header
3207                            let bearer = format!("Bearer {token}");
3208                            match constructed_header("authorization", &bearer) {
3209                                Ok((_, val)) => {
3210                                    collected_headers.push((reqwest::header::AUTHORIZATION, val));
3211                                }
3212                                Err(drop) => debug!(
3213                                    correlation_id = %exchange.correlation_id(),
3214                                    header = %drop.name,
3215                                    "outbound header dropped: {}",
3216                                    drop.reason
3217                                ),
3218                            }
3219                        }
3220                    }
3221
3222                    if config.connection_close {
3223                        collected_headers.push((
3224                            reqwest::header::CONNECTION,
3225                            reqwest::header::HeaderValue::from_static("close"),
3226                        ));
3227                    }
3228                }
3229
3230                // Materialize body
3231                let is_stream_body = matches!(exchange.input.body, Body::Stream(_));
3232                let materialized_body: Option<Vec<u8>> = if is_stream_body {
3233                    if suppress_body {
3234                        // A stream body dropped under a non-entity-enclosing
3235                        // method always warns (its emptiness is unknowable) and
3236                        // stays consumed (mem::take). The stream attach arm below
3237                        // still runs its outer flag check, but the inner `if let
3238                        // Body::Stream` re-match fails on the now-Empty body, so
3239                        // no stream is attached and no AlreadyConsumed error can
3240                        // fire.
3241                        std::mem::take(&mut exchange.input.body);
3242                        // log-policy: handler-owned
3243                        tracing::warn!(
3244                            correlation_id = %exchange.correlation_id(),
3245                            method = %method_str,
3246                            "dropping request body for non-entity-enclosing HTTP method"
3247                        );
3248                    }
3249                    None // Streams can't be replayed on redirect
3250                } else {
3251                    let body = std::mem::take(&mut exchange.input.body);
3252                    let bytes = body.into_bytes(config.max_body_size).await?;
3253                    if bytes.is_empty() {
3254                        // Empty body: nothing to send and nothing to warn about.
3255                        None
3256                    } else if suppress_body {
3257                        // log-policy: handler-owned
3258                        tracing::warn!(
3259                            correlation_id = %exchange.correlation_id(),
3260                            method = %method_str,
3261                            "dropping request body for non-entity-enclosing HTTP method"
3262                        );
3263                        None
3264                    } else {
3265                        Some(bytes.to_vec())
3266                    }
3267                };
3268
3269                let response = if config.follow_redirects && !is_stream_body {
3270                    // Use manual redirect loop with per-hop SSRF validation.
3271                    // `client` is the pinned-or-shared binding for the initial
3272                    // request (a hostname initial request keeps its DNS-pinned
3273                    // client); `shared_client` is the unpinned endpoint client
3274                    // reused by IP-literal redirect hops.
3275                    ssrf::send_with_ssrf_safe_redirects(
3276                        &client,
3277                        &shared_client,
3278                        &pinned_cache,
3279                        &http_config,
3280                        &config,
3281                        method,
3282                        &url,
3283                        collected_headers,
3284                        materialized_body,
3285                        config.max_redirects,
3286                        config.response_timeout,
3287                    )
3288                    .await?
3289                } else {
3290                    // Direct send (no redirect following, or streaming body)
3291                    let mut request = client.request(method, &url);
3292
3293                    if let Some(timeout) = config.response_timeout {
3294                        request = request.timeout(timeout);
3295                    }
3296
3297                    for (name, value) in &collected_headers {
3298                        request = request.header(name, value);
3299                    }
3300
3301                    if is_stream_body {
3302                        if let Body::Stream(ref s) = exchange.input.body {
3303                            let mut stream_lock = s.stream.lock().await;
3304                            if let Some(stream) = stream_lock.take() {
3305                                request = request.body(reqwest::Body::wrap_stream(stream));
3306                            } else {
3307                                return Err(CamelError::AlreadyConsumed);
3308                            }
3309                        }
3310                    } else if let Some(ref body_bytes) = materialized_body {
3311                        request = request.body(body_bytes.clone());
3312                    }
3313
3314                    request.send().await.map_err(|e| {
3315                        CamelError::ProcessorError(format!("HTTP request failed: {e}"))
3316                    })?
3317                };
3318
3319                let status_code = response.status().as_u16();
3320                let status_text = response
3321                    .status()
3322                    .canonical_reason()
3323                    .unwrap_or("Unknown")
3324                    .to_string();
3325
3326                for (key, value) in response.headers() {
3327                    if config
3328                        .skip_response_headers
3329                        .iter()
3330                        .any(|h| h.eq_ignore_ascii_case(key.as_str()))
3331                    {
3332                        continue;
3333                    }
3334                    if let Ok(val_str) = value.to_str() {
3335                        exchange.input.set_header(
3336                            title_case_header(key.as_str()),
3337                            serde_json::Value::String(val_str.to_string()),
3338                        );
3339                    }
3340                }
3341
3342                exchange.input.set_header(
3343                    "CamelHttpResponseCode",
3344                    serde_json::Value::Number(status_code.into()),
3345                );
3346                exchange.input.set_header(
3347                    "CamelHttpResponseText",
3348                    serde_json::Value::String(status_text.clone()),
3349                );
3350
3351                // Read response body with timeout and size guard (HTTP-004, HTTP-005)
3352                let read_timeout = Duration::from_millis(config.read_timeout_ms);
3353                let response_body = tokio::time::timeout(read_timeout, async {
3354                    // Check Content-Length header before allocating
3355                    if let Some(content_len) = response.content_length()
3356                        && content_len > config.max_response_bytes as u64
3357                    {
3358                        return Err(CamelError::ProcessorError(format!(
3359                            "Response body too large: {} bytes exceeds limit of {} bytes",
3360                            content_len, config.max_response_bytes
3361                        )));
3362                    }
3363                    // Use bytes_stream() for lazy streaming with size guard
3364                    use futures::TryStreamExt;
3365                    let mut stream = response.bytes_stream();
3366                    let mut total: usize = 0;
3367                    let mut collected = Vec::new();
3368                    while let Some(chunk) = stream.try_next().await.map_err(|e| {
3369                        CamelError::ProcessorError(format!("Failed to read response body: {e}"))
3370                    })? {
3371                        total += chunk.len();
3372                        if total > config.max_response_bytes {
3373                            return Err(CamelError::ProcessorError(format!(
3374                                "Response body too large: {} bytes exceeds limit of {} bytes",
3375                                total, config.max_response_bytes
3376                            )));
3377                        }
3378                        collected.push(chunk);
3379                    }
3380                    let mut result = bytes::BytesMut::with_capacity(total);
3381                    for chunk in collected {
3382                        result.extend_from_slice(&chunk);
3383                    }
3384                    Ok::<bytes::Bytes, CamelError>(result.freeze())
3385                })
3386                .await
3387                .map_err(|_| {
3388                    CamelError::ProcessorError(format!(
3389                        "Read timeout after {}ms",
3390                        config.read_timeout_ms
3391                    ))
3392                })??;
3393
3394                if config.throw_exception_on_failure
3395                    && !HttpProducer::is_ok_status(status_code, config.ok_status_code_range)
3396                {
3397                    return Err(CamelError::HttpOperationFailed {
3398                        method: method_str,
3399                        // ADR-0051 redact-by-construction: never embed
3400                        // userinfo/query credentials in the error value.
3401                        url: redact_url_for_diagnostics(&url),
3402                        status_code,
3403                        status_text,
3404                        response_body: Some(truncate_error_body(&response_body)),
3405                    });
3406                }
3407
3408                if !response_body.is_empty() {
3409                    exchange.input.body = Body::Bytes(bytes::Bytes::from(response_body.to_vec()));
3410                }
3411
3412                debug!(
3413                    correlation_id = %exchange.correlation_id(),
3414                    status = status_code,
3415                    url = %redact_url_for_diagnostics(&url),
3416                    "HTTP response"
3417                );
3418                Ok(exchange)
3419            }
3420            .await;
3421            // ("http","request") facade (dashboard-observability 4.3): the
3422            // request boundary is the full client round-trip — SSRF checks,
3423            // send, response read, and (with throwExceptionOnFailure) the
3424            // status gate. http runs no retry_async and the producer
3425            // previously emitted nothing, so no label collides with
3426            // e:http:request.
3427            component_metrics.observe("http", "request", outcome.is_err());
3428            outcome
3429        })
3430    }
3431}
3432
3433/// Serializes tests that mutate or depend on the global `ServerRegistry`.
3434///
3435/// `ServerRegistry::global()` is a process-wide singleton that persists
3436/// across tests. `ServerRegistry::reset()` clears ALL entries; if it races
3437/// with another test that has a live server on a fixed port (e.g. 9991),
3438/// the registry entry is removed while the OS socket is still bound, so
3439/// the next `get_or_spawn` call on that port fails with "Address already
3440/// in use". This mutex does not give blanket protection by itself. It
3441/// helps only where every participant follows the mutex law: the
3442/// consumer-test readiness helper holds it from `stage_listener` until
3443/// readiness-complete (http-test-harness spec, requirement
3444/// "Registry-mutation serialization during setup"), and each `reset()`
3445/// caller takes it before the reset.
3446#[cfg(test)]
3447pub(crate) static REGISTRY_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
3448
3449/// Poison-recovering acquire of REGISTRY_TEST_MUTEX (httpflake).
3450///
3451/// The mutex guards test SERIALIZATION only - the registry own data is
3452/// protected by its inner lock - so a sibling test that panics while
3453/// holding the guard must not poison the mutex and cascade failures
3454/// into every other holder. Recovery via into_inner is therefore safe
3455/// and keeps one failing test failing as ONE test.
3456#[cfg(test)]
3457pub(crate) fn lock_registry_test_mutex() -> std::sync::MutexGuard<'static, ()> {
3458    REGISTRY_TEST_MUTEX
3459        .lock()
3460        .unwrap_or_else(|poisoned| poisoned.into_inner())
3461}
3462
3463/// Serializes tests that mutate (or assert on) the process-global
3464/// SSL_CERT_FILE/SSL_CERT_DIR CA-probe env vars (rc-3j4mq). Poison-
3465/// recovering for the same reason as REGISTRY_TEST_MUTEX.
3466#[cfg(test)]
3467static CA_STORE_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
3468
3469#[cfg(test)]
3470pub(crate) fn lock_ca_store_test_mutex() -> std::sync::MutexGuard<'static, ()> {
3471    CA_STORE_TEST_MUTEX
3472        .lock()
3473        .unwrap_or_else(|poisoned| poisoned.into_inner())
3474}
3475
3476/// Map a pipeline error to an HTTP reply.
3477///
3478/// Extracted from the inline `match` in `dispatch_handler` for unit
3479/// testability (rc-1dk4). Client-fault errors map to their 4xx codes
3480/// with a structured JSON error body: `TypeConversionFailed`/
3481/// `ValidationError` → 400, `UnsupportedMediaType` → 415 (media
3482/// negotiation gate, REST lowering), `NotAcceptable` → 406 (same
3483/// gate); `Unauthenticated`/`Unauthorized` keep their `401`/`403`
3484/// mappings; all other errors map to `500 Internal Server Error`.
3485fn pipeline_error_to_reply(e: CamelError, path: &str) -> HttpReply {
3486    match e {
3487        CamelError::Unauthenticated(msg) => {
3488            tracing::warn!(error = %msg, path = %path, "Authentication failed");
3489            HttpReply {
3490                status: 401,
3491                headers: vec![("WWW-Authenticate".to_string(), "Bearer".to_string())],
3492                body: HttpReplyBody::Bytes(bytes::Bytes::from("Unauthorized")),
3493            }
3494        }
3495        CamelError::Unauthorized(msg) => {
3496            tracing::warn!(error = %msg, path = %path, "Authorization failed");
3497            HttpReply {
3498                status: 403,
3499                headers: vec![],
3500                body: HttpReplyBody::Bytes(bytes::Bytes::from("Forbidden")),
3501            }
3502        }
3503        CamelError::TypeConversionFailed(msg) => {
3504            tracing::warn!(error = %msg, path = %path, "Type conversion failed (bad request)");
3505            json_error_reply(400, "bad_request", msg)
3506        }
3507        CamelError::ValidationError(msg) => {
3508            tracing::warn!(error = %msg, path = %path, "Schema validation failed (bad request)");
3509            json_error_reply(400, "validation_error", msg)
3510        }
3511        CamelError::ConsumerStopping => {
3512            tracing::debug!(path = %path, "Pipeline aborted during route shutdown");
3513            HttpReply {
3514                status: 503,
3515                headers: vec![],
3516                body: HttpReplyBody::Bytes(bytes::Bytes::from("Service Unavailable")),
3517            }
3518        }
3519        CamelError::UnsupportedMediaType { consumed, declared } => {
3520            tracing::warn!(error = %consumed, declared = %declared, path = %path, "Unsupported media type (bad request)");
3521            json_error_reply(
3522                415,
3523                "unsupported_media_type",
3524                format!("consumed {consumed}, declared {declared}"),
3525            )
3526        }
3527        CamelError::NotAcceptable { accept, produced } => {
3528            tracing::warn!(error = %accept, produced = %produced, path = %path, "Not acceptable (bad request)");
3529            json_error_reply(
3530                406,
3531                "not_acceptable",
3532                format!("accept {accept}, produced {produced}"),
3533            )
3534        }
3535        e => {
3536            // log-policy: handler-owned
3537            tracing::warn!(error = %e, path = %path, "Pipeline error processing HTTP request");
3538            HttpReply {
3539                status: 500,
3540                headers: vec![],
3541                body: HttpReplyBody::Bytes(bytes::Bytes::from("Internal Server Error")),
3542            }
3543        }
3544    }
3545}
3546
3547/// Build a JSON error reply with the given status, error code, and message.
3548///
3549/// Shared by the `TypeConversionFailed`/`ValidationError` (400),
3550/// `UnsupportedMediaType` (415), and `NotAcceptable` (406) arms of
3551/// `pipeline_error_to_reply` so the four replies cannot drift apart. The
3552/// `unwrap_or_else(|_| "{}".to_string())` fallback keeps the reply valid
3553/// JSON even if serialization fails.
3554fn json_error_reply(status: u16, code: &str, message: String) -> HttpReply {
3555    let body = serde_json::to_string(&serde_json::json!({
3556        "error": code,
3557        "message": message,
3558    }))
3559    .unwrap_or_else(|_| "{}".to_string()); // allow-unwrap
3560    HttpReply {
3561        status,
3562        headers: vec![("Content-Type".to_string(), "application/json".to_string())],
3563        body: HttpReplyBody::Bytes(bytes::Bytes::from(body)),
3564    }
3565}
3566
3567/// Lowercase kind name for a JSON value, used in drop diagnostics so log
3568/// readers see *why* a header had no scalar string form without the value
3569/// itself ever entering diagnostics.
3570const fn json_value_kind(v: &serde_json::Value) -> &'static str {
3571    match v {
3572        serde_json::Value::Null => "null",
3573        serde_json::Value::Bool(_) => "bool",
3574        serde_json::Value::Number(_) => "number",
3575        serde_json::Value::String(_) => "string",
3576        serde_json::Value::Array(_) => "array",
3577        serde_json::Value::Object(_) => "object",
3578    }
3579}
3580
3581/// Scalar string form of a JSON value: strings pass through, `Number` and
3582/// `Bool` are stringified, everything else has no single-value form.
3583/// Shared by the consumer reply finaliser and the producer outbound filter
3584/// so the two directions cannot drift apart (rc-lidtk / rc-8l23a).
3585fn scalar_string_form(v: &serde_json::Value) -> Option<String> {
3586    match v {
3587        serde_json::Value::String(s) => Some(s.clone()),
3588        serde_json::Value::Number(n) => Some(n.to_string()),
3589        serde_json::Value::Bool(b) => Some(b.to_string()),
3590        _ => None,
3591    }
3592}
3593
3594/// Select the HTTP response headers emitted by the consumer reply finaliser
3595/// (ADR-0057 / rc-2jj2). Extracted from the inline filter in
3596/// `dispatch_handler` for unit testability.
3597///
3598/// Drops Camel-namespace headers, hop-by-hop/framing, request-only, and
3599/// server-owned headers, plus `content-length`/`content-type` (re-derived),
3600/// and any header named by a `Connection` token. Scalar non-string values
3601/// (`Number`/`Bool`) are stringified so `set_header("X-Retries", 3)` reaches
3602/// the wire instead of being silently discarded (rc-lidtk); `null`, objects,
3603/// and arrays have no single-value form and are dropped. Every drop is
3604/// logged at DEBUG with the header name and reason — names only, never
3605/// values, so credentials cannot leak into diagnostics (ADR-0051).
3606/// Appends a single `Content-Type` from `user_content_type` falling back to
3607/// `inferred_content_type` when either is present.
3608fn select_response_headers(
3609    headers: &HashMap<String, serde_json::Value>,
3610    user_content_type: Option<String>,
3611    inferred_content_type: Option<String>,
3612) -> Vec<(String, String)> {
3613    let conn_tokens = header_policy::connection_tokens(
3614        headers
3615            .iter()
3616            .filter(|(k, _)| k.eq_ignore_ascii_case("connection"))
3617            .filter_map(|(_, v)| v.as_str()),
3618    );
3619    let mut selected: Vec<(String, String)> = Vec::new();
3620    for (k, v) in headers {
3621        if k.starts_with("Camel") {
3622            debug!(header = %k, "reply header dropped: Camel namespace");
3623            continue;
3624        }
3625        if header_policy::excluded_response(k, &conn_tokens) {
3626            debug!(header = %k, "reply header dropped: emission policy");
3627            continue;
3628        }
3629        match scalar_string_form(v) {
3630            Some(s) => selected.push((k.clone(), s)),
3631            None => debug!(
3632                header = %k,
3633                value_kind = json_value_kind(v),
3634                "reply header dropped: no scalar string form"
3635            ),
3636        }
3637    }
3638    if let Some(ct) = user_content_type.or(inferred_content_type) {
3639        selected.push(("Content-Type".to_string(), ct));
3640    }
3641    selected
3642}
3643
3644/// One outbound header drop: the exchange header name, a stable reason
3645/// string, and — when the drop was caused by the value having no scalar
3646/// string form — the JSON value kind. Names and kinds only, never values
3647/// (ADR-0051).
3648#[derive(Debug)]
3649struct OutboundHeaderDrop<'a> {
3650    name: &'a str,
3651    reason: &'static str,
3652    value_kind: Option<&'static str>,
3653}
3654
3655/// Outbound exchange-header selection result: headers accepted for the
3656/// wire plus drop records for call-site DEBUG logging.
3657struct OutboundHeaderSelection<'a> {
3658    accepted: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
3659    drops: Vec<OutboundHeaderDrop<'a>>,
3660}
3661
3662/// Select the exchange headers the HTTP producer forwards on the outbound
3663/// request (ADR-0057 / rc-8l23a). Extracted from the inline filter in
3664/// `HttpProducer::call` for unit testability.
3665///
3666/// Drops `Camel`-namespace headers, names listed in `skip_request_headers`,
3667/// hop-by-hop/framing and connection-token-named headers excluded by the
3668/// outbound emission policy, and headers whose name or stringified value
3669/// fails `HeaderName`/`HeaderValue` construction. Scalar non-string values
3670/// (`Number`/`Bool`) are stringified so `set_header("X-Retries", 3)` reaches
3671/// the wire instead of being silently discarded (rc-8l23a); `null`, objects,
3672/// and arrays have no single-value form and are dropped. Drops are returned
3673/// rather than logged so the call site can attach the correlation id; log
3674/// consumers see names and kinds only, never values (ADR-0051).
3675fn select_outbound_headers<'a>(
3676    headers: &'a HashMap<String, serde_json::Value>,
3677    skip_request_headers: &[String],
3678    conn_tokens: &[String],
3679) -> OutboundHeaderSelection<'a> {
3680    let mut accepted = Vec::new();
3681    let mut drops = Vec::new();
3682    for (key, value) in headers {
3683        if key.starts_with("Camel") {
3684            drops.push(OutboundHeaderDrop {
3685                name: key,
3686                reason: "Camel namespace",
3687                value_kind: None,
3688            });
3689            continue;
3690        }
3691        if skip_request_headers
3692            .iter()
3693            .any(|h| h.eq_ignore_ascii_case(key))
3694        {
3695            drops.push(OutboundHeaderDrop {
3696                name: key,
3697                reason: "skip_request_headers",
3698                value_kind: None,
3699            });
3700            continue;
3701        }
3702        if header_policy::excluded_outbound(key, conn_tokens) {
3703            drops.push(OutboundHeaderDrop {
3704                name: key,
3705                reason: "outbound emission policy",
3706                value_kind: None,
3707            });
3708            continue;
3709        }
3710        let Some(val_str) = scalar_string_form(value) else {
3711            drops.push(OutboundHeaderDrop {
3712                name: key,
3713                reason: "no scalar string form",
3714                value_kind: Some(json_value_kind(value)),
3715            });
3716            continue;
3717        };
3718        match constructed_header(key, &val_str) {
3719            Ok((name, val)) => accepted.push((name, val)),
3720            Err(drop) => drops.push(drop),
3721        }
3722    }
3723    OutboundHeaderSelection { accepted, drops }
3724}
3725
3726/// Construct a wire-ready `(HeaderName, HeaderValue)` pair for one outbound
3727/// header, or a drop record when the name or value fails construction
3728/// (rc-jbs1v). Drop records carry name and reason only, never values
3729/// (ADR-0051).
3730fn constructed_header<'a>(
3731    name: &'a str,
3732    value: &str,
3733) -> Result<(reqwest::header::HeaderName, reqwest::header::HeaderValue), OutboundHeaderDrop<'a>> {
3734    let header_name = match reqwest::header::HeaderName::from_bytes(name.as_bytes()) {
3735        Ok(header_name) => header_name,
3736        Err(_) => {
3737            return Err(OutboundHeaderDrop {
3738                name,
3739                reason: "invalid header name",
3740                value_kind: None,
3741            });
3742        }
3743    };
3744    let header_value = match reqwest::header::HeaderValue::from_str(value) {
3745        Ok(header_value) => header_value,
3746        Err(_) => {
3747            return Err(OutboundHeaderDrop {
3748                name,
3749                reason: "invalid header value",
3750                value_kind: None,
3751            });
3752        }
3753    };
3754    Ok((header_name, header_value))
3755}
3756
3757#[cfg(test)]
3758#[path = "lib_tests.rs"]
3759mod tests;