Skip to main content

mq_bridge/
models.rs

1//  mq-bridge
2//  © Copyright 2025, by Marco Mengelkoch
3//  Licensed under MIT License, see License file for more details
4//  git clone https://github.com/marcomq/mq-bridge
5
6use serde::{
7    de::{MapAccess, Visitor},
8    Deserialize, Deserializer, Serialize,
9};
10use std::{
11    collections::HashMap,
12    sync::{atomic::AtomicUsize, Arc},
13};
14
15use crate::traits::Handler;
16use tracing::trace;
17
18/// The top-level configuration is a map of named routes.
19/// The key is the route name (e.g., "kafka_to_nats").
20///
21/// # Examples
22///
23/// Deserializing a complex configuration from YAML:
24///
25/// ```
26/// use mq_bridge::models::{Config, EndpointType, Middleware};
27///
28/// let yaml = r#"
29/// kafka_to_nats:
30///   concurrency: 10
31///   input:
32///     middlewares:
33///       - deduplication:
34///           sled_path: "/tmp/mq-bridge/dedup_db"
35///           ttl_seconds: 3600
36///       - metrics: {}
37///       - retry:
38///           max_attempts: 5
39///           initial_interval_ms: 200
40///       - random_panic:
41///           mode: nack
42///       - dlq:
43///           endpoint:
44///             nats:
45///               subject: "dlq-subject"
46///               url: "nats://localhost:4222"
47///     kafka:
48///       topic: "input-topic"
49///       url: "localhost:9092"
50///       group_id: "my-consumer-group"
51///       tls:
52///         required: true
53///         ca_file: "/path_to_ca"
54///         cert_file: "/path_to_cert"
55///         key_file: "/path_to_key"
56///         cert_password: "password"
57///         accept_invalid_certs: true
58///   output:
59///     middlewares:
60///       - metrics: {}
61///       - dlq:
62///           endpoint:
63///             file:
64///               path: "error.out"
65///     nats:
66///       subject: "output-subject"
67///       url: "nats://localhost:4222"
68/// "#;
69///
70/// let config: Config = serde_yaml_ng::from_str(yaml).unwrap();
71/// let route = config.get("kafka_to_nats").unwrap();
72///
73/// assert_eq!(route.options.concurrency, 10);
74/// // Check input middleware
75/// assert!(route.input.middlewares.iter().any(|m| matches!(m, Middleware::Deduplication(_))));
76/// // Check output endpoint
77/// assert!(matches!(route.output.endpoint_type, EndpointType::Nats(_)));
78/// ```
79pub type Config = HashMap<String, Route>;
80
81/// A configuration map for named publishers (endpoints).
82/// The key is the publisher name.
83pub type PublisherConfig = HashMap<String, Endpoint>;
84
85/// Defines a single message processing route from an input to an output.
86#[derive(Debug, Deserialize, Serialize, Clone)]
87#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
88#[cfg_attr(feature = "schema", schemars(transform = route_schema_transform))]
89#[serde(deny_unknown_fields)]
90pub struct Route {
91    /// The input/source endpoint for the route.
92    pub input: Endpoint,
93    /// The output/sink endpoint for the route.
94    #[serde(default = "default_output_endpoint")]
95    pub output: Endpoint,
96    /// (Optional) Fine-tuning options for the route's execution.
97    #[serde(flatten, default)]
98    pub options: RouteOptions,
99}
100
101impl Default for Route {
102    fn default() -> Self {
103        Self {
104            input: Endpoint::null(),
105            output: Endpoint::null(),
106            options: RouteOptions::default(),
107        }
108    }
109}
110
111/// Fine-tuning options for a route's execution.
112///
113/// These options control concurrency, batching, and commit behavior for message processing.
114///
115/// # Examples
116///
117/// ```
118/// use mq_bridge::models::RouteOptions;
119///
120/// let options = RouteOptions {
121///     description: "My Route".to_string(),
122///     concurrency: 10,
123///     batch_size: 5,
124///     commit_concurrency_limit: 1024,
125///     ..Default::default()
126/// };
127/// ```
128#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
129#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
130#[serde(deny_unknown_fields)]
131pub struct RouteOptions {
132    /// A human-readable description of the route's purpose. Defaults to an empty string.
133    #[serde(default, skip_serializing_if = "String::is_empty")]
134    pub description: String,
135    /// (Optional) Number of concurrent processing tasks for this route. While it improves throughput for high-latency
136    /// handlers, it adds synchronization overhead for ordered commits and may lead to out-of-order processing
137    /// in the handler. Defaults to 1.
138    #[serde(default = "default_concurrency")]
139    #[cfg_attr(feature = "schema", schemars(range(min = 1)))]
140    pub concurrency: usize,
141    /// (Optional) Maximum number of messages to process in a single batch. The consumer waits for at least one message
142    /// and then attempts to fetch more if available. Increasing this improves throughput but also increases
143    /// the potential impact of a single batch processing failure. Defaults to 1.
144    #[serde(default = "default_batch_size")]
145    #[cfg_attr(feature = "schema", schemars(range(min = 1)))]
146    pub batch_size: usize,
147    /// (Optional) The maximum number of in-flight commit requests queued for ordered sequencing.
148    /// Lower values apply backpressure earlier; higher values allow larger commit backlogs.
149    /// Defaults to 4096.
150    #[serde(default = "default_commit_concurrency_limit")]
151    #[cfg_attr(feature = "schema", schemars(range(min = 1)))]
152    pub commit_concurrency_limit: usize,
153    /// Time to wait for a route to establish connections before startup fails. Defaults to 5000ms.
154    #[serde(default = "default_startup_timeout_ms")]
155    pub startup_timeout_ms: u64,
156    /// Time to wait before reconnecting after a transient route failure. Defaults to 5000ms.
157    #[serde(default = "default_reconnect_interval_ms")]
158    pub reconnect_interval_ms: u64,
159    /// Delay after an empty receive batch to avoid hot polling. Set to 0 to only yield. Defaults to 10ms.
160    #[serde(default = "default_empty_batch_delay_ms")]
161    pub empty_batch_delay_ms: u64,
162    /// Allows fault-injection middleware such as random_panic. Disabled by default.
163    #[serde(default = "default_false", skip_serializing_if = "is_false")]
164    #[cfg_attr(feature = "schema", schemars(default = "default_false"))]
165    pub allow_fault_injection: bool,
166    /// If true, the route exits gracefully once the source yields an empty batch
167    /// (drain-then-exit). Off by default — routes normally poll indefinitely.
168    #[serde(default = "default_false", skip_serializing_if = "is_false")]
169    #[cfg_attr(feature = "schema", schemars(default = "default_false"))]
170    pub exit_on_empty: bool,
171}
172
173impl Default for RouteOptions {
174    fn default() -> Self {
175        Self {
176            description: String::new(),
177            concurrency: default_concurrency(),
178            batch_size: default_batch_size(),
179            commit_concurrency_limit: default_commit_concurrency_limit(),
180            startup_timeout_ms: default_startup_timeout_ms(),
181            reconnect_interval_ms: default_reconnect_interval_ms(),
182            empty_batch_delay_ms: default_empty_batch_delay_ms(),
183            allow_fault_injection: false,
184            exit_on_empty: false,
185        }
186    }
187}
188
189impl RouteOptions {
190    pub fn validate(&self) -> anyhow::Result<()> {
191        if self.concurrency == 0 {
192            return Err(anyhow::anyhow!("route concurrency must be at least 1"));
193        }
194        if self.batch_size == 0 {
195            return Err(anyhow::anyhow!("route batch_size must be at least 1"));
196        }
197        if self.commit_concurrency_limit == 0 {
198            return Err(anyhow::anyhow!(
199                "route commit_concurrency_limit must be at least 1"
200            ));
201        }
202        Ok(())
203    }
204}
205
206pub(crate) fn default_concurrency() -> usize {
207    1
208}
209
210pub(crate) fn default_batch_size() -> usize {
211    1
212}
213
214pub(crate) fn default_commit_concurrency_limit() -> usize {
215    4096
216}
217
218pub(crate) fn default_startup_timeout_ms() -> u64 {
219    5000
220}
221
222pub(crate) fn default_reconnect_interval_ms() -> u64 {
223    5000
224}
225
226pub(crate) fn default_empty_batch_delay_ms() -> u64 {
227    10
228}
229
230fn is_false(value: &bool) -> bool {
231    !*value
232}
233
234fn default_false() -> bool {
235    false
236}
237
238#[cfg(feature = "schema")]
239fn default_inline_response_fast_path_schema() -> Option<bool> {
240    Some(true)
241}
242
243/// Schema default for `shared` fields, whose runtime default is `true`.
244#[cfg(feature = "schema")]
245fn default_shared_schema() -> Option<bool> {
246    Some(true)
247}
248
249/// Schema default for Kafka `partitions`, whose runtime default is 6.
250#[cfg(feature = "schema")]
251fn default_kafka_partitions_schema() -> Option<i32> {
252    Some(DEFAULT_KAFKA_PARTITIONS)
253}
254
255/// Partition count used when auto-creating a Kafka topic if none is configured.
256/// ordering is per-key (we key by message_id), not global across the topic if > 1
257pub const DEFAULT_KAFKA_PARTITIONS: i32 = 6;
258
259fn default_output_endpoint() -> Endpoint {
260    Endpoint::new(EndpointType::Null)
261}
262
263fn default_retry_attempts() -> usize {
264    3
265}
266fn default_initial_interval_ms() -> u64 {
267    100
268}
269fn default_max_interval_ms() -> u64 {
270    5000
271}
272fn default_multiplier() -> f64 {
273    2.0
274}
275fn default_clean_session() -> bool {
276    false
277}
278fn default_cookie_metadata_key() -> String {
279    "cookie".to_string()
280}
281fn default_set_cookie_metadata_key() -> String {
282    "set-cookie".to_string()
283}
284
285fn is_known_endpoint_name(name: &str) -> bool {
286    matches!(
287        name,
288        "aws"
289            | "kafka"
290            | "nats"
291            | "file"
292            | "static"
293            | "memory"
294            | "sled"
295            | "amqp"
296            | "mongodb"
297            | "mqtt"
298            | "http"
299            | "websocket"
300            | "ibmmq"
301            | "zeromq"
302            | "grpc"
303            | "fanout"
304            | "stream_buffer"
305            | "ref"
306            | "switch"
307            | "response"
308            | "reader"
309            | "null"
310            | "sqlx"
311    )
312}
313
314/// Represents a connection point for messages, which can be a source (input) or a sink (output).
315#[derive(Serialize, Clone, Default)]
316#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
317#[serde(deny_unknown_fields)]
318pub struct Endpoint {
319    /// (Optional) A list of middlewares to apply to the endpoint.
320    #[serde(default)]
321    pub middlewares: Vec<Middleware>,
322
323    /// The specific endpoint implementation, determined by the configuration key (e.g., "kafka", "nats").
324    #[serde(flatten)]
325    pub endpoint_type: EndpointType,
326
327    #[serde(skip_serializing)]
328    #[cfg_attr(feature = "schema", schemars(skip))]
329    /// Internal handler for processing messages (not serialized).
330    pub handler: Option<Arc<dyn Handler>>,
331}
332
333impl std::fmt::Debug for Endpoint {
334    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335        f.debug_struct("Endpoint")
336            .field("middlewares", &self.middlewares)
337            .field("endpoint_type", &self.endpoint_type)
338            .field(
339                "handler",
340                &if self.handler.is_some() {
341                    "Some(<Handler>)"
342                } else {
343                    "None"
344                },
345            )
346            .finish()
347    }
348}
349
350impl<'de> Deserialize<'de> for Endpoint {
351    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
352    where
353        D: Deserializer<'de>,
354    {
355        struct EndpointVisitor;
356
357        impl<'de> Visitor<'de> for EndpointVisitor {
358            type Value = Endpoint;
359
360            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
361                formatter.write_str("a map representing an endpoint, the string \"null\", or null")
362            }
363
364            fn visit_unit<E>(self) -> Result<Self::Value, E>
365            where
366                E: serde::de::Error,
367            {
368                Ok(Endpoint::new(EndpointType::Null))
369            }
370
371            /// Unit variants of `EndpointType` serialize as a bare string (and are advertised
372            /// that way in the JSON schema). `Null` is currently the only one.
373            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
374            where
375                E: serde::de::Error,
376            {
377                if value == "null" {
378                    Ok(Endpoint::new(EndpointType::Null))
379                } else {
380                    Err(serde::de::Error::unknown_variant(value, &["null"]))
381                }
382            }
383
384            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
385            where
386                E: serde::de::Error,
387            {
388                self.visit_str(&value)
389            }
390
391            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
392            where
393                A: MapAccess<'de>,
394            {
395                // Buffer the map into a temporary serde_json::Map.
396                // This allows us to separate the `middlewares` field from the rest.
397                let mut temp_map = serde_json::Map::new();
398                let mut middlewares_val = None;
399
400                while let Some((key, value)) = map.next_entry::<String, serde_json::Value>()? {
401                    if key == "middlewares" {
402                        middlewares_val = Some(value);
403                    } else {
404                        temp_map.insert(key, value);
405                    }
406                }
407
408                // Deserialize the rest of the map into the flattened EndpointType.
409                let temp_val = serde_json::Value::Object(temp_map);
410                let endpoint_type: EndpointType = match serde_json::from_value(temp_val.clone()) {
411                    Ok(et) => et,
412                    Err(original_err) => {
413                        if let serde_json::Value::Object(map) = &temp_val {
414                            if map.len() == 1 {
415                                let (name, config) = map.iter().next().unwrap();
416                                if is_known_endpoint_name(name) {
417                                    return Err(serde::de::Error::custom(original_err));
418                                }
419                                trace!("Falling back to Custom endpoint for key: {}", name);
420                                EndpointType::Custom {
421                                    name: name.clone(),
422                                    config: config.clone(),
423                                }
424                            } else if map.is_empty() {
425                                EndpointType::Null
426                            } else {
427                                return Err(serde::de::Error::custom(
428                                    "Invalid endpoint configuration: multiple keys found or unknown endpoint type",
429                                ));
430                            }
431                        } else {
432                            return Err(serde::de::Error::custom("Invalid endpoint configuration"));
433                        }
434                    }
435                };
436
437                // Deserialize the extracted middlewares value using the existing helper logic.
438                let middlewares = match middlewares_val {
439                    Some(val) => {
440                        deserialize_middlewares_from_value(val).map_err(serde::de::Error::custom)?
441                    }
442                    None => Vec::new(),
443                };
444
445                Ok(Endpoint {
446                    middlewares,
447                    endpoint_type,
448                    handler: None,
449                })
450            }
451        }
452
453        deserializer.deserialize_any(EndpointVisitor)
454    }
455}
456
457fn is_known_middleware_name(name: &str) -> bool {
458    matches!(
459        name,
460        "deduplication"
461            | "metrics"
462            | "dlq"
463            | "retry"
464            | "random_panic"
465            | "delay"
466            | "weak_join"
467            | "limiter"
468            | "buffer"
469            | "cookie_jar"
470            | "custom"
471    )
472}
473
474/// Deserialize middlewares from a generic serde_json::Value.
475///
476/// This logic was extracted from `deserialize_middlewares_from_map_or_seq` to be reused by the custom `Endpoint` deserializer.
477fn deserialize_middlewares_from_value(value: serde_json::Value) -> anyhow::Result<Vec<Middleware>> {
478    let arr = match value {
479        serde_json::Value::Array(arr) => arr,
480        serde_json::Value::Object(map) => {
481            let mut middlewares: Vec<_> = map
482                .into_iter()
483                // The config crate can produce maps with numeric string keys ("0", "1", ...)
484                // from environment variables. We need to sort by these keys to maintain order.
485                .filter_map(|(key, value)| key.parse::<usize>().ok().map(|index| (index, value)))
486                .collect();
487            middlewares.sort_by_key(|(index, _)| *index);
488
489            middlewares.into_iter().map(|(_, value)| value).collect()
490        }
491        _ => return Err(anyhow::anyhow!("Expected an array or object")),
492    };
493
494    let mut middlewares = Vec::new();
495    for item in arr {
496        // Check if it is a map with a single key that matches a known middleware
497        let known_name = if let serde_json::Value::Object(map) = &item {
498            if map.len() == 1 {
499                let (name, _) = map.iter().next().unwrap();
500                if is_known_middleware_name(name) {
501                    Some(name.clone())
502                } else {
503                    None
504                }
505            } else {
506                None
507            }
508        } else {
509            None
510        };
511
512        if let Some(name) = known_name {
513            match serde_json::from_value::<Middleware>(item.clone()) {
514                Ok(m) => middlewares.push(m),
515                Err(e) => {
516                    return Err(anyhow::anyhow!(
517                        "Failed to deserialize known middleware '{}': {}",
518                        name,
519                        e
520                    ))
521                }
522            }
523        } else if let Ok(m) = serde_json::from_value::<Middleware>(item.clone()) {
524            middlewares.push(m);
525        } else if let serde_json::Value::Object(map) = &item {
526            if map.len() == 1 {
527                let (name, config) = map.iter().next().unwrap();
528                middlewares.push(Middleware::Custom {
529                    name: name.clone(),
530                    config: config.clone(),
531                });
532            } else {
533                return Err(anyhow::anyhow!(
534                    "Invalid middleware configuration: {:?}",
535                    item
536                ));
537            }
538        } else {
539            return Err(anyhow::anyhow!(
540                "Invalid middleware configuration: {:?}",
541                item
542            ));
543        }
544    }
545    Ok(middlewares)
546}
547
548/// Configuration for the `static` endpoint.
549///
550/// Accepts either a bare string (the response body, JSON-encoded for backward
551/// compatibility) or a map for full control:
552///
553/// ```yaml
554/// # bare string  -> body is JSON-encoded ("Hello" comes back quoted)
555/// static: "Hello, World!"
556///
557/// # map form -> raw body + custom metadata (HTTP maps metadata to headers)
558/// static:
559///   body: "Hello, World!"
560///   raw: true
561///   metadata:
562///     content-type: "text/plain"
563///     server: "mq-bridge"
564/// ```
565///
566/// When `raw` is true the body is sent verbatim; otherwise it is JSON-encoded as
567/// a string. Every entry in `metadata` is attached to the produced message; when
568/// this endpoint feeds an HTTP response, those entries become response headers
569/// (e.g. `content-type`), otherwise they are ordinary message metadata.
570///
571/// The `body` supports `${…}` placeholders (compiled once at startup): request
572/// fields `${payload:a.b}` / `${metadata:key}` / `${message:id}`, generators
573/// `${gen:uuid|now|timestamp|counter|random(1,100)}`, and `${env:VAR}`. When the
574/// `content-type` metadata is a JSON type, interpolated request values are
575/// JSON-escaped by default; append `| raw` to splice verbatim, and write `$${…}`
576/// to emit a literal `${…}`. See [`crate::support::interpolation`] for the full reference.
577#[derive(Debug, Clone, Default)]
578pub struct StaticConfig {
579    /// The static response body.
580    pub body: String,
581    /// Send the body verbatim instead of JSON-encoding it as a string.
582    pub raw: bool,
583    /// Extra metadata entries attached to the produced message.
584    pub metadata: std::collections::HashMap<String, String>,
585}
586
587// Hand-written schema: the `Deserialize` impl below accepts either a bare string
588// or a map where only `body` is required, so the derived all-fields-required
589// object schema would reject valid configs.
590#[cfg(feature = "schema")]
591impl schemars::JsonSchema for StaticConfig {
592    fn schema_name() -> std::borrow::Cow<'static, str> {
593        "StaticConfig".into()
594    }
595
596    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
597        schemars::json_schema!({
598            "description": "Configuration for the `static` endpoint. Accepts either a bare string (the response body, JSON-encoded for backward compatibility) or a map where only `body` is required and `raw` / `metadata` are optional.",
599            "oneOf": [
600                {
601                    "type": "string",
602                    "description": "The response body, JSON-encoded as a string."
603                },
604                {
605                    "type": "object",
606                    "properties": {
607                        "body": {
608                            "type": "string",
609                            "description": "The static response body."
610                        },
611                        "raw": {
612                            "type": "boolean",
613                            "description": "Send the body verbatim instead of JSON-encoding it as a string.",
614                            "default": false
615                        },
616                        "metadata": {
617                            "type": "object",
618                            "description": "Extra metadata entries attached to the produced message.",
619                            "additionalProperties": { "type": "string" }
620                        }
621                    },
622                    "required": ["body"],
623                    "additionalProperties": false
624                }
625            ]
626        })
627    }
628}
629
630impl Serialize for StaticConfig {
631    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
632    where
633        S: serde::Serializer,
634    {
635        // Backward-compatible: when no extra options are set, serialize as a bare
636        // string exactly like the historical `Static(String)` so configs written
637        // by this version remain readable by older versions.
638        if !self.raw && self.metadata.is_empty() {
639            return serializer.serialize_str(&self.body);
640        }
641        use serde::ser::SerializeStruct;
642        let mut state = serializer.serialize_struct("StaticConfig", 3)?;
643        state.serialize_field("body", &self.body)?;
644        state.serialize_field("raw", &self.raw)?;
645        state.serialize_field("metadata", &self.metadata)?;
646        state.end()
647    }
648}
649
650impl From<String> for StaticConfig {
651    fn from(body: String) -> Self {
652        StaticConfig {
653            body,
654            raw: false,
655            metadata: std::collections::HashMap::new(),
656        }
657    }
658}
659
660impl From<&str> for StaticConfig {
661    fn from(body: &str) -> Self {
662        StaticConfig::from(body.to_string())
663    }
664}
665
666impl<'de> Deserialize<'de> for StaticConfig {
667    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
668    where
669        D: serde::Deserializer<'de>,
670    {
671        #[derive(Deserialize)]
672        #[serde(untagged)]
673        enum Repr {
674            Str(String),
675            Map {
676                body: String,
677                #[serde(default)]
678                raw: bool,
679                #[serde(default)]
680                metadata: std::collections::HashMap<String, String>,
681            },
682        }
683        Ok(match Repr::deserialize(deserializer)? {
684            Repr::Str(body) => StaticConfig {
685                body,
686                raw: false,
687                metadata: std::collections::HashMap::new(),
688            },
689            Repr::Map {
690                body,
691                raw,
692                metadata,
693            } => StaticConfig {
694                body,
695                raw,
696                metadata,
697            },
698        })
699    }
700}
701
702/// An enumeration of all supported endpoint types.
703/// `#[serde(rename_all = "lowercase")]` ensures that the keys in the config (e.g., "kafka")
704/// match the enum variants.
705///
706/// # Examples
707///
708/// Configuring a Fanout endpoint in YAML:
709/// ```
710/// use mq_bridge::models::{Endpoint, EndpointType};
711///
712/// let yaml = r#"
713/// fanout:
714///   - memory: { topic: "out1" }
715///   - memory: { topic: "out2" }
716/// "#;
717///
718/// let endpoint: Endpoint = serde_yaml_ng::from_str(yaml).unwrap();
719/// if let EndpointType::Fanout(targets) = endpoint.endpoint_type {
720///     assert_eq!(targets.len(), 2);
721/// }
722/// ```
723#[derive(Debug, Deserialize, Serialize, Clone, Default)]
724#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
725#[serde(rename_all = "lowercase")]
726pub enum EndpointType {
727    Aws(AwsConfig),
728    Kafka(KafkaConfig),
729    Nats(NatsConfig),
730    File(FileConfig),
731    #[serde(rename = "object_store", alias = "objectstore", alias = "s3")]
732    ObjectStore(ObjectStoreConfig),
733    #[cfg_attr(feature = "schema", schemars(extend("format" = "structural_endpoint")))]
734    Static(StaticConfig),
735    #[cfg_attr(feature = "schema", schemars(extend("format" = "structural_endpoint")))]
736    Ref(String),
737    Memory(MemoryConfig),
738    Sled(SledConfig),
739    Amqp(AmqpConfig),
740    MongoDb(MongoDbConfig),
741    Mqtt(MqttConfig),
742    Http(HttpConfig),
743    WebSocket(WebSocketConfig),
744    IbmMq(IbmMqConfig),
745    ZeroMq(ZeroMqConfig),
746    #[serde(rename = "redis_streams", alias = "redis")]
747    RedisStreams(RedisStreamsConfig),
748    Grpc(GrpcConfig),
749    Sqlx(SqlxConfig),
750    #[serde(rename = "clickhouse", alias = "click_house")]
751    ClickHouse(ClickHouseConfig),
752    #[serde(rename = "postgres_cdc", alias = "postgres-cdc")]
753    PostgresCdc(PostgresCdcConfig),
754    #[cfg_attr(feature = "schema", schemars(extend("format" = "structural_endpoint")))]
755    Fanout(Vec<Endpoint>),
756    #[serde(rename = "stream_buffer")]
757    #[cfg_attr(feature = "schema", schemars(extend("format" = "structural_endpoint")))]
758    StreamBuffer(StreamBufferConfig),
759    #[cfg_attr(feature = "schema", schemars(extend("format" = "structural_endpoint")))]
760    Switch(SwitchConfig),
761    #[cfg_attr(feature = "schema", schemars(extend("format" = "structural_endpoint")))]
762    Response(ResponseConfig),
763    #[cfg_attr(feature = "schema", schemars(extend("format" = "structural_endpoint")))]
764    Reader(Box<Endpoint>),
765    #[cfg_attr(feature = "schema", schemars(extend("format" = "structural_endpoint")))]
766    Request(RequestForwardConfig),
767    #[cfg_attr(feature = "schema", schemars(extend("format" = "structural_endpoint")))]
768    Custom {
769        name: String,
770        config: serde_json::Value,
771    },
772    #[default]
773    #[cfg_attr(feature = "schema", schemars(extend("format" = "structural_endpoint")))]
774    Null,
775}
776
777impl EndpointType {
778    pub fn name(&self) -> &'static str {
779        match self {
780            EndpointType::Aws(_) => "aws",
781            EndpointType::Kafka(_) => "kafka",
782            EndpointType::Nats(_) => "nats",
783            EndpointType::File(_) => "file",
784            EndpointType::ObjectStore(_) => "object_store",
785            EndpointType::Static(_) => "static",
786            EndpointType::Ref(_) => "ref",
787            EndpointType::Memory(_) => "memory",
788            EndpointType::Sled(_) => "sled",
789            EndpointType::Amqp(_) => "amqp",
790            EndpointType::MongoDb(_) => "mongodb",
791            EndpointType::Mqtt(_) => "mqtt",
792            EndpointType::Http(_) => "http",
793            EndpointType::WebSocket(_) => "websocket",
794            EndpointType::IbmMq(_) => "ibmmq",
795            EndpointType::ZeroMq(_) => "zeromq",
796            EndpointType::RedisStreams(_) => "redis_streams",
797            EndpointType::Grpc(_) => "grpc",
798            EndpointType::Sqlx(_) => "sqlx",
799            EndpointType::ClickHouse(_) => "clickhouse",
800            EndpointType::PostgresCdc(_) => "postgres_cdc",
801            EndpointType::Fanout(_) => "fanout",
802            EndpointType::StreamBuffer(_) => "stream_buffer",
803            EndpointType::Switch(_) => "switch",
804            EndpointType::Response(_) => "response",
805            EndpointType::Reader(_) => "reader",
806            EndpointType::Request(_) => "request",
807            EndpointType::Custom { .. } => "custom",
808            EndpointType::Null => "null",
809        }
810    }
811
812    pub fn is_core(&self) -> bool {
813        matches!(
814            self,
815            EndpointType::File(_)
816                | EndpointType::Static(_)
817                | EndpointType::Ref(_)
818                | EndpointType::Memory(_)
819                | EndpointType::Fanout(_)
820                | EndpointType::StreamBuffer(_)
821                | EndpointType::Switch(_)
822                | EndpointType::Response(_)
823                | EndpointType::Reader(_)
824                | EndpointType::Request(_)
825                | EndpointType::Custom { .. }
826                | EndpointType::Null
827        )
828    }
829}
830
831/// AEAD cipher selection for [`EncryptionConfig`].
832#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default, PartialEq, Eq)]
833#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
834#[serde(rename_all = "snake_case")]
835pub enum CipherKind {
836    /// XChaCha20-Poly1305 (default): 192-bit random nonce, safe at high message rates.
837    #[default]
838    Xchacha20poly1305,
839    /// AES-256-GCM: 96-bit random nonce; prefer the default for very high volumes.
840    Aes256gcm,
841}
842
843/// AEAD encryption settings, shared by the `encryption` middleware (per-message
844/// payload encryption) and the at-rest `encryption` field of the file and
845/// object_store endpoints. Requires the `encryption` feature.
846#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
847#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
848#[serde(deny_unknown_fields)]
849pub struct EncryptionConfig {
850    /// AEAD cipher. Defaults to `xchacha20poly1305`.
851    #[serde(default)]
852    pub cipher: CipherKind,
853    /// Key identifier written into each envelope; selects the key when decrypting. Defaults to `default`.
854    #[serde(default = "default_encryption_key_id")]
855    pub key_id: String,
856    /// Base64-encoded 32-byte key. Supports `${env:VAR}` to read it from the environment.
857    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
858    pub key: String,
859    /// Extra `key_id -> base64 key` entries accepted when decrypting (key rotation).
860    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
861    #[cfg_attr(feature = "schema", schemars(extend("format" = "password")))]
862    pub decrypt_keys: HashMap<String, String>,
863}
864
865fn default_encryption_key_id() -> String {
866    "default".to_string()
867}
868
869impl Default for EncryptionConfig {
870    fn default() -> Self {
871        Self {
872            cipher: CipherKind::default(),
873            key_id: default_encryption_key_id(),
874            key: String::new(),
875            decrypt_keys: HashMap::new(),
876        }
877    }
878}
879
880/// An enumeration of all supported middleware types.
881#[derive(Debug, Deserialize, Serialize, Clone)]
882#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
883#[serde(rename_all = "snake_case")]
884pub enum Middleware {
885    Deduplication(DeduplicationMiddleware),
886    Metrics(MetricsMiddleware),
887    Dlq(Box<DeadLetterQueueMiddleware>),
888    Retry(RetryMiddleware),
889    RandomPanic(RandomPanicMiddleware),
890    Delay(DelayMiddleware),
891    WeakJoin(WeakJoinMiddleware),
892    Limiter(LimiterMiddleware),
893    Buffer(BufferMiddleware),
894    CookieJar(CookieJarMiddleware),
895    Transform(TransformMiddleware),
896    Encryption(EncryptionConfig),
897    Compression(CompressionMiddleware),
898    Custom {
899        name: String,
900        config: serde_json::Value,
901    },
902}
903
904/// Deduplication middleware configuration.
905///
906/// Prevents duplicate messages from being processed using a sled, MongoDB, or SQL backend.
907/// Messages are identified by their deduplication key and removed after the TTL expires.
908#[derive(Debug, Deserialize, Serialize, Clone)]
909#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
910#[serde(deny_unknown_fields)]
911pub struct DeduplicationMiddleware {
912    /// Store URL: `sled:///path` (local), `mongodb://host/db[/collection]`, or `postgres|mysql|mariadb|sqlite://…[/table]` (shared).
913    #[serde(default)]
914    pub store: Option<String>,
915    /// Local Sled directory (legacy). Prefer `store`.
916    #[serde(default)]
917    pub sled_path: Option<String>,
918    /// Time-to-live for deduplication entries in seconds.
919    pub ttl_seconds: u64,
920}
921
922/// Metrics middleware configuration.
923///
924/// Enables collection and reporting of message processing metrics such as throughput,
925/// latency, and error rates. The presence of this middleware in the configuration
926/// enables metrics collection for the endpoint.
927///
928/// Metrics are typically exported via Prometheus or similar monitoring systems.
929#[derive(Debug, Deserialize, Serialize, Clone)]
930#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
931#[serde(deny_unknown_fields)]
932pub struct MetricsMiddleware {}
933
934/// Dead-Letter Queue (DLQ) middleware configuration.
935///
936/// Routes failed messages to a designated endpoint for later analysis and recovery.
937/// It is recommended to pair this with the Retry middleware to avoid message loss.
938///
939/// Failed messages are sent to the configured endpoint when they are exhausted after retry attempts.
940#[derive(Debug, Deserialize, Serialize, Clone, Default)]
941#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
942#[serde(deny_unknown_fields)]
943pub struct DeadLetterQueueMiddleware {
944    /// The endpoint to send failed messages to.
945    pub endpoint: Endpoint,
946}
947
948/// Retry middleware configuration.
949///
950/// Implements exponential backoff retry logic for failed message processing.
951/// Failed messages are automatically retried with increasing delays between attempts.
952#[derive(Debug, Deserialize, Serialize, Clone, Default)]
953#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
954#[serde(deny_unknown_fields)]
955pub struct RetryMiddleware {
956    /// Maximum number of retry attempts. Defaults to 3.
957    #[serde(default = "default_retry_attempts")]
958    pub max_attempts: usize,
959    /// Initial retry interval in milliseconds. Defaults to 100ms.
960    #[serde(default = "default_initial_interval_ms")]
961    pub initial_interval_ms: u64,
962    /// Maximum retry interval in milliseconds. Defaults to 5000ms.
963    #[serde(default = "default_max_interval_ms")]
964    pub max_interval_ms: u64,
965    /// Multiplier for exponential backoff. Defaults to 2.0.
966    #[serde(default = "default_multiplier")]
967    pub multiplier: f64,
968}
969
970/// Delay middleware configuration.
971///
972/// Introduces a fixed delay before processing each message.
973/// Useful for rate limiting, testing, or allowing time for dependent systems to become ready.
974#[derive(Debug, Deserialize, Serialize, Clone)]
975#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
976#[serde(deny_unknown_fields)]
977pub struct DelayMiddleware {
978    /// Delay duration in milliseconds.
979    pub delay_ms: u64,
980}
981
982/// Throughput limiter middleware configuration.
983///
984/// Applies a best-effort pacing delay so an endpoint does not exceed the configured
985/// message rate. For batch operations the limiter accounts for the number of messages
986/// in the batch, not just the batch count.
987#[derive(Debug, Deserialize, Serialize, Clone)]
988#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
989#[serde(deny_unknown_fields)]
990pub struct LimiterMiddleware {
991    /// Target throughput in messages per second. Must be greater than zero.
992    pub messages_per_second: f64,
993}
994
995/// Publisher-side buffer middleware configuration.
996///
997/// Buffers outbound messages briefly so multiple single-message sends can be
998/// forwarded as one `send_batch` call to the wrapped publisher.
999#[derive(Debug, Deserialize, Serialize, Clone)]
1000#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1001#[serde(deny_unknown_fields)]
1002pub struct BufferMiddleware {
1003    /// Maximum number of messages to accumulate before flushing immediately.
1004    pub max_messages: usize,
1005    /// Maximum time to wait before flushing a non-full buffer.
1006    pub max_delay_ms: u64,
1007}
1008
1009/// Cookie/session jar middleware configuration.
1010///
1011/// Optimized for HTTP by default: it can read `cookie` and `set-cookie` metadata,
1012/// persist session cookies, and inject them into later outgoing requests.
1013///
1014/// The middleware can also capture arbitrary metadata values into the same session store
1015/// and optionally expose stored values back into message metadata.
1016#[derive(Debug, Deserialize, Serialize, Clone)]
1017#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1018#[serde(deny_unknown_fields)]
1019pub struct CookieJarMiddleware {
1020    /// Optional shared scope name. When set, middleware instances using the same scope
1021    /// share one session store across endpoints/routes in the process.
1022    #[serde(default)]
1023    pub shared_scope: Option<String>,
1024    /// Metadata key used to read/write HTTP Cookie headers. Defaults to `cookie`.
1025    #[serde(default = "default_cookie_metadata_key")]
1026    pub cookie_metadata_key: String,
1027    /// Metadata key used to read HTTP Set-Cookie responses. Defaults to `set-cookie`.
1028    #[serde(default = "default_set_cookie_metadata_key")]
1029    pub set_cookie_metadata_key: String,
1030    /// Additional metadata keys to persist into the session value store.
1031    #[serde(default)]
1032    pub capture_metadata_keys: Vec<String>,
1033    /// Optional metadata prefix used to export stored values back onto each message.
1034    ///
1035    /// Exported keys use `PREFIXcookie.<name>` for cookies and `PREFIXvalue.<name>` for
1036    /// captured generic values.
1037    #[serde(default)]
1038    pub export_metadata_prefix: Option<String>,
1039    /// Optional mapping of outgoing metadata keys to stored session value names.
1040    ///
1041    /// Example: `{ "authorization": "access_token" }` copies the stored value
1042    /// `access_token` into outgoing metadata key `authorization` when not already present.
1043    #[serde(default)]
1044    pub inject_metadata: HashMap<String, String>,
1045}
1046
1047impl Default for CookieJarMiddleware {
1048    fn default() -> Self {
1049        Self {
1050            shared_scope: None,
1051            cookie_metadata_key: default_cookie_metadata_key(),
1052            set_cookie_metadata_key: default_set_cookie_metadata_key(),
1053            capture_metadata_keys: Vec::new(),
1054            export_metadata_prefix: None,
1055            inject_metadata: HashMap::new(),
1056        }
1057    }
1058}
1059
1060/// Weak Join middleware configuration.
1061///
1062/// Correlates messages by a metadata key and joins them within a timeout window.
1063/// Count mode (default) waits for `expected_count` messages and emits a JSON array.
1064/// Branch mode (set `branch_by`) waits for named branches and emits a branch-keyed object.
1065#[derive(Debug, Deserialize, Serialize, Clone)]
1066#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1067#[serde(deny_unknown_fields)]
1068pub struct WeakJoinMiddleware {
1069    /// The metadata key to group messages by (e.g., "correlation_id").
1070    pub group_by: String,
1071    /// The number of messages (count mode) or distinct branches (branch mode) to wait for.
1072    pub expected_count: usize,
1073    /// Timeout in milliseconds.
1074    pub timeout_ms: u64,
1075    /// Metadata key naming each message's branch; enables branch mode when set.
1076    #[serde(default)]
1077    pub branch_by: Option<String>,
1078    /// Branch names that must all arrive before firing (branch mode; overrides expected_count).
1079    #[serde(default)]
1080    pub required: Vec<String>,
1081    /// What to do with an incomplete group when the timeout expires.
1082    #[serde(default)]
1083    pub on_timeout: WeakJoinTimeout,
1084}
1085
1086/// Action taken on an incomplete weak-join group when its timeout expires.
1087#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default, PartialEq, Eq)]
1088#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1089#[serde(rename_all = "snake_case")]
1090pub enum WeakJoinTimeout {
1091    /// Emit the partial join (current behavior).
1092    #[default]
1093    Fire,
1094    /// Drop the incomplete group without emitting.
1095    Discard,
1096}
1097
1098/// JSON transform middleware configuration.
1099///
1100/// Reshapes JSON payloads declaratively, in two stages over a single parse: field
1101/// `mapping` (rename/move/nest), then `schema` (type coercion, defaults, validation).
1102/// Either stage may be omitted; with neither configured the message passes through
1103/// untouched and is never parsed.
1104///
1105/// On an output endpoint a rejected message becomes a non-retryable failure, so a `dlq`
1106/// middleware listed *after* this one captures it (publisher middlewares are wrapped in
1107/// list order, so the last entry is the outermost layer). On an input endpoint a rejected
1108/// message is dropped from the batch and acknowledged, which is how invalid input is kept
1109/// out of the route.
1110#[derive(Debug, Deserialize, Serialize, Clone)]
1111#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1112#[cfg_attr(feature = "schema", schemars(transform = transform_middleware_schema_transform))]
1113#[serde(deny_unknown_fields)]
1114pub struct TransformMiddleware {
1115    /// Output field name -> source path (e.g. `firstName: "$.first_name"`). Dots nest the output.
1116    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1117    pub mapping: HashMap<String, MappingRule>,
1118    /// Inline JSON Schema subset (type, properties, required, default, items, nullable, enum).
1119    #[serde(default, skip_serializing_if = "Option::is_none")]
1120    pub schema: Option<serde_json::Value>,
1121    /// Path to a JSON Schema file. Read once at startup; never re-read per message.
1122    #[serde(default, skip_serializing_if = "Option::is_none")]
1123    pub schema_file: Option<String>,
1124    /// Coerce safely convertible types (e.g. `"42"` -> `42`) instead of rejecting. Defaults to true.
1125    #[serde(default = "default_true")]
1126    pub coerce: bool,
1127    /// Insert `default` values from the schema for missing fields. Defaults to true.
1128    #[serde(default = "default_true")]
1129    pub apply_defaults: bool,
1130    /// What to do with a message that fails to transform. Defaults to `reject`.
1131    #[serde(default)]
1132    pub on_error: TransformErrorPolicy,
1133}
1134
1135// Hand-written rather than derived: `coerce` and `apply_defaults` default to *true*, which
1136// a derived `Default` would silently turn into `false`. That would make
1137// `TransformMiddleware { ..Default::default() }` in Rust behave differently from the same
1138// config parsed from YAML.
1139impl Default for TransformMiddleware {
1140    fn default() -> Self {
1141        Self {
1142            mapping: HashMap::new(),
1143            schema: None,
1144            schema_file: None,
1145            coerce: default_true(),
1146            apply_defaults: default_true(),
1147            on_error: TransformErrorPolicy::default(),
1148        }
1149    }
1150}
1151
1152/// How one output field is produced from the input document.
1153///
1154/// Either a bare path string (`"$.first_name"`) or an object with a `path` plus an
1155/// optional `default` and `required` flag.
1156#[derive(Debug, Deserialize, Serialize, Clone)]
1157#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1158#[serde(untagged)]
1159pub enum MappingRule {
1160    /// Shorthand: just the source path.
1161    Path(String),
1162    /// Full form with a fallback value and/or a presence requirement.
1163    Detailed(DetailedMappingRule),
1164}
1165
1166/// Full mapping form with a fallback value and/or a presence requirement.
1167#[derive(Debug, Deserialize, Serialize, Clone)]
1168#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1169#[serde(deny_unknown_fields)]
1170pub struct DetailedMappingRule {
1171    /// Source path in the input document (e.g. `$.user.id`, `user.id`, `$.items[0]`).
1172    pub path: String,
1173    /// Value used when the source path is absent.
1174    #[serde(default, skip_serializing_if = "Option::is_none")]
1175    pub default: Option<serde_json::Value>,
1176    /// Reject the message when the source path is absent and no `default` is set.
1177    #[serde(default)]
1178    pub required: bool,
1179}
1180
1181impl MappingRule {
1182    /// The source path this rule reads from.
1183    pub fn path(&self) -> &str {
1184        match self {
1185            MappingRule::Path(p) => p,
1186            MappingRule::Detailed(d) => &d.path,
1187        }
1188    }
1189}
1190
1191/// Action taken on a message that fails to transform.
1192#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default, PartialEq, Eq)]
1193#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1194#[serde(rename_all = "snake_case")]
1195pub enum TransformErrorPolicy {
1196    /// Reject the message: non-retryable failure on output, dropped from the batch on input.
1197    #[default]
1198    Reject,
1199    /// Forward the original payload unchanged with the error recorded in metadata.
1200    PassThrough,
1201}
1202
1203/// Fault injection modes for testing error handling and recovery mechanisms.
1204#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1205#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1206#[serde(rename_all = "snake_case")]
1207pub enum FaultMode {
1208    /// Trigger a thread panic.
1209    #[default]
1210    Panic,
1211    /// Simulate a connection/network error (retryable).
1212    Disconnect,
1213    /// Simulate a timeout error (retryable).
1214    Timeout,
1215    /// Simulate a JSON format error (non-retryable).
1216    JsonFormatError,
1217    /// Return a negative acknowledgement (for handlers).
1218    Nack,
1219}
1220
1221impl std::fmt::Display for FaultMode {
1222    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1223        match self {
1224            FaultMode::Panic => write!(f, "panic"),
1225            FaultMode::Disconnect => write!(f, "disconnect"),
1226            FaultMode::Timeout => write!(f, "timeout"),
1227            FaultMode::JsonFormatError => write!(f, "json_format_error"),
1228            FaultMode::Nack => write!(f, "nack"),
1229        }
1230    }
1231}
1232
1233/// Middleware for fault injection testing.
1234///
1235/// Allows testing error handling and recovery mechanisms by injecting faults
1236/// at specific points in the message processing pipeline.
1237///
1238/// # Examples
1239///
1240/// ```yaml
1241/// random_panic:
1242///   mode: panic
1243///   trigger_on_message: 3  # Trigger on the 3rd message
1244/// ```
1245#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1246#[serde(deny_unknown_fields)]
1247#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1248pub struct RandomPanicMiddleware {
1249    /// The type of fault to inject.
1250    #[serde(default)]
1251    pub mode: FaultMode,
1252    /// Trigger the fault on the Nth message (1-indexed). None = trigger on every message.
1253    #[cfg_attr(feature = "schema", schemars(range(min = 1)))]
1254    #[serde(default)]
1255    pub trigger_on_message: Option<usize>,
1256    /// Enable/disable the fault injection without removing the configuration.
1257    #[serde(default = "default_true")]
1258    pub enabled: bool,
1259    #[serde(skip, default = "default_atomic_usize_arc")]
1260    #[cfg_attr(feature = "schema", schemars(skip))]
1261    pub message_count: Arc<AtomicUsize>,
1262}
1263
1264fn default_true() -> bool {
1265    true
1266}
1267
1268fn default_atomic_usize_arc() -> Arc<AtomicUsize> {
1269    Arc::new(AtomicUsize::new(0))
1270}
1271
1272fn deserialize_null_as_false<'de, D>(deserializer: D) -> Result<bool, D::Error>
1273where
1274    D: Deserializer<'de>,
1275{
1276    let opt = Option::<bool>::deserialize(deserializer)?;
1277    Ok(opt.unwrap_or(false))
1278}
1279
1280// --- AWS Specific Configuration ---
1281#[derive(Debug, Deserialize, Serialize, Clone, Default)]
1282#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1283#[serde(deny_unknown_fields)]
1284pub struct AwsConfig {
1285    /// The SQS queue URL. Required for Consumer. Optional for Publisher if `topic_arn` is set. If it contains userinfo, it will be treated as a secret.
1286    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
1287    pub queue_url: Option<String>,
1288    /// (Publisher only) The SNS topic ARN.
1289    pub topic_arn: Option<String>,
1290    /// AWS Region (e.g., "us-east-1").
1291    pub region: Option<String>,
1292    /// Custom endpoint URL (e.g., for LocalStack).
1293    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
1294    pub endpoint_url: Option<String>,
1295    /// AWS Access Key ID.
1296    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
1297    pub access_key: Option<String>,
1298    /// AWS Secret Access Key.
1299    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
1300    pub secret_key: Option<String>,
1301    /// AWS Session Token.
1302    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
1303    pub session_token: Option<String>,
1304    /// (Consumer only) Maximum number of messages to receive in a batch (1-10).
1305    #[cfg_attr(feature = "schema", schemars(range(min = 1, max = 10)))]
1306    pub max_messages: Option<i32>,
1307    /// (Consumer only) Wait time for long polling in seconds (0-20).
1308    #[cfg_attr(feature = "schema", schemars(range(min = 0, max = 20)))]
1309    pub wait_time_seconds: Option<i32>,
1310    /// Use binary payloads in SQS/SNS messages.
1311    #[serde(default)]
1312    pub binary_payload_mode: bool,
1313}
1314
1315impl AwsConfig {
1316    /// Creates a new AWS configuration with default settings.
1317    pub fn new() -> Self {
1318        Self::default()
1319    }
1320
1321    pub fn with_queue_url(mut self, queue_url: impl Into<String>) -> Self {
1322        self.queue_url = Some(queue_url.into());
1323        self
1324    }
1325
1326    pub fn with_topic_arn(mut self, topic_arn: impl Into<String>) -> Self {
1327        self.topic_arn = Some(topic_arn.into());
1328        self
1329    }
1330
1331    pub fn with_region(mut self, region: impl Into<String>) -> Self {
1332        self.region = Some(region.into());
1333        self
1334    }
1335
1336    pub fn with_endpoint_url(mut self, endpoint_url: impl Into<String>) -> Self {
1337        self.endpoint_url = Some(endpoint_url.into());
1338        self
1339    }
1340
1341    pub fn with_credentials(
1342        mut self,
1343        access_key: impl Into<String>,
1344        secret_key: impl Into<String>,
1345    ) -> Self {
1346        self.access_key = Some(access_key.into());
1347        self.secret_key = Some(secret_key.into());
1348        self
1349    }
1350}
1351
1352// --- Kafka Specific Configuration ---
1353
1354/// General Kafka connection configuration.
1355#[derive(Debug, Deserialize, Serialize, Clone, Default)]
1356#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1357#[serde(deny_unknown_fields)]
1358pub struct KafkaConfig {
1359    /// Comma-separated list of Kafka broker URLs. If it contains userinfo, it will be treated as a secret.
1360    #[serde(alias = "brokers")]
1361    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
1362    pub url: String,
1363    /// The Kafka topic to produce to or consume from.
1364    pub topic: Option<String>,
1365    /// Optional username for SASL authentication.
1366    pub username: Option<String>,
1367    /// Optional password for SASL authentication.
1368    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
1369    pub password: Option<String>,
1370    /// TLS configuration.
1371    #[serde(default)]
1372    pub tls: TlsConfig,
1373    /// (Consumer only) Consumer group ID.
1374    /// If not provided, the consumer acts in **Subscriber mode**: it generates a unique, ephemeral group ID and starts consuming from the latest offset.
1375    pub group_id: Option<String>,
1376    /// (Publisher only) If true, do not wait for an acknowledgement when sending to broker. Defaults to false.
1377    #[serde(default)]
1378    pub delayed_ack: bool,
1379    /// (Publisher only) Additional librdkafka producer configuration options (key-value pairs).
1380    #[serde(default)]
1381    pub producer_options: Option<Vec<(String, String)>>,
1382    /// (Consumer only) Additional librdkafka consumer configuration options (key-value pairs).
1383    #[serde(default)]
1384    pub consumer_options: Option<Vec<(String, String)>>,
1385    /// (Publisher only) Share one producer per connection (default: true); false gives a dedicated producer.
1386    #[serde(default)]
1387    #[cfg_attr(feature = "schema", schemars(default = "default_shared_schema"))]
1388    pub shared: Option<bool>,
1389    /// (Publisher only) Partition count used when auto-creating the topic (default: 6).
1390    /// Higher values raise write/consume parallelism; ordering is only guaranteed per
1391    /// partition key (message_id), not across the whole topic. Ignored if the topic exists.
1392    #[serde(default)]
1393    #[cfg_attr(
1394        feature = "schema",
1395        schemars(default = "default_kafka_partitions_schema", range(min = 1))
1396    )]
1397    pub partitions: Option<i32>,
1398    /// (Publisher only) Name of a metadata field whose value is used as the Kafka record
1399    /// key (drives partitioning/ordering). Unset, or absent on a given message, falls back
1400    /// to the message id. Default unset.
1401    #[serde(default)]
1402    pub partition_key: Option<String>,
1403}
1404
1405impl KafkaConfig {
1406    /// Creates a new Kafka configuration with the specified broker URL.
1407    pub fn new(url: impl Into<String>) -> Self {
1408        Self {
1409            url: url.into(),
1410            ..Default::default()
1411        }
1412    }
1413
1414    pub fn with_topic(mut self, topic: impl Into<String>) -> Self {
1415        self.topic = Some(topic.into());
1416        self
1417    }
1418
1419    pub fn with_group_id(mut self, group_id: impl Into<String>) -> Self {
1420        self.group_id = Some(group_id.into());
1421        self
1422    }
1423
1424    pub fn with_credentials(
1425        mut self,
1426        username: impl Into<String>,
1427        password: impl Into<String>,
1428    ) -> Self {
1429        self.username = Some(username.into());
1430        self.password = Some(password.into());
1431        self
1432    }
1433
1434    pub fn with_producer_option(
1435        mut self,
1436        key: impl Into<String>,
1437        value: impl Into<String>,
1438    ) -> Self {
1439        let options = self.producer_options.get_or_insert_with(Vec::new);
1440        options.push((key.into(), value.into()));
1441        self
1442    }
1443
1444    pub fn with_consumer_option(
1445        mut self,
1446        key: impl Into<String>,
1447        value: impl Into<String>,
1448    ) -> Self {
1449        let options = self.consumer_options.get_or_insert_with(Vec::new);
1450        options.push((key.into(), value.into()));
1451        self
1452    }
1453}
1454
1455// --- Sled Specific Configuration ---
1456
1457/// General Sled database configuration
1458#[derive(Debug, Deserialize, Serialize, Clone, Default)]
1459#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1460#[serde(deny_unknown_fields)]
1461pub struct SledConfig {
1462    /// Path to the Sled database directory.
1463    pub path: String,
1464    /// The tree name to use as a queue. Defaults to "default".
1465    pub tree: Option<String>,
1466    /// (Consumer only) If true, start reading from the beginning of the tree.
1467    #[serde(default)]
1468    pub read_from_start: bool,
1469    /// (Consumer only) If true, delete messages after processing (Queue mode).
1470    #[serde(default)]
1471    pub delete_after_read: bool,
1472}
1473
1474impl SledConfig {
1475    /// Creates a new Sled configuration with the specified database path.
1476    pub fn new(path: impl Into<String>) -> Self {
1477        Self {
1478            path: path.into(),
1479            ..Default::default()
1480        }
1481    }
1482
1483    pub fn with_tree(mut self, tree: impl Into<String>) -> Self {
1484        self.tree = Some(tree.into());
1485        self
1486    }
1487
1488    pub fn with_read_from_start(mut self, read_from_start: bool) -> Self {
1489        self.read_from_start = read_from_start;
1490        self
1491    }
1492}
1493
1494/// Format for messages written to or read from a file.
1495#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq, Eq)]
1496#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1497#[serde(rename_all = "snake_case")]
1498pub enum FileFormat {
1499    /// The full `CanonicalMessage` is serialized to JSON. Payload is a byte array.
1500    #[default]
1501    Normal,
1502    /// The full `CanonicalMessage` is serialized to JSON. Payload is rendered as a JSON value if possible.
1503    Json,
1504    /// The full `CanonicalMessage` is serialized to JSON. Payload is rendered as a string if possible.
1505    Text,
1506    /// The raw payload of the message is written. For consumers, the line is read as raw bytes.
1507    Raw,
1508    /// CSV rows mapped to/from JSON objects (string values only). The first row is the header/schema.
1509    Csv,
1510}
1511
1512/// Compression algorithm. Used for at-rest batches (file, object_store) and for HTTP
1513/// body compression (http, clickhouse). Orthogonal to `format`.
1514#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default, PartialEq, Eq)]
1515#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1516#[serde(rename_all = "snake_case")]
1517pub enum Compression {
1518    /// No compression (default).
1519    #[default]
1520    None,
1521    /// gzip; each batch is a self-contained member, so files stay readable with `zcat`.
1522    Gzip,
1523    /// lz4 frame format; each batch is a self-contained frame (`lz4 -d` compatible).
1524    Lz4,
1525    /// zstd; each batch is a self-contained frame, concatenated frames decode as one
1526    /// stream (`zstd -d` compatible). Better ratio than lz4, still fast.
1527    Zstd,
1528}
1529
1530fn default_compression_algorithm() -> Compression {
1531    Compression::Zstd
1532}
1533
1534/// Payload-compression middleware configuration.
1535///
1536/// Compresses each message payload on the output side and decompresses it on the input
1537/// side; metadata and routing keys are left untouched. Requires the `compression` feature.
1538/// Distinct from the `file`/`object_store` batch `compression` field, which stays
1539/// CLI-decodable at rest — this operates per message on any transport.
1540#[derive(Debug, Deserialize, Serialize, Clone)]
1541#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1542#[serde(deny_unknown_fields)]
1543pub struct CompressionMiddleware {
1544    /// Algorithm: `none`, `gzip`, `lz4`, or `zstd`. Defaults to `zstd`.
1545    #[serde(default = "default_compression_algorithm")]
1546    pub algorithm: Compression,
1547    /// Reject a decompressed payload larger than this many bytes (decompression-bomb guard).
1548    /// Consumer side only; unset means no limit.
1549    #[serde(default)]
1550    pub max_decompressed_bytes: Option<u64>,
1551}
1552
1553impl Default for CompressionMiddleware {
1554    fn default() -> Self {
1555        Self {
1556            algorithm: default_compression_algorithm(),
1557            max_decompressed_bytes: None,
1558        }
1559    }
1560}
1561
1562// --- File Specific Configuration ---
1563
1564#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1565#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1566pub struct FileConfig {
1567    /// Path to the file.
1568    pub path: String,
1569    /// Optional delimiter for messages. Defaults to newline ("\n").
1570    /// Can be a string or a hex sequence (e.g. "0x00").
1571    /// Currently only single-byte delimiters are supported.
1572    pub delimiter: Option<String>,
1573    /// The consumption mode. If not specified, defaults to `consume`.
1574    /// For publishers, this setting is ignored.
1575    #[serde(flatten, default)]
1576    pub mode: Option<FileConsumerMode>,
1577    /// The format for writing messages to the file (Publisher) or interpreting them (Consumer). Defaults to `normal`.
1578    #[serde(default)]
1579    pub format: FileFormat,
1580    /// Per-batch compression (`none`, `gzip`, `lz4`, `zstd`). Requires the `compression` feature; consume mode only.
1581    #[serde(default)]
1582    pub compression: Compression,
1583    /// At-rest AEAD encryption applied after compression. Requires the `encryption` feature; consume mode only.
1584    #[serde(default)]
1585    pub encryption: Option<EncryptionConfig>,
1586}
1587
1588#[derive(Debug, Clone, Deserialize, Serialize)]
1589#[serde(tag = "mode", rename_all = "snake_case")]
1590#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1591pub enum FileConsumerMode {
1592    /// **Queue Mode**: Standard point-to-point consumption. Reads from the start
1593    /// of the file. If `delete` is true, processed lines are physically removed
1594    /// from the file once they are successfully acknowledged.
1595    Consume {
1596        /// If true, processed lines are physically removed from the file once
1597        /// they are successfully acknowledged.
1598        #[serde(default)]
1599        delete: bool,
1600    },
1601    /// **Broadcast Mode**: Pub-sub style consumption. Tails the file by starting
1602    /// at the current end. If `delete` is true, lines are removed only after
1603    /// all local application subscribers for this specific file have acknowledged them.
1604    Subscribe {
1605        /// If true, lines are removed only after all local application
1606        /// subscribers for this file have acknowledged them.
1607        #[serde(default)]
1608        delete: bool,
1609    },
1610    /// **Persistent Mode**: Consumption with external offset tracking.
1611    /// Saves the last read byte position to a `.offset` file identified by the `group_id`.
1612    /// This allows the consumer to resume exactly where it left off after a restart
1613    /// without deleting data or requiring the bridge to stay running.
1614    GroupSubscribe {
1615        /// The consumer group ID that is used for offset tracking. Should be unique.
1616        group_id: String,
1617        /// If true, starts reading from the end of the file if no offset is stored.
1618        /// If false, starts reading from the beginning.
1619        #[serde(default)]
1620        read_from_tail: bool,
1621    },
1622}
1623
1624impl Default for FileConsumerMode {
1625    fn default() -> Self {
1626        Self::Consume { delete: false }
1627    }
1628}
1629
1630impl FileConfig {
1631    /// Creates a new File configuration with the specified path.
1632    pub fn new(path: impl Into<String>) -> Self {
1633        Self {
1634            path: path.into(),
1635            mode: Some(FileConsumerMode::default()),
1636            delimiter: None,
1637            format: FileFormat::default(),
1638            compression: Compression::default(),
1639            encryption: None,
1640        }
1641    }
1642
1643    pub fn with_mode(mut self, mode: FileConsumerMode) -> Self {
1644        self.mode = Some(mode);
1645        self
1646    }
1647
1648    /// Returns the effective consumer mode, defaulting to `Consume` if not set.
1649    pub fn effective_mode(&self) -> FileConsumerMode {
1650        self.mode.clone().unwrap_or_default()
1651    }
1652}
1653
1654// --- Object Store (S3/GCS/Azure) Specific Configuration ---
1655
1656/// Configuration for a cloud object-store endpoint (S3, GCS, Azure Blob, R2, ...).
1657///
1658/// As a **sink**, each flushed batch is written as one immutable object under `url`,
1659/// named `<prefix>/[YYYY/MM/DD/]<uuidv7>.<ext>`. As a **source**, objects under `url`
1660/// are listed in key order, fetched, split by `delimiter`, and emitted as messages;
1661/// progress is persisted to `checkpoint_store` (the last processed object key) so a
1662/// restart resumes without re-emitting. Objects are never mutated or deleted in place.
1663#[derive(Debug, Clone, Serialize, Deserialize)]
1664#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1665pub struct ObjectStoreConfig {
1666    /// Object-store URL, e.g. `s3://bucket/prefix`, `gs://bucket/prefix`,
1667    /// `az://account/container/prefix`. Credentials are resolved from the environment by
1668    /// the `object_store` crate (same mechanism as the checkpoint backend); R2 uses
1669    /// `s3://` plus a custom `AWS_ENDPOINT_URL`.
1670    pub url: String,
1671    /// Record encoding within an object, shared with the file endpoint. Defaults to
1672    /// `normal` (one JSON `CanonicalMessage` per line). CSV is supported for sources only.
1673    #[serde(default)]
1674    pub format: FileFormat,
1675    /// Record delimiter within an object. Defaults to newline ("\n"). Can be a string or a
1676    /// hex sequence (e.g. "0x00").
1677    pub delimiter: Option<String>,
1678    /// (Source only) Durable resume store URL recording the last processed object key, e.g.
1679    /// `file:///var/lib/mqb/obj.json`, `s3://bucket/cursors`, or `postgres://…`. Without it
1680    /// every restart re-lists and re-emits all objects.
1681    pub checkpoint_store: Option<String>,
1682    /// (Source only) Cursor id namespacing the checkpoint key; enables durable resume.
1683    pub cursor_id: Option<String>,
1684    /// (Source only) Idle poll interval in milliseconds when no new objects are found.
1685    /// Defaults to 1000.
1686    pub polling_interval_ms: Option<u64>,
1687    /// (Source only) Maximum size in bytes of a single object to fetch into memory. An object
1688    /// larger than this fails the read (surfaced as a consumer error) instead of being
1689    /// buffered whole. Unset means no limit (the whole object is materialized).
1690    pub max_object_bytes: Option<u64>,
1691    /// (Sink only) Prepend a `YYYY/MM/DD/` path (write time, UTC) to each object key. Purely
1692    /// for readability / lifecycle rules — the uuidv7 name already sorts by time. Default true.
1693    #[serde(default = "default_true")]
1694    pub date_partition: bool,
1695    /// (Sink only) Extension for written objects, without the dot. Defaults to a value derived
1696    /// from `format`, `compression` and `encryption` (e.g. `jsonl`, `csv`, `bin`, `jsonl.gz`,
1697    /// `jsonl.lz4`, `jsonl.gz.enc`); encrypted objects get a trailing `.enc` since they are
1698    /// ciphertext, not a directly decompressible `.gz`.
1699    pub extension: Option<String>,
1700    /// Whole-object compression (`none`, `gzip`, `lz4`, `zstd`). Requires the `compression` feature.
1701    #[serde(default)]
1702    pub compression: Compression,
1703    /// At-rest AEAD encryption applied after compression. Requires the `encryption` feature.
1704    #[serde(default)]
1705    pub encryption: Option<EncryptionConfig>,
1706}
1707
1708impl Default for ObjectStoreConfig {
1709    fn default() -> Self {
1710        Self {
1711            url: String::new(),
1712            format: FileFormat::default(),
1713            delimiter: None,
1714            checkpoint_store: None,
1715            cursor_id: None,
1716            polling_interval_ms: None,
1717            max_object_bytes: None,
1718            date_partition: true,
1719            extension: None,
1720            compression: Compression::default(),
1721            encryption: None,
1722        }
1723    }
1724}
1725
1726// --- NATS Specific Configuration ---
1727
1728/// General NATS connection configuration.
1729#[derive(Debug, Deserialize, Serialize, Clone, Default)]
1730#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1731#[serde(deny_unknown_fields)]
1732pub struct NatsConfig {
1733    /// Comma-separated list of NATS server URLs (e.g., "nats://localhost:4222,nats://localhost:4223"). If it contains userinfo, it will be treated as a secret.
1734    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
1735    pub url: String,
1736    /// The NATS subject to publish to or subscribe to. If a stream is
1737    /// auto-created, it's scoped to `{stream}.>`, so prefix accordingly.
1738    pub subject: Option<String>,
1739    /// The JetStream stream name. Required for Consumers, even with
1740    /// `no_jetstream: true` (unused there, but still validated).
1741    pub stream: Option<String>,
1742    /// Optional username for authentication.
1743    pub username: Option<String>,
1744    /// Optional password for authentication.
1745    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
1746    pub password: Option<String>,
1747    /// TLS configuration.
1748    #[serde(default)]
1749    pub tls: TlsConfig,
1750    /// Optional token for authentication.
1751    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
1752    pub token: Option<String>,
1753    /// (Publisher only) If true, the publisher uses the request-reply pattern.
1754    /// It sends a request and waits for a response (using `core_client.request_with_headers()`).
1755    /// Defaults to false.
1756    #[serde(default)]
1757    pub request_reply: bool,
1758    /// (Publisher only) Timeout for request-reply operations in milliseconds. Defaults to 30000ms.
1759    pub request_timeout_ms: Option<u64>,
1760    /// (Publisher only) If true, do not wait for an acknowledgement when sending to broker. Defaults to false.
1761    #[serde(default)]
1762    pub delayed_ack: bool,
1763    /// (Publisher only, JetStream) If true, publish a `Nats-Msg-Id` header (from the message id) so
1764    /// JetStream deduplicates redeliveries within the stream's duplicate window. Defaults to false.
1765    #[serde(default)]
1766    pub deduplicate: bool,
1767    /// If no_jetstream: true, use Core NATS (fire-and-forget) instead of JetStream. Defaults to false.
1768    #[serde(default)]
1769    pub no_jetstream: bool,
1770    /// (Consumer only) If true, use ephemeral **Subscriber mode**. Defaults to false (durable consumer).
1771    #[serde(default)]
1772    pub subscriber_mode: bool,
1773    /// (Publisher only) Maximum number of messages in the stream (if created by the bridge). Defaults to 1,000,000.
1774    pub stream_max_messages: Option<i64>,
1775    /// (Consumer only) The delivery policy for the consumer. Defaults to "all".
1776    pub deliver_policy: Option<NatsDeliverPolicy>,
1777    /// (Publisher only) Maximum total bytes in the stream (if created by the bridge). Defaults to 1GB.
1778    pub stream_max_bytes: Option<i64>,
1779    /// (Consumer only) Number of messages to prefetch from the consumer. Defaults to 10000.
1780    pub prefetch_count: Option<usize>,
1781    /// Share one NATS client per connection (default: true); false forces a dedicated connection.
1782    #[serde(default)]
1783    #[cfg_attr(feature = "schema", schemars(default = "default_shared_schema"))]
1784    pub shared: Option<bool>,
1785}
1786
1787#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq, Eq)]
1788#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1789#[serde(rename_all = "snake_case")]
1790pub enum NatsDeliverPolicy {
1791    #[default]
1792    All,
1793    Last,
1794    New,
1795    LastPerSubject,
1796}
1797
1798impl NatsConfig {
1799    /// Creates a new NATS configuration with the specified server URL.
1800    pub fn new(url: impl Into<String>) -> Self {
1801        Self {
1802            url: url.into(),
1803            ..Default::default()
1804        }
1805    }
1806
1807    pub fn with_subject(mut self, subject: impl Into<String>) -> Self {
1808        self.subject = Some(subject.into());
1809        self
1810    }
1811
1812    pub fn with_stream(mut self, stream: impl Into<String>) -> Self {
1813        self.stream = Some(stream.into());
1814        self
1815    }
1816
1817    pub fn with_deliver_policy(mut self, policy: NatsDeliverPolicy) -> Self {
1818        self.deliver_policy = Some(policy);
1819        self
1820    }
1821
1822    pub fn with_credentials(
1823        mut self,
1824        username: impl Into<String>,
1825        password: impl Into<String>,
1826    ) -> Self {
1827        self.username = Some(username.into());
1828        self.password = Some(password.into());
1829        self
1830    }
1831}
1832
1833#[derive(Debug, Serialize, Clone, Default)]
1834#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1835#[cfg_attr(feature = "schema", schemars(transform = memory_config_schema_transform))]
1836#[serde(deny_unknown_fields)]
1837pub struct MemoryConfig {
1838    /// The topic name or transport URL. Can be:
1839    /// - Simple name: "my-topic" (defaults to memory://my-topic)
1840    /// - Memory URL: "memory://my-topic"
1841    /// - IPC URL: "ipc://my-queue" or "ipc:///path/to/socket"
1842    /// - Unix socket: "unix:///path/to/socket" (Unix only)
1843    /// - Named pipe: "pipe://my-pipe" (Windows only)
1844    ///
1845    /// Either `topic` or `url` can be specified (they are serde aliases).
1846    #[serde(default, skip_serializing_if = "String::is_empty", alias = "url")]
1847    pub topic: String,
1848    /// Transport URL (serde alias for `topic`). Use either `topic` or `url`.
1849    #[serde(skip)]
1850    pub url: Option<String>,
1851    /// The capacity of the channel. Defaults to 100.
1852    pub capacity: Option<usize>,
1853    /// (Publisher only) If true, send() waits for a response.
1854    #[serde(default)]
1855    pub request_reply: bool,
1856    /// (Publisher only) Timeout for request-reply operations in milliseconds. Defaults to 30000ms.
1857    pub request_timeout_ms: Option<u64>,
1858    /// (Consumer only) If true, act as a **Subscriber** (fan-out). Defaults to false (queue).
1859    #[serde(default)]
1860    pub subscribe_mode: bool,
1861    /// (Consumer only) If true, enables NACK support (re-queuing), which requires cloning messages.
1862    /// Defaults to false for memory:// transports, automatically true for IPC transports (ipc://, unix://, pipe://).
1863    #[serde(default)]
1864    pub enable_nack: bool,
1865    #[serde(skip)]
1866    pub enable_nack_overridden: bool,
1867}
1868
1869impl MemoryConfig {
1870    pub fn new(topic: impl Into<String>, capacity: Option<usize>) -> Self {
1871        Self {
1872            topic: topic.into(),
1873            url: None,
1874            capacity,
1875            ..Default::default()
1876        }
1877    }
1878
1879    pub fn new_with_url(url: impl Into<String>, capacity: Option<usize>) -> Self {
1880        let url = url.into();
1881        Self {
1882            topic: url.clone(),
1883            url: Some(url),
1884            capacity,
1885            ..Default::default()
1886        }
1887    }
1888
1889    pub fn with_subscribe(self, subscribe_mode: bool) -> Self {
1890        Self {
1891            subscribe_mode,
1892            ..self
1893        }
1894    }
1895
1896    pub fn with_request_reply(mut self, request_reply: bool) -> Self {
1897        self.request_reply = request_reply;
1898        self
1899    }
1900
1901    /// Gets the effective transport identifier.
1902    /// If topic contains ://, it's treated as a URL, otherwise as memory://topic.
1903    pub fn get_transport_identifier(&self) -> anyhow::Result<String> {
1904        let identifier = if !self.topic.is_empty() {
1905            &self.topic
1906        } else if let Some(url) = self.url.as_ref().filter(|url| !url.is_empty()) {
1907            url
1908        } else {
1909            return Err(anyhow::anyhow!(
1910                "MemoryConfig: 'topic' (or 'url' alias) is required."
1911            ));
1912        };
1913
1914        // If topic doesn't contain ://, treat it as memory://topic for backward compatibility
1915        if identifier.contains("://") {
1916            Ok(identifier.clone())
1917        } else {
1918            Ok(format!("memory://{}", identifier))
1919        }
1920    }
1921
1922    /// Check if the transport URL scheme suggests IPC (inter-process communication).
1923    /// IPC transports should enable nack by default for reliability.
1924    pub fn is_ipc_transport(&self) -> bool {
1925        if let Ok(identifier) = self.get_transport_identifier() {
1926            identifier.starts_with("ipc://")
1927                || identifier.starts_with("unix://")
1928                || identifier.starts_with("pipe://")
1929        } else {
1930            false
1931        }
1932    }
1933
1934    /// Apply smart defaults based on the transport type.
1935    /// For IPC transports, enable_nack defaults to true for reliability.
1936    pub fn with_smart_defaults(mut self) -> Self {
1937        if !self.enable_nack_overridden && self.is_ipc_transport() {
1938            self.enable_nack = true;
1939        }
1940        self
1941    }
1942}
1943
1944impl<'de> Deserialize<'de> for MemoryConfig {
1945    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1946    where
1947        D: Deserializer<'de>,
1948    {
1949        #[derive(Deserialize, Default)]
1950        #[serde(deny_unknown_fields)]
1951        struct MemoryConfigSerde {
1952            #[serde(default)]
1953            topic: String,
1954            #[serde(default)]
1955            url: Option<String>,
1956            capacity: Option<usize>,
1957            #[serde(default)]
1958            request_reply: bool,
1959            request_timeout_ms: Option<u64>,
1960            #[serde(default)]
1961            subscribe_mode: bool,
1962            #[serde(default)]
1963            enable_nack: Option<bool>,
1964        }
1965
1966        let raw = MemoryConfigSerde::deserialize(deserializer)?;
1967        if raw.topic.is_empty() && raw.url.as_deref().is_none_or(str::is_empty) {
1968            return Err(serde::de::Error::custom(
1969                "MemoryConfig: 'topic' (or 'url' alias) is required.",
1970            ));
1971        }
1972        let topic = if raw.topic.is_empty() {
1973            raw.url.clone().unwrap_or_default()
1974        } else {
1975            raw.topic
1976        };
1977        Ok(Self {
1978            topic,
1979            url: raw.url,
1980            capacity: raw.capacity,
1981            request_reply: raw.request_reply,
1982            request_timeout_ms: raw.request_timeout_ms,
1983            subscribe_mode: raw.subscribe_mode,
1984            enable_nack: raw.enable_nack.unwrap_or(false),
1985            enable_nack_overridden: raw.enable_nack.is_some(),
1986        })
1987    }
1988}
1989
1990#[cfg(feature = "schema")]
1991fn memory_config_schema_transform(schema: &mut schemars::Schema) {
1992    let Some(schema_obj) = schema.as_object_mut() else {
1993        return;
1994    };
1995
1996    let Some(properties) = schema_obj
1997        .get_mut("properties")
1998        .and_then(serde_json::Value::as_object_mut)
1999    else {
2000        return;
2001    };
2002
2003    properties.insert(
2004        "url".to_string(),
2005        serde_json::json!({
2006            "description": "Alias for `topic`. Use either `topic` or `url`.",
2007            "type": "string",
2008            "minLength": 1
2009        }),
2010    );
2011
2012    // Mirror the runtime check (see `MemoryConfig::deserialize`): an empty
2013    // `topic`/`url` is rejected, so the schema must require a non-empty value.
2014    if let Some(topic) = properties
2015        .get_mut("topic")
2016        .and_then(serde_json::Value::as_object_mut)
2017    {
2018        topic.insert("minLength".to_string(), serde_json::json!(1));
2019    }
2020
2021    schema_obj.insert(
2022        "anyOf".to_string(),
2023        serde_json::json!([
2024            { "required": ["topic"] },
2025            { "required": ["url"] }
2026        ]),
2027    );
2028}
2029
2030#[cfg(feature = "schema")]
2031fn route_schema_transform(schema: &mut schemars::Schema) {
2032    let Some(properties) = schema
2033        .as_object_mut()
2034        .and_then(|schema_obj| schema_obj.get_mut("properties"))
2035        .and_then(serde_json::Value::as_object_mut)
2036    else {
2037        return;
2038    };
2039
2040    // `output: null` (the documented "no output" form) is valid; accept an Endpoint or null.
2041    // Input stays endpoint-only.
2042    if let Some(output) = properties
2043        .get_mut("output")
2044        .and_then(serde_json::Value::as_object_mut)
2045    {
2046        let reference = output.remove("$ref");
2047        let default = output.remove("default");
2048        let description = output.remove("description");
2049        output.clear();
2050        let mut any_of = Vec::new();
2051        if let Some(reference) = reference {
2052            any_of.push(serde_json::json!({ "$ref": reference }));
2053        }
2054        any_of.push(serde_json::json!({ "type": "null" }));
2055        output.insert("anyOf".to_string(), serde_json::Value::Array(any_of));
2056        if let Some(description) = description {
2057            output.insert("description".to_string(), description);
2058        }
2059        if let Some(default) = default {
2060            output.insert("default".to_string(), default);
2061        }
2062    }
2063
2064    let Some(allow_fault_injection) = properties
2065        .get_mut("allow_fault_injection")
2066        .and_then(serde_json::Value::as_object_mut)
2067    else {
2068        return;
2069    };
2070
2071    allow_fault_injection.insert("default".to_string(), serde_json::Value::Bool(false));
2072}
2073
2074/// `schema` and `schema_file` are mutually exclusive; reject a config setting both.
2075#[cfg(feature = "schema")]
2076fn transform_middleware_schema_transform(schema: &mut schemars::Schema) {
2077    if let Some(schema_obj) = schema.as_object_mut() {
2078        // Only reject a non-null `schema_file` alongside `schema`; `schema_file: null`
2079        // is allowed with `schema`, matching the runtime compiler (Option is None).
2080        schema_obj.insert(
2081            "not".to_string(),
2082            serde_json::json!({
2083                "required": ["schema", "schema_file"],
2084                "properties": { "schema_file": { "type": "string" } }
2085            }),
2086        );
2087    }
2088}
2089
2090/// Configuration for the correlated in-process stream response buffer.
2091#[derive(Debug, Serialize, Deserialize, Clone, Default)]
2092#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2093#[serde(deny_unknown_fields)]
2094pub struct StreamBufferConfig {
2095    /// Shared buffer topic used by both the publisher and correlated consumers.
2096    pub topic: String,
2097    /// Consumer-only correlation id partition to read from.
2098    ///
2099    /// Leave this unset for the publisher endpoint configured in
2100    /// `HttpConfig::stream_response_to`. Set it on consumers so a reader only
2101    /// receives messages belonging to one request or response stream.
2102    #[serde(default, skip_serializing_if = "Option::is_none")]
2103    pub correlation_id: Option<String>,
2104    /// Capacity of each correlation partition. Defaults to 100.
2105    #[serde(default, skip_serializing_if = "Option::is_none")]
2106    pub capacity: Option<usize>,
2107}
2108
2109impl StreamBufferConfig {
2110    /// Creates a `stream_buffer` config for the given topic.
2111    ///
2112    /// Add `with_correlation_id` when constructing a consumer for one stream.
2113    /// Leave the correlation id unset when constructing the publisher buffer
2114    /// used by `HttpConfig::stream_response_to`.
2115    pub fn new(topic: impl Into<String>) -> Self {
2116        Self {
2117            topic: topic.into(),
2118            ..Default::default()
2119        }
2120    }
2121
2122    /// Selects the response stream partition that a consumer should read.
2123    pub fn with_correlation_id(mut self, correlation_id: impl Into<String>) -> Self {
2124        self.correlation_id = Some(correlation_id.into());
2125        self
2126    }
2127
2128    /// Sets the per-correlation partition capacity.
2129    pub fn with_capacity(mut self, capacity: usize) -> Self {
2130        self.capacity = Some(capacity);
2131        self
2132    }
2133}
2134
2135// --- AMQP Specific Configuration ---
2136
2137/// General AMQP connection configuration.
2138#[derive(Debug, Deserialize, Serialize, Clone, Default)]
2139#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2140#[serde(deny_unknown_fields)]
2141pub struct AmqpConfig {
2142    /// AMQP connection URI. The `lapin` client connects to a single host specified in the URI. If it contains userinfo, it will be treated as a secret.
2143    /// For high availability, provide the address of a load balancer or use DNS resolution
2144    /// that points to multiple brokers. Example: "amqp://localhost:5672/vhost".
2145    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2146    pub url: String,
2147    /// The AMQP queue name.
2148    pub queue: Option<String>,
2149    /// (Consumer only) If true, act as a **Subscriber** (fan-out). Defaults to false.
2150    #[serde(default)]
2151    pub subscribe_mode: bool,
2152    /// Optional username for authentication.
2153    pub username: Option<String>,
2154    /// Optional password for authentication.
2155    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2156    pub password: Option<String>,
2157    /// TLS configuration.
2158    #[serde(default)]
2159    pub tls: TlsConfig,
2160    /// The exchange to publish to or bind the queue to.
2161    pub exchange: Option<String>,
2162    /// (Consumer only) Number of messages to prefetch. Defaults to 100.
2163    pub prefetch_count: Option<u16>,
2164    /// If true, declare queues as non-durable (transient). Defaults to false. Affects both Consumer (queue durability) and Publisher (message persistence).
2165    #[serde(default)]
2166    pub no_persistence: bool,
2167    /// (Publisher only) If true, do not attempt to declare the queue. Assumes the queue already exists. Defaults to false.
2168    #[serde(default)]
2169    pub no_declare_queue: bool,
2170    /// (Publisher only) If true, do not wait for an acknowledgement when sending to broker. Defaults to false.
2171    #[serde(default)]
2172    pub delayed_ack: bool,
2173}
2174
2175impl AmqpConfig {
2176    /// Creates a new AMQP configuration with the specified connection URL.
2177    pub fn new(url: impl Into<String>) -> Self {
2178        Self {
2179            url: url.into(),
2180            ..Default::default()
2181        }
2182    }
2183
2184    pub fn with_queue(mut self, queue: impl Into<String>) -> Self {
2185        self.queue = Some(queue.into());
2186        self
2187    }
2188
2189    pub fn with_exchange(mut self, exchange: impl Into<String>) -> Self {
2190        self.exchange = Some(exchange.into());
2191        self
2192    }
2193
2194    pub fn with_credentials(
2195        mut self,
2196        username: impl Into<String>,
2197        password: impl Into<String>,
2198    ) -> Self {
2199        self.username = Some(username.into());
2200        self.password = Some(password.into());
2201        self
2202    }
2203}
2204
2205/// MongoDB message storage format.
2206///
2207/// Determines how messages are stored and retrieved from MongoDB collections.
2208#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq, Eq)]
2209#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2210#[serde(rename_all = "lowercase")]
2211pub enum MongoDbFormat {
2212    #[default]
2213    Normal,
2214    Json,
2215    Text,
2216    Raw,
2217}
2218
2219/// How a MongoDB endpoint consumes a collection. One intent-named selector — the bridge picks the
2220/// underlying mechanism (change stream vs. polling) automatically. Defaults to `consumer`.
2221#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default, PartialEq, Eq)]
2222#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2223#[serde(rename_all = "snake_case")]
2224pub enum MongoConsume {
2225    /// **Queue** — competing consumers: claim, process, delete, so each document goes to exactly one
2226    /// reader. Default. Destructive and ~5x slower than `capture_all`; for jobs, not bulk reads.
2227    #[default]
2228    Consumer,
2229    /// **Queue, ephemeral** — receive only new messages, no durable position (fan-out subscriber).
2230    Subscriber,
2231    /// **Watch existing collection** — capture changes from now on (insert/update/delete), resuming
2232    /// under `cursor_id`. Reads an existing collection non-destructively; never ends on drain.
2233    CaptureNew,
2234    /// **Watch existing collection** — read the existing documents first, then capture changes.
2235    /// Non-destructive and the fastest read mode; use this for bulk reads and ETL.
2236    CaptureAll,
2237}
2238
2239// --- MongoDB Specific Configuration ---
2240
2241/// General MongoDB connection configuration.
2242#[derive(Debug, Deserialize, Serialize, Clone, Default)]
2243#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2244#[serde(deny_unknown_fields)]
2245pub struct MongoDbConfig {
2246    /// MongoDB connection string URI. Can contain a comma-separated list of hosts for a replica set. If it contains userinfo, it will be treated as a secret.
2247    /// Credentials provided via the separate `username` and `password` fields take precedence over any credentials embedded in the URL.
2248    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2249    pub url: String,
2250    /// The MongoDB collection name.
2251    pub collection: Option<String>,
2252    /// Optional username. Takes precedence over any credentials embedded in the `url`.
2253    /// Use embedded URL credentials for simple one-off connections but prefer explicit username/password fields (or environment-sourced secrets) for clarity and secret management in production.
2254    pub username: Option<String>,
2255    /// Optional password. Takes precedence over any credentials embedded in the `url`.
2256    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2257    /// Use embedded URL credentials for simple one-off connections but prefer explicit username/password fields (or environment-sourced secrets) for clarity and secret management in production.
2258    pub password: Option<String>,
2259    /// TLS configuration.
2260    #[serde(default)]
2261    pub tls: TlsConfig,
2262    /// The database name.
2263    pub database: String,
2264    /// (Consumer only) Polling interval in milliseconds for the consumer (when not using Change Streams). Defaults to 100ms.
2265    pub polling_interval_ms: Option<u64>,
2266    /// (Publisher only) Polling interval in milliseconds for the publisher when waiting for a reply. Defaults to 50ms.
2267    pub reply_polling_ms: Option<u64>,
2268    /// (Publisher only) If true, the publisher will wait for a response in a dedicated collection. Defaults to false.
2269    #[serde(default)]
2270    pub request_reply: bool,
2271    /// (Consumer only) How to consume the collection: `consumer` (default, competing-consumers work
2272    /// queue — destructive and ~5x slower), `subscriber` (ephemeral queue), `capture_new` (watch an
2273    /// existing collection for changes), or `capture_all` (read existing documents first, then watch
2274    /// for changes — use this for single-reader bulk reads and ETL). The bridge selects the
2275    /// underlying mechanism automatically. If unset, the deprecated `change_stream` boolean is
2276    /// honored for backward compatibility.
2277    pub consume: Option<MongoConsume>,
2278    /// (Consumer only) Optional custom MongoDB query to filter messages. Provided as a JSON string (e.g., '{"type": "notification"}').
2279    pub receive_query: Option<String>,
2280    /// (Consumer only) **Deprecated** — use `consume: subscriber`. Kept for compatibility.
2281    #[serde(default)]
2282    pub change_stream: bool,
2283    /// (Consumer only) Where to persist the resume cursor in `capture_new`/`capture_all` mode. A URL
2284    /// selects the backend; a bare name (or `/name`) reuses the **source** database with that name:
2285    /// - absent → source database, collection `mqb_cursors_<source_collection>` (auto-unique)
2286    /// - `/my_cursors` → source database, collection `my_cursors`
2287    /// - `file:///var/lib/mqb/cursors.json` → local JSON file (read-only / write-restricted sources)
2288    /// - `mongodb://host/db/collection` → external MongoDB collection (collection optional)
2289    /// - `postgres://user@host/db/table` or `mysql://host/db/table` → external SQL table (table optional)
2290    /// - `s3://bucket/prefix` (also `gs://`, `az://`, `abfs://`) → cloud object store; creds via env
2291    ///
2292    /// When no collection/table is named, it defaults to `mqb_cursors_<source_collection>`.
2293    /// May embed connection credentials, so it is treated as a secret.
2294    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2295    pub checkpoint_store: Option<String>,
2296    /// (Publisher only) Timeout for request-reply operations in milliseconds. Defaults to 30000ms.
2297    pub request_timeout_ms: Option<u64>,
2298    /// (Publisher only) TTL in seconds for documents created by the publisher. If set, a TTL index is created.
2299    pub ttl_seconds: Option<u64>,
2300    /// (Publisher only) If set, creates a capped collection with this size in bytes.
2301    pub capped_size_bytes: Option<i64>,
2302    /// Format for storing messages. Defaults to Normal.
2303    #[serde(default)]
2304    pub format: MongoDbFormat,
2305    /// (Publisher only) Top-level payload field whose value becomes the document `_id`, for
2306    /// idempotent inserts via the unique `_id` index. Sink collections only.
2307    pub id_field: Option<String>,
2308    /// (Publisher only) Return the message with metadata `mongodb.outcome` = `inserted`/`existed`
2309    /// (dup-key) so a `request`+`switch` can branch. Sink collections only; pair with `id_field`.
2310    #[serde(default)]
2311    pub report_outcome: bool,
2312    /// The ID used for the cursor in sequenced mode. If not provided, consumption starts from the current sequence (ephemeral).
2313    pub cursor_id: Option<String>,
2314    /// (Optional) Collection to store sequence counters and cursor positions. Defaults to the message collection if not set.
2315    pub meta_collection: Option<String>,
2316    /// Share one MongoDB client per connection (default: true); false forces a dedicated client.
2317    #[serde(default)]
2318    #[cfg_attr(feature = "schema", schemars(default = "default_shared_schema"))]
2319    pub shared: Option<bool>,
2320}
2321
2322impl MongoDbConfig {
2323    /// Creates a new MongoDB configuration with the specified URL and database name.
2324    pub fn new(url: impl Into<String>, database: impl Into<String>) -> Self {
2325        Self {
2326            url: url.into(),
2327            database: database.into(),
2328            ..Default::default()
2329        }
2330    }
2331
2332    pub fn with_collection(mut self, collection: impl Into<String>) -> Self {
2333        self.collection = Some(collection.into());
2334        self
2335    }
2336
2337    pub fn with_credentials(
2338        mut self,
2339        username: impl Into<String>,
2340        password: impl Into<String>,
2341    ) -> Self {
2342        self.username = Some(username.into());
2343        self.password = Some(password.into());
2344        self
2345    }
2346
2347    pub fn with_change_stream(mut self, change_stream: bool) -> Self {
2348        self.change_stream = change_stream;
2349        self
2350    }
2351
2352    /// The effective consume mode: the explicit `consume` field if set, otherwise derived from the
2353    /// deprecated `change_stream` boolean.
2354    pub fn resolved_consume(&self) -> MongoConsume {
2355        if let Some(mode) = self.consume {
2356            return mode;
2357        }
2358        if self.change_stream {
2359            MongoConsume::Subscriber
2360        } else {
2361            MongoConsume::Consumer
2362        }
2363    }
2364}
2365
2366// --- MQTT Specific Configuration ---
2367
2368/// General MQTT connection configuration.
2369#[derive(Debug, Deserialize, Serialize, Clone, Default)]
2370#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2371#[serde(deny_unknown_fields)]
2372pub struct MqttConfig {
2373    /// MQTT broker URL (e.g., "tcp://localhost:1883"). Does not support multiple hosts. If it contains userinfo, it will be treated as a secret.
2374    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2375    pub url: String,
2376    /// The MQTT topic.
2377    pub topic: Option<String>,
2378    /// Optional username for authentication.
2379    pub username: Option<String>,
2380    /// Optional password for authentication.
2381    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2382    pub password: Option<String>,
2383    /// TLS configuration.
2384    #[serde(default)]
2385    pub tls: TlsConfig,
2386    /// Optional client ID. If not provided, one is generated or derived from route name.
2387    pub client_id: Option<String>,
2388    /// Capacity of the internal channel for incoming messages. Defaults to 100.
2389    pub queue_capacity: Option<usize>,
2390    /// Maximum number of inflight messages.
2391    pub max_inflight: Option<u16>,
2392    /// Quality of Service level (0, 1, or 2). Defaults to 1.
2393    pub qos: Option<u8>,
2394    /// (Consumer only) If true, start with a clean session. Defaults to false (persistent session). Setting this to true effectively enables **Subscriber mode** (ephemeral).
2395    #[serde(default = "default_clean_session")]
2396    pub clean_session: bool,
2397    /// Keep-alive interval in seconds. Defaults to 20.
2398    pub keep_alive_seconds: Option<u64>,
2399    /// MQTT protocol version (V3 or V5). Defaults to V5.
2400    #[serde(default)]
2401    pub protocol: MqttProtocol,
2402    /// Session expiry interval in seconds (MQTT v5 only).
2403    pub session_expiry_interval: Option<u32>,
2404    /// (Consumer only) If true, messages are acknowledged immediately upon receipt (auto-ack).
2405    /// If false (default), messages are acknowledged after processing (manual-ack).
2406    /// Note: For QoS 1/2 the publisher always waits for end-to-end broker
2407    /// confirmation (PUBACK/PUBCOMP) before reporting success, independent of
2408    /// this setting; QoS 0 remains fire-and-forget.
2409    #[serde(default)]
2410    pub delayed_ack: bool,
2411}
2412
2413impl MqttConfig {
2414    /// Creates a new MQTT configuration with the specified broker URL.
2415    pub fn new(url: impl Into<String>) -> Self {
2416        Self {
2417            url: url.into(),
2418            ..Default::default()
2419        }
2420    }
2421
2422    pub fn with_topic(mut self, topic: impl Into<String>) -> Self {
2423        self.topic = Some(topic.into());
2424        self
2425    }
2426
2427    pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {
2428        self.client_id = Some(client_id.into());
2429        self
2430    }
2431
2432    pub fn with_credentials(
2433        mut self,
2434        username: impl Into<String>,
2435        password: impl Into<String>,
2436    ) -> Self {
2437        self.username = Some(username.into());
2438        self.password = Some(password.into());
2439        self
2440    }
2441}
2442
2443/// MQTT protocol version.
2444///
2445/// Specifies which version of the MQTT protocol to use for connections.
2446#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
2447#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2448#[serde(rename_all = "lowercase")]
2449pub enum MqttProtocol {
2450    #[default]
2451    V5,
2452    V3,
2453}
2454
2455// --- ZeroMQ Specific Configuration ---
2456
2457#[derive(Debug, Deserialize, Serialize, Clone, Default)]
2458#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2459#[serde(deny_unknown_fields)]
2460pub struct ZeroMqConfig {
2461    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2462    /// The ZeroMQ URL (e.g., "tcp://127.0.0.1:5555").
2463    pub url: String,
2464    /// The socket type (PUSH, PULL, PUB, SUB, REQ, REP).
2465    #[serde(default)]
2466    pub socket_type: Option<ZeroMqSocketType>,
2467    /// (Consumer only) The ZeroMQ topic (for SUB sockets).
2468    pub topic: Option<String>,
2469    /// If true, bind to the address. If false, connect.
2470    #[serde(default)]
2471    pub bind: bool,
2472    /// Internal buffer size for the channel. Defaults to 128.
2473    #[serde(default)]
2474    pub internal_buffer_size: Option<usize>,
2475    /// Wire format: `json` wraps the CanonicalMessage; `raw` sends payload bytes per frame; `raw_framed` adds a JSON metadata frame. Default `json`.
2476    #[serde(default)]
2477    pub format: ZeroMqFormat,
2478    /// Backend: `zmq` (default, the `zeromq` crate) or `omq` (the `omq-tokio` PoC — PUSH/PULL + PUB/SUB only). `omq` needs the `zeromq-omq` build feature.
2479    #[serde(default)]
2480    pub backend: ZeroMqBackend,
2481    /// (REQ publisher only) Timeout in ms for one request/reply exchange before it is reported as failed. Defaults to 30000.
2482    #[serde(default)]
2483    pub request_timeout_ms: Option<u64>,
2484}
2485
2486impl ZeroMqConfig {
2487    /// Creates a new ZeroMQ configuration with the specified URL.
2488    pub fn new(url: impl Into<String>) -> Self {
2489        Self {
2490            url: url.into(),
2491            ..Default::default()
2492        }
2493    }
2494
2495    pub fn with_socket_type(mut self, socket_type: ZeroMqSocketType) -> Self {
2496        self.socket_type = Some(socket_type);
2497        self
2498    }
2499
2500    pub fn with_bind(mut self, bind: bool) -> Self {
2501        self.bind = bind;
2502        self
2503    }
2504}
2505
2506/// ZeroMQ wire format.
2507///
2508/// `json` wraps each message as a JSON CanonicalMessage (batched into one frame);
2509/// `raw` sends/receives the payload bytes directly, one frame per message (metadata
2510/// is not transmitted); `raw_framed` sends a two-frame message — a JSON metadata frame
2511/// followed by the raw payload frame — keeping the payload binary-safe while still
2512/// carrying headers. Use `raw`/`raw_framed` for binary feeds such as JPEG, Avro or Protobuf.
2513#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq, Eq)]
2514#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2515#[serde(rename_all = "snake_case")]
2516pub enum ZeroMqFormat {
2517    #[default]
2518    Json,
2519    Raw,
2520    RawFramed,
2521}
2522
2523/// ZeroMQ socket type.
2524///
2525/// Defines the messaging pattern for ZeroMQ connections.
2526/// Different patterns support different communication paradigms (request-reply, publish-subscribe, etc.).
2527#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
2528#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2529#[serde(rename_all = "lowercase")]
2530pub enum ZeroMqSocketType {
2531    Push,
2532    Pull,
2533    Pub,
2534    Sub,
2535    Req,
2536    Rep,
2537}
2538
2539/// ZeroMQ backend implementation.
2540///
2541/// `zmq` (default) uses the `zeromq` crate (pure-Rust zmq.rs). `omq` uses
2542/// `omq-tokio` (omq.rs) — much faster on the per-message `raw`/`raw_framed`
2543/// path and adds CURVE/PLAIN security, but currently covers PUSH/PULL + PUB/SUB
2544/// only and requires the `zeromq-omq` build feature (MSRV 1.93).
2545#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq, Eq)]
2546#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2547#[serde(rename_all = "lowercase")]
2548pub enum ZeroMqBackend {
2549    #[default]
2550    Zmq,
2551    Omq,
2552}
2553
2554// --- Redis Streams Specific Configuration ---
2555
2556/// Configuration for a Redis Streams endpoint.
2557///
2558/// Publishers `XADD` to the stream; consumers read via a consumer group
2559/// (`XREADGROUP` + `XACK`) by default, or ephemerally via `XREAD` from new
2560/// messages when `subscriber_mode` is set.
2561#[derive(Debug, Deserialize, Serialize, Clone, Default)]
2562#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2563#[serde(deny_unknown_fields)]
2564pub struct RedisStreamsConfig {
2565    /// Redis URL, `redis://` or `rediss://` for TLS. Userinfo is treated as a secret.
2566    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2567    pub url: String,
2568    /// The stream key to publish to or read from. Defaults to the route name.
2569    pub stream: Option<String>,
2570    /// (Consumer) Group name. Defaults to `{APP_NAME}-{stream}`; ignored in `subscriber_mode`.
2571    pub group: Option<String>,
2572    /// (Consumer) Consumer name within the group. Defaults to a unique per-instance id.
2573    pub consumer_name: Option<String>,
2574    /// (Consumer) Read ephemerally via `XREAD` from new messages (no group/acks). Default false.
2575    #[serde(default)]
2576    pub subscriber_mode: bool,
2577    /// (Consumer) Block timeout in milliseconds for each read. Defaults to 5000ms.
2578    pub block_ms: Option<u64>,
2579    /// (Consumer) On group creation, start from the stream beginning ("0") not "$". Default false.
2580    #[serde(default)]
2581    pub read_from_start: bool,
2582    /// (Consumer) Redeliver entries pending ≥ this long via `XAUTOCLAIM`; 0 disables. Default 60000ms.
2583    pub redelivery_timeout_ms: Option<u64>,
2584    /// (Publisher) If set, cap the stream length with `XADD MAXLEN`.
2585    pub maxlen: Option<usize>,
2586    /// (Publisher) Use approximate (`~`) trimming when `maxlen` is set. Defaults to true.
2587    pub approx_trim: Option<bool>,
2588    /// Optional username for authentication (Redis ACL).
2589    pub username: Option<String>,
2590    /// Optional password for authentication.
2591    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2592    pub password: Option<String>,
2593    /// Internal buffer size for the consumer channel. Defaults to 128.
2594    pub internal_buffer_size: Option<usize>,
2595    /// (Consumer) Parallel `XREADGROUP` reader connections fanned out across the group. Default 1.
2596    /// Ignored in `subscriber_mode`.
2597    pub reader_connections: Option<usize>,
2598}
2599
2600impl RedisStreamsConfig {
2601    /// Creates a new Redis Streams configuration with the specified URL.
2602    pub fn new(url: impl Into<String>) -> Self {
2603        Self {
2604            url: url.into(),
2605            ..Default::default()
2606        }
2607    }
2608
2609    pub fn with_stream(mut self, stream: impl Into<String>) -> Self {
2610        self.stream = Some(stream.into());
2611        self
2612    }
2613
2614    pub fn with_group(mut self, group: impl Into<String>) -> Self {
2615        self.group = Some(group.into());
2616        self
2617    }
2618
2619    pub fn with_subscriber(mut self, subscriber: bool) -> Self {
2620        self.subscriber_mode = subscriber;
2621        self
2622    }
2623
2624    pub fn with_reader_connections(mut self, connections: usize) -> Self {
2625        self.reader_connections = Some(connections);
2626        self
2627    }
2628}
2629
2630// --- gRPC Specific Configuration ---
2631
2632#[derive(Debug, Deserialize, Serialize, Clone, Default)]
2633#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2634#[serde(deny_unknown_fields)]
2635pub struct GrpcConfig {
2636    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2637    /// The gRPC server URL (e.g., "http://localhost:50051" for client or "0.0.0.0:50051" for server mode).
2638    pub url: String,
2639    /// Topic / subject used for both subscribe and publish paths.
2640    pub topic: Option<String>,
2641    /// Timeout in milliseconds.
2642    /// - Client mode: used as the connection timeout and per-request deadline.
2643    /// - Server mode: applied as the per-request deadline on the embedded server.
2644    pub timeout_ms: Option<u64>,
2645    /// TLS configuration.
2646    #[serde(default)]
2647    pub tls: TlsConfig,
2648    /// If `true`, start an embedded tonic gRPC server that accepts incoming `Publish` /
2649    /// `PublishBatch` RPCs. If `false` (the default), connect to a remote server as a client.
2650    #[serde(default)]
2651    pub server_mode: bool,
2652    /// HTTP/2 stream-level initial window size in bytes. **Server-mode only.**
2653    #[serde(default)]
2654    pub initial_stream_window_size: Option<u32>,
2655    /// HTTP/2 connection-level initial window size in bytes. **Server-mode only.**
2656    #[serde(default)]
2657    pub initial_connection_window_size: Option<u32>,
2658    /// Maximum number of concurrent requests handled per connection. **Server-mode only.**
2659    #[serde(default)]
2660    pub concurrency_limit_per_connection: Option<usize>,
2661    /// HTTP/2 keepalive ping interval in milliseconds. **Server-mode only.** Default disabled
2662    #[serde(default)]
2663    pub http2_keepalive_interval_ms: Option<u64>,
2664    /// Timeout for a keepalive ping acknowledgement in milliseconds. **Server-mode only.**
2665    #[serde(default)]
2666    pub http2_keepalive_timeout_ms: Option<u64>,
2667    /// Maximum size of a decoded incoming message in bytes. **Server-mode only.** Default 4 MiB.
2668    #[serde(default)]
2669    pub max_decoding_message_size: Option<usize>,
2670    /// (Publisher only) Share one gRPC channel per connection (default: true); false forces a dedicated channel.
2671    #[serde(default)]
2672    #[cfg_attr(feature = "schema", schemars(default = "default_shared_schema"))]
2673    pub shared: Option<bool>,
2674}
2675
2676impl GrpcConfig {
2677    /// Creates a new gRPC configuration with the specified server URL.
2678    pub fn new(url: impl Into<String>) -> Self {
2679        Self {
2680            url: url.into(),
2681            ..Default::default()
2682        }
2683    }
2684
2685    pub fn with_topic(mut self, topic: impl Into<String>) -> Self {
2686        self.topic = Some(topic.into());
2687        self
2688    }
2689
2690    /// Enable or disable server mode for this gRPC endpoint.
2691    pub fn with_server_mode(mut self, server_mode: bool) -> Self {
2692        self.server_mode = server_mode;
2693        self
2694    }
2695}
2696
2697// --- HTTP Specific Configuration ---
2698
2699/// Supported inbound HTTP protocols for server listeners.
2700#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Hash, Default)]
2701#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2702#[serde(rename_all = "snake_case")]
2703pub enum HttpServerProtocol {
2704    /// Accept both HTTP/1.1 and HTTP/2, matching the current default behavior.
2705    #[default]
2706    Auto,
2707    /// Accept only HTTP/1.x connections.
2708    Http1Only,
2709    /// Accept only HTTP/2 connections.
2710    Http2Only,
2711}
2712
2713/// WebSocket route execution strategy.
2714#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default, PartialEq, Eq)]
2715#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2716#[serde(rename_all = "snake_case")]
2717pub enum WebSocketExecutionMode {
2718    /// Use direct per-connection handling for simple `websocket -> response` routes and fall back
2719    /// to the routed adapter with a warning when route semantics need the normal pipeline.
2720    #[default]
2721    Auto,
2722    /// Require direct per-connection handling. Startup fails if the route cannot run directly.
2723    DirectOnly,
2724    /// Always use the normal routed consumer/worker/disposition pipeline.
2725    Routed,
2726}
2727
2728/// General HTTP connection configuration.
2729#[derive(Debug, Deserialize, Serialize, Clone, Default)]
2730#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2731#[serde(deny_unknown_fields)]
2732pub struct HttpConfig {
2733    /// For consumers, the listen address (e.g., "0.0.0.0:8080"). For publishers, the target URL.
2734    pub url: String,
2735    /// (Consumer only) Optional request path filter. If set, only requests whose URI path matches exactly are delivered to this consumer.
2736    pub path: Option<String>,
2737    /// (Optional) HTTP method. For publishers: the method to use (defaults to POST). For consumers: restrict to this method (others return 405).
2738    pub method: Option<String>,
2739    /// TLS configuration.
2740    #[serde(default)]
2741    pub tls: TlsConfig,
2742    /// (Consumer only) Number of worker threads to use. Defaults to 0 for unlimited.
2743    pub workers: Option<usize>,
2744    /// (Consumer only) Header key to extract the message ID from. Defaults to "message-id".
2745    pub message_id_header: Option<String>,
2746    /// Timeout for HTTP requests in milliseconds. For consumers, it's the request-reply timeout. For publishers, it's the timeout for each individual request. Defaults to 30000ms.
2747    pub request_timeout_ms: Option<u64>,
2748    /// (Consumer only) Internal buffer size for the channel. Defaults to 100.
2749    pub internal_buffer_size: Option<usize>,
2750    /// (Consumer only) If true, respond immediately with 202 Accepted without waiting for downstream processing. Defaults to false.
2751    #[serde(default)]
2752    pub fire_and_forget: bool,
2753    /// (Consumer only) If true, read request bodies as a stream and emit each received stream item as a separate message.
2754    #[serde(default)]
2755    pub receive_streamable: bool,
2756    /// (Consumer only) If true, compatible `http -> response` routes may bypass the normal route consumer/worker/disposition pipeline
2757    /// and reply inline for lower latency. Defaults to true. Set to false to force the normal route path.
2758    #[serde(default, skip_serializing_if = "Option::is_none")]
2759    #[cfg_attr(
2760        feature = "schema",
2761        schemars(default = "default_inline_response_fast_path_schema")
2762    )]
2763    pub inline_response_fast_path: Option<bool>,
2764    /// (Consumer only) Restrict which HTTP protocol versions a server listener accepts.
2765    /// Defaults to `auto` (HTTP/1.1 + HTTP/2). On cleartext listeners, `http2_only`
2766    /// means HTTP/2 prior-knowledge (h2c) only.
2767    #[serde(default)]
2768    pub server_protocol: HttpServerProtocol,
2769    /// (Publisher only) Optional endpoint that receives streamed HTTP response items as correlated messages.
2770    ///
2771    /// Use a `stream_buffer` endpoint here when callers need to read streamed
2772    /// response items later through a normal mq-bridge consumer. Each streamed
2773    /// item is published with `correlation_id`, `http_stream_id`,
2774    /// `http_stream_index`, `http_stream_format`, and `http_stream_end`
2775    /// metadata. If the request message has no `correlation_id`, the HTTP
2776    /// publisher uses `format!("{:032x}", request.message_id)` so callers can
2777    /// derive the consumer correlation id before calling `send`.
2778    #[serde(default, skip_serializing_if = "Option::is_none")]
2779    pub stream_response_to: Option<Box<Endpoint>>,
2780    /// (Publisher only) The number of concurrent HTTP requests to send in a batch. Defaults to 20.
2781    #[serde(default, skip_serializing_if = "Option::is_none")]
2782    pub batch_concurrency: Option<usize>,
2783    /// (Publisher only) TCP keepalive timeout for the underlying connection pool in milliseconds. Defaults to 60000ms.
2784    #[serde(default, skip_serializing_if = "Option::is_none")]
2785    pub tcp_keepalive_ms: Option<u64>,
2786    /// (Publisher only) Timeout for idle connections in the connection pool in milliseconds. Defaults to 90000ms.
2787    #[serde(default, skip_serializing_if = "Option::is_none")]
2788    pub pool_idle_timeout_ms: Option<u64>,
2789    /// (Publisher only) Codec for the request body (`none`, `gzip`, `lz4`, `zstd`); overrides
2790    /// `compression_enabled`. `lz4` is non-standard (mq-bridge peers only). Ignored on a consumer —
2791    /// enable response compression with `compression_enabled`. Defaults to `none`.
2792    #[serde(default)]
2793    pub compression: Compression,
2794    /// Turns compression on. Publisher: compress the request body with gzip (unless `compression`
2795    /// sets another codec). Consumer: compress responses, negotiating the best codec the client's
2796    /// `Accept-Encoding` accepts. Defaults to off.
2797    #[serde(default, skip_serializing_if = "Option::is_none")]
2798    pub compression_enabled: Option<bool>,
2799    /// Minimum message size in bytes to compress. Messages smaller than this are sent uncompressed. Defaults to 1024 bytes.
2800    #[serde(default)]
2801    pub compression_threshold_bytes: Option<usize>,
2802    /// (Consumer only) Maximum number of concurrent requests to handle. Defaults to 100.
2803    pub concurrency_limit: Option<usize>,
2804    /// HTTP Basic Authentication credentials (username, password). For consumers: validates incoming requests. For publishers: adds Authorization header.
2805    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2806    #[serde(
2807        default,
2808        skip_serializing_if = "Option::is_none",
2809        deserialize_with = "deserialize_basic_auth"
2810    )]
2811    pub basic_auth: Option<(String, String)>,
2812    /// Custom headers as key-value pairs (e.g., {"X-API-Key": "token123"}). Added to outgoing HTTP headers for both consumers and publishers.
2813    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2814    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2815    pub custom_headers: HashMap<String, String>,
2816    /// (Publisher only) Share one HTTP client per connection (default: true); false forces a dedicated client.
2817    #[serde(default)]
2818    #[cfg_attr(feature = "schema", schemars(default = "default_shared_schema"))]
2819    pub shared: Option<bool>,
2820}
2821
2822/// WebSocket connection configuration.
2823#[derive(Debug, Deserialize, Serialize, Clone, Default)]
2824#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2825#[serde(deny_unknown_fields)]
2826pub struct WebSocketConfig {
2827    /// For consumers, the listen address (e.g. "0.0.0.0:9000"). For publishers, the target URL.
2828    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2829    pub url: String,
2830    /// (Consumer only) Optional request path filter. If set, only upgrade requests whose URI path matches exactly are delivered to this consumer.
2831    pub path: Option<String>,
2832    /// (Consumer only) Header key to extract the message ID from the WebSocket handshake. Defaults to "message-id".
2833    pub message_id_header: Option<String>,
2834    /// (Consumer only) Queue capacity for the routed adapter. Direct response routes do not use this queue. Defaults to 100.
2835    pub routed_queue_capacity: Option<usize>,
2836    /// (Consumer only) TCP listen backlog (pending-connection queue depth) for the accept socket.
2837    /// Raise this if high-concurrency handshake bursts are being dropped/reset before `accept()`
2838    /// can keep up. Defaults to 4096, which is higher than the OS/tokio default of 1024.
2839    pub backlog: Option<u32>,
2840    /// (Consumer only) Selects whether WebSocket routes run directly or through the routed pipeline.
2841    #[serde(default)]
2842    pub execution_mode: WebSocketExecutionMode,
2843}
2844
2845fn deserialize_basic_auth<'de, D>(deserializer: D) -> Result<Option<(String, String)>, D::Error>
2846where
2847    D: Deserializer<'de>,
2848{
2849    let val = serde_json::Value::deserialize(deserializer)?;
2850    match val {
2851        serde_json::Value::Null => Ok(None),
2852        serde_json::Value::Array(arr) => {
2853            if arr.len() != 2 {
2854                return Err(serde::de::Error::custom("basic_auth must have 2 elements"));
2855            }
2856            let u = arr[0]
2857                .as_str()
2858                .ok_or_else(|| serde::de::Error::custom("basic_auth[0] must be string"))?
2859                .to_string();
2860            let p = arr[1]
2861                .as_str()
2862                .ok_or_else(|| serde::de::Error::custom("basic_auth[1] must be string"))?
2863                .to_string();
2864            Ok(Some((u, p)))
2865        }
2866        serde_json::Value::Object(map) => {
2867            let u = map
2868                .get("0")
2869                .and_then(|v| v.as_str())
2870                .ok_or_else(|| serde::de::Error::custom("basic_auth map missing '0'"))?
2871                .to_string();
2872            let p = map
2873                .get("1")
2874                .and_then(|v| v.as_str())
2875                .ok_or_else(|| serde::de::Error::custom("basic_auth map missing '1'"))?
2876                .to_string();
2877            Ok(Some((u, p)))
2878        }
2879        _ => Err(serde::de::Error::custom("invalid type for basic_auth")),
2880    }
2881}
2882
2883impl HttpConfig {
2884    /// Creates a new HTTP configuration with the specified URL.
2885    pub fn new(url: impl Into<String>) -> Self {
2886        Self {
2887            url: url.into(),
2888            ..Default::default()
2889        }
2890    }
2891
2892    pub fn with_workers(mut self, workers: usize) -> Self {
2893        self.workers = Some(workers);
2894        self
2895    }
2896
2897    pub fn with_method(mut self, method: impl Into<String>) -> Self {
2898        self.method = Some(method.into());
2899        self
2900    }
2901
2902    pub fn with_path(mut self, path: impl Into<String>) -> Self {
2903        self.path = Some(path.into());
2904        self
2905    }
2906
2907    pub fn with_receive_streamable(mut self, receive_streamable: bool) -> Self {
2908        self.receive_streamable = receive_streamable;
2909        self
2910    }
2911
2912    pub fn with_inline_response_fast_path(mut self, inline_response_fast_path: bool) -> Self {
2913        self.inline_response_fast_path = Some(inline_response_fast_path);
2914        self
2915    }
2916
2917    pub fn with_server_protocol(mut self, server_protocol: HttpServerProtocol) -> Self {
2918        self.server_protocol = server_protocol;
2919        self
2920    }
2921
2922    pub fn inline_response_fast_path_enabled(&self) -> bool {
2923        self.inline_response_fast_path.unwrap_or(true)
2924    }
2925
2926    /// Request-body codec for a publisher: explicit `compression`, else gzip when
2927    /// `compression_enabled`, else none.
2928    pub fn publisher_compression(&self) -> Compression {
2929        match self.compression {
2930            Compression::None if self.compression_enabled == Some(true) => Compression::Gzip,
2931            other => other,
2932        }
2933    }
2934
2935    /// Whether a consumer compresses responses (then it negotiates the best codec the client
2936    /// accepts). Driven by `compression_enabled`; the publisher-only `compression` codec is ignored.
2937    pub fn consumer_compression_enabled(&self) -> bool {
2938        self.compression_enabled == Some(true)
2939    }
2940
2941    pub fn with_stream_response_to(mut self, endpoint: Endpoint) -> Self {
2942        self.stream_response_to = Some(Box::new(endpoint));
2943        self
2944    }
2945}
2946
2947impl WebSocketConfig {
2948    /// Creates a new WebSocket configuration with the specified URL.
2949    pub fn new(url: impl Into<String>) -> Self {
2950        Self {
2951            url: url.into(),
2952            ..Default::default()
2953        }
2954    }
2955
2956    pub fn with_path(mut self, path: impl Into<String>) -> Self {
2957        self.path = Some(path.into());
2958        self
2959    }
2960
2961    pub fn with_backlog(mut self, backlog: u32) -> Self {
2962        self.backlog = Some(backlog);
2963        self
2964    }
2965
2966    pub fn with_execution_mode(mut self, execution_mode: WebSocketExecutionMode) -> Self {
2967        self.execution_mode = execution_mode;
2968        self
2969    }
2970}
2971
2972// --- IBM MQ Specific Configuration ---
2973
2974/// TLS configuration for the IBM MQ native client.
2975///
2976/// The IBM MQ client doesn't consume PEM files, so this uses MQ-native field
2977/// names rather than the generic [`TlsConfig`] used by the other endpoints.
2978#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq, Eq, Hash)]
2979#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2980#[cfg_attr(feature = "schema", schemars(transform = ibm_tls_config_schema_transform))]
2981#[serde(deny_unknown_fields)]
2982pub struct IbmTlsConfig {
2983    /// If true, enable TLS/SSL.
2984    #[serde(default, deserialize_with = "deserialize_null_as_false")]
2985    pub required: bool,
2986    /// TLS CipherSpec (e.g., `ANY_TLS12`). Required for encrypted connections. IBM MQ-specific.
2987    pub cipher_spec: Option<String>,
2988    /// For IBM MQ this is the CMS key repository stem (e.g. `/path/to/tls` for `tls.kdb`/`tls.sth`),
2989    /// not a PEM file. Exposed as `cert_file` for config parity with the generic `TlsConfig`;
2990    /// the MQ-native name `key_repository` is still accepted.
2991    #[serde(rename = "cert_file", alias = "key_repository")]
2992    pub key_repository: Option<String>,
2993    /// Password unlocking the key repository. Requires an IBM MQ client/server at 9.3.0.0+.
2994    /// Exposed as `cert_password` for parity with `TlsConfig`; alias `key_repository_password`.
2995    #[serde(rename = "cert_password", alias = "key_repository_password")]
2996    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
2997    pub key_repository_password: Option<String>,
2998    /// If true, disable server certificate verification (insecure).
2999    #[serde(default)]
3000    pub accept_invalid_certs: bool,
3001}
3002
3003// schemars ignores serde `alias`, so the MQ-native names accepted at runtime
3004// (`key_repository`, `key_repository_password`) must be added to the schema by
3005// hand, otherwise `additionalProperties: false` rejects otherwise-valid configs.
3006#[cfg(feature = "schema")]
3007fn ibm_tls_config_schema_transform(schema: &mut schemars::Schema) {
3008    let Some(properties) = schema
3009        .as_object_mut()
3010        .and_then(|schema_obj| schema_obj.get_mut("properties"))
3011        .and_then(serde_json::Value::as_object_mut)
3012    else {
3013        return;
3014    };
3015
3016    properties.insert(
3017        "key_repository".to_string(),
3018        serde_json::json!({
3019            "description": "MQ-native alias for `cert_file`: the CMS key repository stem \
3020                (e.g. `/path/to/tls` for `tls.kdb`/`tls.sth`).",
3021            "type": ["string", "null"]
3022        }),
3023    );
3024
3025    properties.insert(
3026        "key_repository_password".to_string(),
3027        serde_json::json!({
3028            "description": "MQ-native alias for `cert_password`: password unlocking the key \
3029                repository. Requires an IBM MQ client/server at 9.3.0.0+.",
3030            "type": ["string", "null"],
3031            "format": "password"
3032        }),
3033    );
3034}
3035
3036/// Connection settings for the IBM MQ Queue Manager.
3037// Default is implemented manually (not derived): the numeric fields must match
3038// the serde defaults, otherwise `IbmMqConfig::new()` / `..Default::default()`
3039// would yield max_message_size=0 (zero-length receive buffer) and wait_timeout=0.
3040#[derive(Debug, Deserialize, Serialize, Clone)]
3041#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3042#[serde(deny_unknown_fields)]
3043pub struct IbmMqConfig {
3044    /// Required. Connection URL in `host(port)` format. Supports comma-separated list for failover (e.g., `host1(1414),host2(1414)`). If it contains userinfo, it will be treated as a secret.
3045    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
3046    pub url: String,
3047    /// Target Queue name for point-to-point messaging. Optional if `topic` is set; defaults to route name if omitted.
3048    pub queue: Option<String>,
3049    /// Target Topic string for Publish/Subscribe. If set, enables **Subscriber mode** (Consumer) or publishes to a topic (Publisher). Optional if `queue` is set.
3050    pub topic: Option<String>,
3051    /// Required. Name of the Queue Manager to connect to (e.g., `QM1`).
3052    pub queue_manager: String,
3053    /// Required. Server Connection (SVRCONN) Channel name defined on the QM.
3054    pub channel: String,
3055    /// Username for authentication. Optional; required if the channel enforces authentication
3056    pub username: Option<String>,
3057    /// Password for authentication. Optional; required if the channel enforces authentication.
3058    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
3059    pub password: Option<String>,
3060    /// TLS configuration settings (e.g., keystore paths). Optional.
3061    #[serde(default)]
3062    pub tls: IbmTlsConfig,
3063    /// Maximum message size in bytes (default: 4MB). Optional.
3064    #[serde(default = "default_max_message_size")]
3065    pub max_message_size: usize,
3066    /// (Consumer only) Polling timeout in milliseconds (default: 1000ms). Optional.
3067    #[serde(default = "default_wait_timeout_ms")]
3068    pub wait_timeout_ms: i32,
3069    /// Internal buffer size for the channel. Defaults to 100.
3070    #[serde(default)]
3071    pub internal_buffer_size: Option<usize>,
3072    /// If false, attempt to open the queue with INQUIRE permissions to fetch queue depth for status checks. Defaults to false.
3073    #[serde(default)]
3074    pub disable_status_inq: bool,
3075}
3076
3077impl IbmMqConfig {
3078    /// Creates a new IBM MQ configuration with the specified connection URL, queue manager, and channel.
3079    pub fn new(
3080        url: impl Into<String>,
3081        queue_manager: impl Into<String>,
3082        channel: impl Into<String>,
3083    ) -> Self {
3084        Self {
3085            url: url.into(),
3086            queue_manager: queue_manager.into(),
3087            channel: channel.into(),
3088            disable_status_inq: false,
3089            ..Default::default()
3090        }
3091    }
3092
3093    pub fn with_queue(mut self, queue: impl Into<String>) -> Self {
3094        self.queue = Some(queue.into());
3095        self
3096    }
3097
3098    pub fn with_topic(mut self, topic: impl Into<String>) -> Self {
3099        self.topic = Some(topic.into());
3100        self
3101    }
3102
3103    pub fn with_credentials(
3104        mut self,
3105        username: impl Into<String>,
3106        password: impl Into<String>,
3107    ) -> Self {
3108        self.username = Some(username.into());
3109        self.password = Some(password.into());
3110        self
3111    }
3112}
3113
3114impl Default for IbmMqConfig {
3115    fn default() -> Self {
3116        Self {
3117            url: String::new(),
3118            queue: None,
3119            topic: None,
3120            queue_manager: String::new(),
3121            channel: String::new(),
3122            username: None,
3123            password: None,
3124            tls: IbmTlsConfig::default(),
3125            max_message_size: default_max_message_size(),
3126            wait_timeout_ms: default_wait_timeout_ms(),
3127            internal_buffer_size: None,
3128            disable_status_inq: false,
3129        }
3130    }
3131}
3132
3133fn default_max_message_size() -> usize {
3134    4 * 1024 * 1024 // 4MB default
3135}
3136
3137fn default_wait_timeout_ms() -> i32 {
3138    1000 // 1 second default
3139}
3140
3141// --- Switch/Router Configuration ---
3142
3143#[derive(Debug, Deserialize, Serialize, Clone)]
3144#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3145#[serde(deny_unknown_fields)]
3146pub struct SwitchConfig {
3147    /// The metadata key to inspect for routing decisions.
3148    pub metadata_key: String,
3149    /// A map of values to endpoints.
3150    pub cases: HashMap<String, Endpoint>,
3151    /// The default endpoint if no case matches.
3152    pub default: Option<Box<Endpoint>>,
3153}
3154
3155// --- Response Endpoint Configuration ---
3156#[derive(Debug, Deserialize, Serialize, Clone, Default)]
3157#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3158#[serde(deny_unknown_fields)]
3159pub struct ResponseConfig {
3160    // This struct is a marker and currently has no fields.
3161}
3162
3163// --- Request/Forward Endpoint Configuration ---
3164
3165/// Sends each message to a request-capable endpoint and forwards its response elsewhere.
3166///
3167/// Turns a request/reply exchange (HTTP, or a request_reply NATS/Mongo/Memory endpoint) into
3168/// a one-way flow whose response lands on `forward_to` — e.g. IBM MQ → HTTP → IBM MQ. On
3169/// request error/timeout the original message is forwarded instead (unchanged). Successful
3170/// responses carry the transport-native status (e.g. `http_status_code`), so a `switch` on
3171/// `forward_to` can route them by status; a failed request forwards the original message with
3172/// no status key, so catch failures on the switch's default branch.
3173#[derive(Debug, Deserialize, Serialize, Clone)]
3174#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3175#[serde(deny_unknown_fields)]
3176pub struct RequestForwardConfig {
3177    /// The request-capable endpoint to send each message to (e.g. an `http` client).
3178    pub to: Box<Endpoint>,
3179    /// Where the response (or, on error, the original message) is forwarded.
3180    pub forward_to: Box<Endpoint>,
3181}
3182
3183// --- Postgres CDC (logical replication) Configuration ---
3184
3185/// Postgres logical-replication CDC source (pgoutput). Source-only.
3186#[derive(Debug, Deserialize, Serialize, Clone, Default)]
3187#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3188#[serde(deny_unknown_fields)]
3189pub struct PostgresCdcConfig {
3190    /// Connection URL, e.g. `postgres://user:pass@host:5432/dbname`.
3191    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
3192    pub url: String,
3193    /// Publication name (must already exist; defines which tables are captured).
3194    pub publication: String,
3195    /// Replication slot name; created if missing when `create_slot` is true.
3196    #[serde(default = "default_pg_cdc_slot")]
3197    pub slot_name: String,
3198    /// Create the replication slot if it does not exist.
3199    #[serde(default = "default_true")]
3200    pub create_slot: bool,
3201    /// Create the `publication` if missing (default false; leave off if it pre-exists).
3202    /// Needs table ownership for `publication_tables`, or superuser when none are set (`FOR ALL TABLES`).
3203    #[serde(default)]
3204    pub create_publication: bool,
3205    /// Tables to include when managing the publication (`create_publication`); may be `schema.table`.
3206    /// Missing ones are added to an existing publication (never removed). Empty = `FOR ALL TABLES` (needs superuser).
3207    #[serde(default)]
3208    pub publication_tables: Vec<String>,
3209    /// Use a temporary slot (dropped on disconnect). Not restart-safe; default is a permanent slot.
3210    #[serde(default)]
3211    pub temporary_slot: bool,
3212    /// Checkpoint key for persisting the confirmed LSN across restarts (optional; the slot is authoritative).
3213    pub cursor_id: Option<String>,
3214    /// Checkpoint store spec (e.g. `file:///path`, `s3://bucket/prefix`); defaults to the source database.
3215    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
3216    pub checkpoint_store: Option<String>,
3217    /// Standby-status-update interval in ms; must be shorter than the server's `wal_sender_timeout`.
3218    #[serde(default = "default_pg_cdc_status_interval_ms")]
3219    pub status_interval_ms: u64,
3220    /// TLS configuration for the replication connection.
3221    #[serde(default)]
3222    pub tls: TlsConfig,
3223}
3224
3225fn default_pg_cdc_slot() -> String {
3226    "mq_bridge_slot".to_string()
3227}
3228
3229fn default_pg_cdc_status_interval_ms() -> u64 {
3230    10_000
3231}
3232
3233// --- SQLx Specific Configuration ---
3234
3235/// General SQLx connection configuration.
3236#[derive(Debug, Deserialize, Serialize, Clone, Default)]
3237#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3238#[serde(deny_unknown_fields)]
3239pub struct SqlxConfig {
3240    /// Database connection URL. If it contains userinfo, it will be treated as a secret.
3241    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
3242    pub url: String,
3243    /// Optional username. Takes precedence over any credentials embedded in the `url`.
3244    #[serde(default)]
3245    pub username: Option<String>,
3246    /// Optional password. Takes precedence over any credentials embedded in the `url`.
3247    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
3248    #[serde(default)]
3249    pub password: Option<String>,
3250    /// The table to interact with.
3251    pub table: String,
3252    /// (Publisher only) Optional. A custom SQL INSERT query. Use `?` as a placeholder for the payload.
3253    /// If not provided, a default `INSERT INTO {table} (payload) VALUES (?)` is used.
3254    ///
3255    /// For multi-column inserts, embed explicit source tokens directly in the query:
3256    /// `${metadata:<key>}` binds `message.metadata["<key>"]`, and `${payload:<field>}`
3257    /// binds the top-level JSON field `<field>` of the payload (types preserved:
3258    /// numbers/bools stay numeric/bool). There is no fallback between the two: an
3259    /// absent metadata key, non-JSON payload, or missing/non-scalar field binds SQL NULL.
3260    /// Example: `INSERT INTO orders (customer_id, sku, qty) VALUES (${metadata:customer_id}, ${payload:sku}, ${payload:qty})`.
3261    /// A query with no `${...}` tokens behaves exactly as before (whole payload bound once).
3262    /// `auto_create_table` is not supported together with a token-based query.
3263    ///
3264    /// Tokens bind as text/number/bool; Postgres won't implicitly cast text into a
3265    /// `numeric`/`timestamptz` column (these arrive as JSON strings from a sql source).
3266    /// Add an explicit cast next to the token — it is preserved verbatim in the SQL:
3267    /// `VALUES (${payload:amount}::numeric, ${payload:created_at}::timestamptz)`.
3268    pub insert_query: Option<String>,
3269    /// (Consumer only) Optional. A custom SQL SELECT query to fetch messages. This is only supported for PostgreSQL and Microsoft SQL Server.
3270    /// The query must include a placeholder for the batch size (`$1` for PostgreSQL, `@p1` for SQL Server).
3271    /// The bridge will bind the route's `batch_size` to this placeholder.
3272    pub select_query: Option<String>,
3273    /// (Consumer only) If true, delete messages after processing.
3274    #[serde(default)]
3275    pub delete_after_read: bool,
3276    /// (Consumer only) Read an existing table **non-destructively** and resumably, paging by this
3277    /// monotonic column (`SELECT * FROM {table} WHERE {cursor_column} > $last ORDER BY {cursor_column} ASC LIMIT n`)
3278    /// and persisting the last read value under `cursor_id`. Does not delete/lock source rows.
3279    /// Mutually exclusive with `delete_after_read`.
3280    pub cursor_column: Option<String>,
3281    /// (Consumer only) Cursor id used to key the persisted resume position. Recommended when
3282    /// `cursor_column` is set: without it, progress is not persisted and every restart re-copies
3283    /// from the beginning.
3284    pub cursor_id: Option<String>,
3285    /// (Consumer only) Where to persist the resume cursor in `cursor_column` mode. A URL selects the
3286    /// backend; a bare name (or `/name`) reuses the **source** datastore with that table name:
3287    /// - absent → source datastore, table `mqb_cursors_<source_table>` (auto-unique)
3288    /// - `/my_cursors` → source datastore, table `my_cursors`
3289    /// - `file:///var/lib/mqb/cursors.json` → local JSON file (read-only / write-restricted sources)
3290    /// - `postgres://user@host/db/table` or `mysql://host/db/table` → external SQL table (table optional)
3291    /// - `mongodb://host/db/collection` → external MongoDB collection (collection optional)
3292    /// - `s3://bucket/prefix` (also `gs://`, `az://`, `abfs://`) → cloud object store; creds via env
3293    ///
3294    /// When no table/collection is named, it defaults to `mqb_cursors_<source_table>`.
3295    /// May embed connection credentials, so it is treated as a secret.
3296    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
3297    pub checkpoint_store: Option<String>,
3298    /// (Publisher only) If true, automatically create the table and indexes if they don't exist. Defaults to false.
3299    #[serde(default)]
3300    pub auto_create_table: bool,
3301    /// (Publisher only) PostgreSQL only. Bulk-load batches via `COPY FROM STDIN` (much faster than multi-row INSERT). Requires a token-based `insert_query`; no `ON CONFLICT`/`RETURNING`.
3302    #[serde(default)]
3303    pub bulk_copy: bool,
3304    /// (Consumer only) Polling interval in milliseconds. Defaults to 100ms.
3305    pub polling_interval_ms: Option<u64>,
3306    /// (Consumer only) If set, the poll interval backs off exponentially from `polling_interval_ms`
3307    /// up to this value while drained, resetting on new rows. Unset = constant interval.
3308    pub max_polling_interval_ms: Option<u64>,
3309    /// (Consumer only, PostgreSQL) If set, consume via logical-replication CDC instead of cursor
3310    /// polling: streams inserts/updates/deletes from this publication. Requires the `postgres-cdc`
3311    /// feature and a Postgres URL. For full control use the dedicated `postgres_cdc` endpoint.
3312    pub publication: Option<String>,
3313    /// (Consumer only, CDC) Replication slot name; created if missing. Defaults to `mq_bridge_slot`.
3314    pub slot_name: Option<String>,
3315    /// (Consumer only, CDC) When `publication` is set, create it if missing (default false).
3316    /// Needs table-owner privilege: it is auto-published `FOR TABLE {table}`.
3317    #[serde(default)]
3318    pub create_publication: bool,
3319    /// TLS configuration for the database connection.
3320    #[serde(default)]
3321    pub tls: TlsConfig,
3322    /// Maximum number of connections in the pool. Defaults to 10.
3323    pub max_connections: Option<u32>,
3324    /// Minimum number of connections to keep in the pool. Defaults to 0.
3325    pub min_connections: Option<u32>,
3326    /// Timeout for acquiring a connection from the pool in milliseconds. Defaults to 30000ms.
3327    pub acquire_timeout_ms: Option<u64>,
3328    /// Maximum idle time for a connection in milliseconds. Defaults to 600000ms (10 minutes).
3329    pub idle_timeout_ms: Option<u64>,
3330    /// Maximum lifetime of a connection in milliseconds. Defaults to 1800000ms (30 minutes).
3331    pub max_lifetime_ms: Option<u64>,
3332    /// Share one connection pool per connection (default: true); false forces a dedicated pool.
3333    #[serde(default)]
3334    #[cfg_attr(feature = "schema", schemars(default = "default_shared_schema"))]
3335    pub shared: Option<bool>,
3336}
3337
3338// --- ClickHouse Specific Configuration ---
3339
3340/// ClickHouse endpoint configuration (talks the ClickHouse HTTP interface).
3341///
3342/// As a **publisher** it batch-inserts messages using `FORMAT JSONEachRow` — by default the whole
3343/// message payload (which must be a JSON object) becomes one row; set `columns` to build each row
3344/// from explicit `${payload:<field>}` / `${metadata:<key>}` tokens instead. As a **consumer** it
3345/// reads an existing table **non-destructively** by paging over a monotonic `cursor_column`
3346/// (ClickHouse has no native queue/pub-sub), serializing each row to a JSON payload.
3347#[derive(Debug, Deserialize, Serialize, Clone, Default)]
3348#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3349#[serde(deny_unknown_fields)]
3350pub struct ClickHouseConfig {
3351    /// ClickHouse HTTP endpoint URL, e.g. `http://localhost:8123` (or `https://…`). If it contains
3352    /// userinfo, it will be treated as a secret.
3353    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
3354    pub url: String,
3355    /// Optional username. Takes precedence over any credentials embedded in the `url`. Defaults to `default`.
3356    #[serde(default)]
3357    pub username: Option<String>,
3358    /// Optional password. Takes precedence over any credentials embedded in the `url`.
3359    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
3360    #[serde(default)]
3361    pub password: Option<String>,
3362    /// Database name. Defaults to `default`.
3363    pub database: Option<String>,
3364    /// The table to read from / write to. May be schema-qualified (`db.table`).
3365    pub table: String,
3366    /// (Publisher only) Optional per-column mapping. Each entry maps a target column name to a value
3367    /// token: `${payload:<field>}` takes the top-level JSON field `<field>` of the payload (JSON type
3368    /// preserved), `${metadata:<key>}` takes `message.metadata["<key>"]` (as a string), and any other
3369    /// value is inserted literally. When omitted, the whole payload JSON object is inserted as one row.
3370    pub columns: Option<std::collections::BTreeMap<String, String>>,
3371    /// (Publisher only) If true, set the ClickHouse `async_insert=1` server setting so inserts are
3372    /// buffered server-side. Defaults to false.
3373    #[serde(default)]
3374    pub async_insert: bool,
3375    /// (Publisher only) With `async_insert`, wait for the server to flush before acking. Defaults to
3376    /// true (durable). False = fire-and-forget: faster, but a crash before flush can drop the batch.
3377    #[serde(default)]
3378    pub wait_for_async_insert: Option<bool>,
3379    /// (Consumer only) Read an existing table **non-destructively** and resumably, paging by this
3380    /// monotonic column (`SELECT … WHERE {cursor_column} > {last} ORDER BY {cursor_column} ASC LIMIT n`)
3381    /// and persisting the last read value under `cursor_id`.
3382    pub cursor_column: Option<String>,
3383    /// (Consumer only) Cursor id used to key the persisted resume position. Without it, progress is not
3384    /// persisted and every restart re-copies from the beginning.
3385    pub cursor_id: Option<String>,
3386    /// (Consumer only) Where to persist the resume cursor. Because ClickHouse is unsuited to per-row
3387    /// cursor upserts, a durable checkpoint requires an **external** store URL:
3388    /// - `file:///var/lib/mqb/cursors.json` → local JSON file
3389    /// - `postgres://user@host/db/table` / `mysql://host/db/table` → external SQL table (table optional)
3390    /// - `mongodb://host/db/collection` → external MongoDB collection (collection optional)
3391    /// - `s3://bucket/prefix` (also `gs://`, `az://`, `abfs://`) → cloud object store; creds via env
3392    ///
3393    /// May embed connection credentials, so it is treated as a secret.
3394    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
3395    pub checkpoint_store: Option<String>,
3396    /// (Consumer only) Columns to select in `cursor_column` mode. Defaults to `*`.
3397    pub select_columns: Option<String>,
3398    /// (Consumer only) Polling interval in milliseconds when the table is drained. Defaults to 100ms.
3399    pub polling_interval_ms: Option<u64>,
3400    /// (Consumer only) If set, the poll interval backs off exponentially from `polling_interval_ms`
3401    /// up to this value while drained, resetting on new rows. Unset = constant interval.
3402    pub max_polling_interval_ms: Option<u64>,
3403    /// Request timeout in milliseconds for ClickHouse HTTP calls (inserts, cursor reads, status).
3404    /// Unset = no timeout (wait indefinitely), which suits very large batch inserts.
3405    pub request_timeout_ms: Option<u64>,
3406    /// Connection (TCP + TLS handshake) timeout in milliseconds. Defaults to 10000ms.
3407    pub connect_timeout_ms: Option<u64>,
3408    /// TLS configuration for `https://` connections.
3409    #[serde(default)]
3410    pub tls: TlsConfig,
3411    /// HTTP body compression for inserts and cursor reads (`none`, `gzip`, `lz4`, `zstd`). Applied
3412    /// as `Content-Encoding` on the request body and negotiated on the response via `Accept-Encoding`.
3413    /// `lz4`/`zstd` are faster than `gzip`; all are understood natively by ClickHouse. Defaults to `gzip`.
3414    #[serde(default = "default_gzip_compression")]
3415    pub compression: Compression,
3416}
3417
3418fn default_gzip_compression() -> Compression {
3419    Compression::Gzip
3420}
3421
3422// --- Common Configuration ---
3423
3424/// TLS configuration for secure connections.
3425///
3426/// Configures Transport Layer Security (TLS/SSL) for encrypted communication.
3427/// Supports both client certificate (mutual TLS) and server certificate validation.
3428///
3429/// # Examples
3430///
3431/// ```
3432/// use mq_bridge::models::TlsConfig;
3433///
3434/// let tls = TlsConfig {
3435///     required: true,
3436///     ca_file: Some("/path/to/ca.pem".to_string()),
3437///     cert_file: Some("/path/to/cert.pem".to_string()),
3438///     key_file: Some("/path/to/key.pem".to_string()),
3439///     ..Default::default()
3440/// };
3441/// ```
3442#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq, Eq, Hash)]
3443#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
3444#[serde(deny_unknown_fields)]
3445pub struct TlsConfig {
3446    /// If true, enable TLS/SSL.
3447    #[serde(default, deserialize_with = "deserialize_null_as_false")]
3448    pub required: bool,
3449    /// Path to the CA certificate file.
3450    pub ca_file: Option<String>,
3451    /// Path to the client certificate file (PEM).
3452    pub cert_file: Option<String>,
3453    /// Path to the client private key file (PEM).
3454    pub key_file: Option<String>,
3455    /// Password for the private key (if encrypted).
3456    #[cfg_attr(feature = "schema", schemars(extend("format"="password")))]
3457    pub cert_password: Option<String>,
3458    /// If true, disable server certificate verification (insecure).
3459    #[serde(default)]
3460    pub accept_invalid_certs: bool,
3461}
3462
3463impl TlsConfig {
3464    /// Creates a new TLS configuration with default settings (TLS not required).
3465    pub fn new() -> Self {
3466        Self::default()
3467    }
3468
3469    pub fn with_ca_file(mut self, ca_file: impl Into<String>) -> Self {
3470        self.ca_file = Some(ca_file.into());
3471        self.required = true;
3472        self
3473    }
3474
3475    pub fn with_client_cert(
3476        mut self,
3477        cert_file: impl Into<String>,
3478        key_file: impl Into<String>,
3479    ) -> Self {
3480        self.cert_file = Some(cert_file.into());
3481        self.key_file = Some(key_file.into());
3482        self.required = true;
3483        self
3484    }
3485
3486    pub fn with_insecure(mut self, accept_invalid_certs: bool) -> Self {
3487        self.accept_invalid_certs = accept_invalid_certs;
3488        self
3489    }
3490
3491    /// Checks if mutual TLS (mTLS) client authentication is configured.
3492    pub fn is_mtls_client_configured(&self) -> bool {
3493        self.required && self.cert_file.is_some() && self.key_file.is_some()
3494    }
3495
3496    /// Checks if TLS server certificate authentication is configured.
3497    pub fn is_tls_server_configured(&self) -> bool {
3498        self.required && self.cert_file.is_some() && self.key_file.is_some()
3499    }
3500
3501    /// Checks if the TLS configuration is sufficient to make a TLS client connection.
3502    pub fn is_tls_client_configured(&self) -> bool {
3503        self.required
3504            || self.ca_file.is_some()
3505            || (self.cert_file.is_some() && self.key_file.is_some())
3506    }
3507
3508    /// Helper to normalize a URL by adding the appropriate scheme prefix (http:// or https://) if missing.
3509    pub fn normalize_url(&self, url: &str) -> String {
3510        if url
3511            .get(..7)
3512            .is_some_and(|prefix| prefix.eq_ignore_ascii_case("http://"))
3513            || url
3514                .get(..8)
3515                .is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://"))
3516        {
3517            url.to_string()
3518        } else {
3519            let is_tls = self.required;
3520            let scheme = if is_tls { "https" } else { "http" };
3521            format!("{}://{}", scheme, url)
3522        }
3523    }
3524}
3525
3526/// Trait for extracting secrets from configuration structures.
3527pub trait SecretExtractor {
3528    /// Extracts secrets into the provided map using the given prefix, and clears them from self.
3529    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>);
3530}
3531
3532fn extract_sensitive_string_map_entries(
3533    values: &mut HashMap<String, String>,
3534    prefix: &str,
3535    field_name: &str,
3536    secrets: &mut HashMap<String, String>,
3537) {
3538    let secret_keys = values
3539        .keys()
3540        .filter(|key| {
3541            let key = key.to_ascii_lowercase();
3542            key.contains("key") || key.contains("token") || key.contains("auth")
3543        })
3544        .cloned()
3545        .collect::<Vec<_>>();
3546
3547    for key in secret_keys {
3548        if let Some(value) = values.remove(&key) {
3549            secrets.insert(
3550                sanitize_secret_key(&format!("{}__{}__{}", prefix, field_name, key)),
3551                value,
3552            );
3553        }
3554    }
3555}
3556
3557fn url_has_userinfo(url: &str) -> bool {
3558    let Some(authority_start) = url.find("://").map(|idx| idx + 3) else {
3559        return false;
3560    };
3561    let authority_end = url[authority_start..]
3562        .find(['/', '?', '#'])
3563        .map(|idx| authority_start + idx)
3564        .unwrap_or(url.len());
3565    url[authority_start..authority_end].contains('@')
3566}
3567
3568fn sanitize_secret_key(key: &str) -> String {
3569    key.chars()
3570        .map(|ch| {
3571            let ch = ch.to_ascii_uppercase();
3572            if ch.is_ascii_alphanumeric() || ch == '_' {
3573                ch
3574            } else {
3575                '_'
3576            }
3577        })
3578        .collect()
3579}
3580
3581fn extract_sensitive_url(
3582    url: &mut String,
3583    prefix: &str,
3584    field_name: &str,
3585    secrets: &mut HashMap<String, String>,
3586) {
3587    if !url.is_empty() && url_has_userinfo(url) {
3588        secrets.insert(
3589            sanitize_secret_key(&format!("{}__{}", prefix, field_name)),
3590            std::mem::take(url),
3591        );
3592    }
3593}
3594
3595fn extract_sensitive_optional_url(
3596    url: &mut Option<String>,
3597    prefix: &str,
3598    field_name: &str,
3599    secrets: &mut HashMap<String, String>,
3600) {
3601    if url.as_ref().is_some_and(|url| url_has_userinfo(url)) {
3602        if let Some(url) = url.take() {
3603            secrets.insert(
3604                sanitize_secret_key(&format!("{}__{}", prefix, field_name)),
3605                url,
3606            );
3607        }
3608    }
3609}
3610
3611impl SecretExtractor for Route {
3612    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3613        self.input
3614            .extract_secrets(&format!("{}__{}", prefix, "INPUT"), secrets);
3615        self.output
3616            .extract_secrets(&format!("{}__{}", prefix, "OUTPUT"), secrets);
3617    }
3618}
3619
3620impl SecretExtractor for Endpoint {
3621    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3622        for (i, middleware) in self.middlewares.iter_mut().enumerate() {
3623            middleware.extract_secrets(&format!("{}__{}__{}", prefix, "MIDDLEWARES", i), secrets);
3624        }
3625        self.endpoint_type.extract_secrets(prefix, secrets);
3626    }
3627}
3628
3629impl SecretExtractor for EndpointType {
3630    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3631        match self {
3632            EndpointType::Aws(cfg) => {
3633                cfg.extract_secrets(&format!("{}__{}", prefix, "AWS"), secrets)
3634            }
3635            EndpointType::Kafka(cfg) => {
3636                cfg.extract_secrets(&format!("{}__{}", prefix, "KAFKA"), secrets)
3637            }
3638            EndpointType::Nats(cfg) => {
3639                cfg.extract_secrets(&format!("{}__{}", prefix, "NATS"), secrets)
3640            }
3641            EndpointType::Amqp(cfg) => {
3642                cfg.extract_secrets(&format!("{}__{}", prefix, "AMQP"), secrets)
3643            }
3644            EndpointType::MongoDb(cfg) => {
3645                cfg.extract_secrets(&format!("{}__{}", prefix, "MONGODB"), secrets)
3646            }
3647            EndpointType::Mqtt(cfg) => {
3648                cfg.extract_secrets(&format!("{}__{}", prefix, "MQTT"), secrets)
3649            }
3650            EndpointType::Http(cfg) => {
3651                cfg.extract_secrets(&format!("{}__{}", prefix, "HTTP"), secrets)
3652            }
3653            EndpointType::WebSocket(cfg) => {
3654                cfg.extract_secrets(&format!("{}__{}", prefix, "WEBSOCKET"), secrets)
3655            }
3656            EndpointType::IbmMq(cfg) => {
3657                cfg.extract_secrets(&format!("{}__{}", prefix, "IBMMQ"), secrets)
3658            }
3659            EndpointType::ZeroMq(cfg) => {
3660                cfg.extract_secrets(&format!("{}__{}", prefix, "ZEROMQ"), secrets)
3661            }
3662            EndpointType::RedisStreams(cfg) => {
3663                cfg.extract_secrets(&format!("{}__{}", prefix, "REDIS_STREAMS"), secrets)
3664            }
3665            EndpointType::Sqlx(cfg) => {
3666                cfg.extract_secrets(&format!("{}__{}", prefix, "SQLX"), secrets)
3667            }
3668            EndpointType::ClickHouse(cfg) => {
3669                cfg.extract_secrets(&format!("{}__{}", prefix, "CLICKHOUSE"), secrets)
3670            }
3671            EndpointType::PostgresCdc(cfg) => {
3672                cfg.extract_secrets(&format!("{}__{}", prefix, "POSTGRES_CDC"), secrets)
3673            }
3674            EndpointType::Grpc(cfg) => {
3675                cfg.extract_secrets(&format!("{}__{}", prefix, "GRPC"), secrets)
3676            }
3677            EndpointType::Fanout(endpoints) => {
3678                for (i, ep) in endpoints.iter_mut().enumerate() {
3679                    ep.extract_secrets(&format!("{}__{}__{}", prefix, "FANOUT", i), secrets);
3680                }
3681            }
3682            EndpointType::Switch(cfg) => {
3683                for (key, ep) in cfg.cases.iter_mut() {
3684                    ep.extract_secrets(
3685                        &format!(
3686                            "{}__{}__{}",
3687                            prefix,
3688                            "SWITCH__CASES",
3689                            sanitize_secret_key(key)
3690                        ),
3691                        secrets,
3692                    );
3693                }
3694                if let Some(default) = &mut cfg.default {
3695                    default.extract_secrets(&format!("{}__{}", prefix, "SWITCH__DEFAULT"), secrets);
3696                }
3697            }
3698            EndpointType::Reader(ep) => {
3699                ep.extract_secrets(&format!("{}__{}", prefix, "READER"), secrets)
3700            }
3701            EndpointType::Request(cfg) => {
3702                cfg.to
3703                    .extract_secrets(&format!("{}__{}", prefix, "REQUEST__TO"), secrets);
3704                cfg.forward_to
3705                    .extract_secrets(&format!("{}__{}", prefix, "REQUEST__FORWARD_TO"), secrets);
3706            }
3707            EndpointType::File(cfg) => {
3708                if let Some(enc) = &mut cfg.encryption {
3709                    enc.extract_secrets(&format!("{}__{}", prefix, "FILE__ENCRYPTION"), secrets);
3710                }
3711            }
3712            EndpointType::ObjectStore(cfg) => {
3713                if let Some(enc) = &mut cfg.encryption {
3714                    enc.extract_secrets(
3715                        &format!("{}__{}", prefix, "OBJECT_STORE__ENCRYPTION"),
3716                        secrets,
3717                    );
3718                }
3719            }
3720            _ => {}
3721        }
3722    }
3723}
3724
3725impl SecretExtractor for Middleware {
3726    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3727        match self {
3728            Middleware::Dlq(cfg) => {
3729                cfg.endpoint
3730                    .extract_secrets(&format!("{}__{}__{}", prefix, "DLQ", "ENDPOINT"), secrets);
3731            }
3732            Middleware::Encryption(cfg) => {
3733                cfg.extract_secrets(&format!("{}__{}", prefix, "ENCRYPTION"), secrets);
3734            }
3735            _ => {}
3736        }
3737    }
3738}
3739
3740impl SecretExtractor for EncryptionConfig {
3741    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3742        if !self.key.is_empty() {
3743            secrets.insert(
3744                sanitize_secret_key(&format!("{}__{}", prefix, "KEY")),
3745                std::mem::take(&mut self.key),
3746            );
3747        }
3748        for (id, k) in std::mem::take(&mut self.decrypt_keys) {
3749            secrets.insert(
3750                sanitize_secret_key(&format!("{}__{}__{}", prefix, "DECRYPT_KEYS", id)),
3751                k,
3752            );
3753        }
3754    }
3755}
3756
3757impl SecretExtractor for AwsConfig {
3758    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3759        if let Some(val) = self.access_key.take() {
3760            secrets.insert(format!("{}__{}", prefix, "ACCESS_KEY"), val);
3761        }
3762        if let Some(val) = self.secret_key.take() {
3763            secrets.insert(format!("{}__{}", prefix, "SECRET_KEY"), val);
3764        }
3765        if let Some(val) = self.session_token.take() {
3766            secrets.insert(format!("{}__{}", prefix, "SESSION_TOKEN"), val);
3767        }
3768        extract_sensitive_optional_url(&mut self.queue_url, prefix, "QUEUE_URL", secrets);
3769        extract_sensitive_optional_url(&mut self.endpoint_url, prefix, "ENDPOINT_URL", secrets);
3770    }
3771}
3772
3773impl SecretExtractor for KafkaConfig {
3774    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3775        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3776        if let Some(val) = self.username.take() {
3777            secrets.insert(format!("{}__{}", prefix, "USERNAME"), val);
3778        }
3779        if let Some(val) = self.password.take() {
3780            secrets.insert(format!("{}__{}", prefix, "PASSWORD"), val);
3781        }
3782        self.tls
3783            .extract_secrets(&format!("{}__{}", prefix, "TLS"), secrets);
3784    }
3785}
3786
3787impl SecretExtractor for NatsConfig {
3788    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3789        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3790        if let Some(val) = self.username.take() {
3791            secrets.insert(format!("{}__{}", prefix, "USERNAME"), val);
3792        }
3793        if let Some(val) = self.password.take() {
3794            secrets.insert(format!("{}__{}", prefix, "PASSWORD"), val);
3795        }
3796        if let Some(val) = self.token.take() {
3797            secrets.insert(format!("{}__{}", prefix, "TOKEN"), val);
3798        }
3799        self.tls
3800            .extract_secrets(&format!("{}__{}", prefix, "TLS"), secrets);
3801    }
3802}
3803
3804impl SecretExtractor for AmqpConfig {
3805    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3806        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3807        if let Some(val) = self.username.take() {
3808            secrets.insert(format!("{}__{}", prefix, "USERNAME"), val);
3809        }
3810        if let Some(val) = self.password.take() {
3811            secrets.insert(format!("{}__{}", prefix, "PASSWORD"), val);
3812        }
3813        self.tls
3814            .extract_secrets(&format!("{}__{}", prefix, "TLS"), secrets);
3815    }
3816}
3817
3818impl SecretExtractor for MongoDbConfig {
3819    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3820        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3821        if let Some(val) = self.username.take() {
3822            secrets.insert(format!("{}__{}", prefix, "USERNAME"), val);
3823        }
3824        if let Some(val) = self.password.take() {
3825            secrets.insert(format!("{}__{}", prefix, "PASSWORD"), val);
3826        }
3827        // The checkpoint store URL may embed connection credentials.
3828        extract_sensitive_optional_url(
3829            &mut self.checkpoint_store,
3830            prefix,
3831            "CHECKPOINT_STORE",
3832            secrets,
3833        );
3834        self.tls
3835            .extract_secrets(&format!("{}__{}", prefix, "TLS"), secrets);
3836    }
3837}
3838
3839impl SecretExtractor for MqttConfig {
3840    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3841        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3842        if let Some(val) = self.username.take() {
3843            secrets.insert(format!("{}__{}", prefix, "USERNAME"), val);
3844        }
3845        if let Some(val) = self.password.take() {
3846            secrets.insert(format!("{}__{}", prefix, "PASSWORD"), val);
3847        }
3848        self.tls
3849            .extract_secrets(&format!("{}__{}", prefix, "TLS"), secrets);
3850    }
3851}
3852
3853impl SecretExtractor for HttpConfig {
3854    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3855        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3856        if let Some((u, p)) = self.basic_auth.take() {
3857            secrets.insert(format!("{}__{}__{}", prefix, "BASIC_AUTH", 0), u);
3858            secrets.insert(format!("{}__{}__{}", prefix, "BASIC_AUTH", 1), p);
3859        }
3860        extract_sensitive_string_map_entries(
3861            &mut self.custom_headers,
3862            prefix,
3863            "CUSTOM_HEADERS",
3864            secrets,
3865        );
3866        self.tls
3867            .extract_secrets(&format!("{}__{}", prefix, "TLS"), secrets);
3868        if let Some(endpoint) = &mut self.stream_response_to {
3869            endpoint.extract_secrets(&format!("{}__{}", prefix, "STREAM_RESPONSE_TO"), secrets);
3870        }
3871    }
3872}
3873
3874impl SecretExtractor for WebSocketConfig {
3875    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3876        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3877    }
3878}
3879
3880impl SecretExtractor for IbmMqConfig {
3881    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3882        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3883        if let Some(val) = self.username.take() {
3884            secrets.insert(format!("{}__{}", prefix, "USERNAME"), val);
3885        }
3886        if let Some(val) = self.password.take() {
3887            secrets.insert(format!("{}__{}", prefix, "PASSWORD"), val);
3888        }
3889        self.tls
3890            .extract_secrets(&format!("{}__{}", prefix, "TLS"), secrets);
3891    }
3892}
3893
3894impl SecretExtractor for ZeroMqConfig {
3895    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3896        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3897    }
3898}
3899
3900impl SecretExtractor for RedisStreamsConfig {
3901    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3902        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3903        if let Some(val) = self.username.take() {
3904            secrets.insert(format!("{}__{}", prefix, "USERNAME"), val);
3905        }
3906        if let Some(val) = self.password.take() {
3907            secrets.insert(format!("{}__{}", prefix, "PASSWORD"), val);
3908        }
3909    }
3910}
3911
3912impl SecretExtractor for SqlxConfig {
3913    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3914        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3915        if let Some(val) = self.username.take() {
3916            secrets.insert(format!("{}__{}", prefix, "USERNAME"), val);
3917        }
3918        if let Some(val) = self.password.take() {
3919            secrets.insert(format!("{}__{}", prefix, "PASSWORD"), val);
3920        }
3921        self.tls
3922            .extract_secrets(&format!("{}__{}", prefix, "TLS"), secrets);
3923    }
3924}
3925
3926impl SecretExtractor for ClickHouseConfig {
3927    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3928        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3929        if let Some(val) = self.username.take() {
3930            secrets.insert(format!("{}__{}", prefix, "USERNAME"), val);
3931        }
3932        if let Some(val) = self.password.take() {
3933            secrets.insert(format!("{}__{}", prefix, "PASSWORD"), val);
3934        }
3935        if let Some(val) = self.checkpoint_store.take() {
3936            secrets.insert(format!("{}__{}", prefix, "CHECKPOINT_STORE"), val);
3937        }
3938        self.tls
3939            .extract_secrets(&format!("{}__{}", prefix, "TLS"), secrets);
3940    }
3941}
3942
3943impl SecretExtractor for PostgresCdcConfig {
3944    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3945        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3946        if let Some(val) = self.checkpoint_store.take() {
3947            secrets.insert(format!("{}__{}", prefix, "CHECKPOINT_STORE"), val);
3948        }
3949        self.tls
3950            .extract_secrets(&format!("{}__{}", prefix, "TLS"), secrets);
3951    }
3952}
3953
3954impl SecretExtractor for GrpcConfig {
3955    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3956        extract_sensitive_url(&mut self.url, prefix, "URL", secrets);
3957        self.tls
3958            .extract_secrets(&format!("{}__{}", prefix, "TLS"), secrets);
3959    }
3960}
3961
3962impl SecretExtractor for TlsConfig {
3963    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3964        if let Some(val) = self.cert_password.take() {
3965            secrets.insert(format!("{}__{}", prefix, "CERT_PASSWORD"), val);
3966        }
3967    }
3968}
3969
3970impl SecretExtractor for IbmTlsConfig {
3971    fn extract_secrets(&mut self, prefix: &str, secrets: &mut HashMap<String, String>) {
3972        if let Some(val) = self.key_repository_password.take() {
3973            // Wire/env name matches the serde rename (`cert_password`), so the config
3974            // crate's env override resolves back to this field.
3975            secrets.insert(format!("{}__{}", prefix, "CERT_PASSWORD"), val);
3976        }
3977    }
3978}
3979
3980/// Extracts sensitive values (passwords, keys, tokens) from the configuration
3981/// and returns them as a map of environment variables (key-value pairs).
3982/// The extracted fields in the configuration are set to `None`.
3983///
3984/// The keys in the returned map follow the `MQB__{ROUTE}__{ENDPOINT}__{FIELD}` pattern
3985/// compatible with the `config` crate's environment variable override mechanism.
3986pub fn extract_config_secrets(config: &mut Config) -> HashMap<String, String> {
3987    let mut secrets = HashMap::new();
3988    for (route_name, route) in config.iter_mut() {
3989        let prefix = sanitize_secret_key(&format!("MQB__{}", route_name));
3990        route.extract_secrets(&prefix, &mut secrets);
3991    }
3992    secrets
3993}
3994
3995#[cfg(test)]
3996mod null_endpoint_tests {
3997    use super::*;
3998
3999    #[test]
4000    fn null_endpoint_json_round_trip() {
4001        let value = serde_json::to_value(Endpoint::null()).expect("serialize");
4002        let back: Endpoint = serde_json::from_value(value).expect("deserialize");
4003        assert!(matches!(back.endpoint_type, EndpointType::Null));
4004    }
4005
4006    /// The schema advertises unit variants as bare strings, so `"null"` must parse.
4007    #[test]
4008    fn null_endpoint_accepts_string_and_unit_forms() {
4009        for input in ["\"null\"", "null", "{}"] {
4010            let endpoint: Endpoint = serde_json::from_str(input).unwrap_or_else(|e| {
4011                panic!("failed to parse {input}: {e}");
4012            });
4013            assert!(matches!(endpoint.endpoint_type, EndpointType::Null));
4014        }
4015    }
4016
4017    #[test]
4018    fn unknown_endpoint_string_is_rejected() {
4019        let err = serde_json::from_str::<Endpoint>("\"kafka\"").expect_err("should fail");
4020        assert!(
4021            err.to_string().contains("unknown variant"),
4022            "unexpected error: {err}"
4023        );
4024    }
4025
4026    #[test]
4027    fn nested_null_endpoint_json_round_trip() {
4028        let config =
4029            HttpConfig::new("http://localhost:8080").with_stream_response_to(Endpoint::null());
4030        let value = serde_json::to_value(&config).expect("serialize");
4031        let back: HttpConfig = serde_json::from_value(value).expect("deserialize");
4032        let nested = back.stream_response_to.expect("stream_response_to present");
4033        assert!(matches!(nested.endpoint_type, EndpointType::Null));
4034    }
4035
4036    #[test]
4037    fn nested_null_endpoint_yaml_forms() {
4038        for yaml in [
4039            "url: http://localhost:8080\nstream_response_to: \"null\"\n",
4040            "url: http://localhost:8080\nstream_response_to: {}\n",
4041        ] {
4042            let config: HttpConfig = serde_yaml_ng::from_str(yaml)
4043                .unwrap_or_else(|e| panic!("failed to parse {yaml:?}: {e}"));
4044            let nested = config
4045                .stream_response_to
4046                .expect("stream_response_to present");
4047            assert!(matches!(nested.endpoint_type, EndpointType::Null));
4048        }
4049    }
4050
4051    /// In an `Option<Box<Endpoint>>` field, a bare `null` is consumed by serde's `Option`
4052    /// layer as `None` and never reaches the endpoint visitor.
4053    #[test]
4054    fn nested_bare_null_yaml_is_none() {
4055        let config: HttpConfig =
4056            serde_yaml_ng::from_str("url: http://localhost:8080\nstream_response_to: null\n")
4057                .expect("deserialize");
4058        assert!(config.stream_response_to.is_none());
4059    }
4060
4061    #[test]
4062    fn null_endpoint_yaml_round_trip() {
4063        let yaml = serde_yaml_ng::to_string(&Endpoint::null()).expect("serialize");
4064        let back: Endpoint = serde_yaml_ng::from_str(&yaml).expect("deserialize");
4065        assert!(matches!(back.endpoint_type, EndpointType::Null));
4066    }
4067}
4068
4069#[cfg(test)]
4070mod tests {
4071    use super::*;
4072    use config::{Config as ConfigBuilder, Environment};
4073
4074    const TEST_YAML: &str = r#"
4075kafka_to_nats:
4076  concurrency: 10
4077  input:
4078    middlewares:
4079      - deduplication:
4080          sled_path: "/tmp/mq-bridge/dedup_db"
4081          ttl_seconds: 3600
4082      - metrics: {}
4083      - retry:
4084          max_attempts: 5
4085          initial_interval_ms: 200
4086      - random_panic:
4087          mode: nack
4088      - dlq:
4089          endpoint:
4090            nats:
4091              subject: "dlq-subject"
4092              url: "nats://localhost:4222"
4093    kafka:
4094      topic: "input-topic"
4095      url: "localhost:9092"
4096      group_id: "my-consumer-group"
4097      tls:
4098        required: true
4099        ca_file: "/path_to_ca"
4100        cert_file: "/path_to_cert"
4101        key_file: "/path_to_key"
4102        cert_password: "password"
4103        accept_invalid_certs: true
4104  output:
4105    middlewares:
4106      - metrics: {}
4107      - dlq:
4108          endpoint:
4109            file:
4110              path: "error.out"
4111    nats:
4112      subject: "output-subject"
4113      url: "nats://localhost:4222"
4114"#;
4115
4116    fn assert_config_values(config: &Config) {
4117        assert_eq!(config.len(), 1);
4118        let route = config.get("kafka_to_nats").expect("Route should exist");
4119
4120        assert_eq!(route.options.concurrency, 10);
4121
4122        // --- Assert Input ---
4123        let input = &route.input;
4124        assert_eq!(input.middlewares.len(), 5);
4125
4126        let mut has_dedup = false;
4127        let mut has_metrics = false;
4128        let mut has_dlq = false;
4129        let mut has_retry = false;
4130        let mut has_random_panic = false;
4131        for middleware in &input.middlewares {
4132            match middleware {
4133                Middleware::Deduplication(dedup) => {
4134                    assert_eq!(dedup.sled_path.as_deref(), Some("/tmp/mq-bridge/dedup_db"));
4135                    assert_eq!(dedup.ttl_seconds, 3600);
4136                    has_dedup = true;
4137                }
4138                Middleware::Metrics(_) => {
4139                    has_metrics = true;
4140                }
4141                Middleware::Custom { .. } => {}
4142                Middleware::Dlq(dlq) => {
4143                    assert!(dlq.endpoint.middlewares.is_empty());
4144                    if let EndpointType::Nats(nats_cfg) = &dlq.endpoint.endpoint_type {
4145                        assert_eq!(nats_cfg.subject, Some("dlq-subject".to_string()));
4146                        assert_eq!(nats_cfg.url, "nats://localhost:4222");
4147                    }
4148                    has_dlq = true;
4149                }
4150                Middleware::Retry(retry) => {
4151                    assert_eq!(retry.max_attempts, 5);
4152                    assert_eq!(retry.initial_interval_ms, 200);
4153                    has_retry = true;
4154                }
4155                Middleware::RandomPanic(rp) => {
4156                    assert!(rp.mode == FaultMode::Nack);
4157                    has_random_panic = true;
4158                }
4159                Middleware::Delay(_) => {}
4160                Middleware::WeakJoin(_) => {}
4161                Middleware::Limiter(_) => {}
4162                Middleware::Buffer(_) => {}
4163                Middleware::CookieJar(_) => {}
4164                Middleware::Transform(_) => {}
4165                Middleware::Encryption(_) => {}
4166                Middleware::Compression(_) => {}
4167            }
4168        }
4169
4170        if let EndpointType::Kafka(kafka) = &input.endpoint_type {
4171            assert_eq!(kafka.topic, Some("input-topic".to_string()));
4172            assert_eq!(kafka.url, "localhost:9092");
4173            assert_eq!(kafka.group_id, Some("my-consumer-group".to_string()));
4174            let tls = &kafka.tls;
4175            assert!(tls.required);
4176            assert_eq!(tls.ca_file.as_deref(), Some("/path_to_ca"));
4177            assert!(tls.accept_invalid_certs);
4178        } else {
4179            panic!("Input endpoint should be Kafka");
4180        }
4181        assert!(has_dedup);
4182        assert!(has_metrics);
4183        assert!(has_dlq);
4184        assert!(has_retry);
4185        assert!(has_random_panic);
4186
4187        // --- Assert Output ---
4188        let output = &route.output;
4189        assert_eq!(output.middlewares.len(), 2);
4190        assert!(matches!(output.middlewares[0], Middleware::Metrics(_)));
4191
4192        if let EndpointType::Nats(nats) = &output.endpoint_type {
4193            assert_eq!(nats.subject, Some("output-subject".to_string()));
4194            assert_eq!(nats.url, "nats://localhost:4222");
4195        } else {
4196            panic!("Output endpoint should be NATS");
4197        }
4198    }
4199
4200    #[test]
4201    fn test_deserialize_from_yaml() {
4202        // We use serde_yaml directly here because the `config` crate's processing
4203        // can interfere with complex deserialization logic.
4204        let result: Result<Config, _> = serde_yaml_ng::from_str(TEST_YAML);
4205        println!("Deserialized from YAML: {:#?}", result);
4206        let config = result.expect("Failed to deserialize TEST_YAML");
4207        assert_config_values(&config);
4208    }
4209
4210    #[test]
4211    fn test_deserialize_from_env() {
4212        // Set environment variables based on README
4213        unsafe {
4214            std::env::set_var("MQB__KAFKA_TO_NATS__CONCURRENCY", "10");
4215            std::env::set_var("MQB__KAFKA_TO_NATS__INPUT__KAFKA__TOPIC", "input-topic");
4216            std::env::set_var("MQB__KAFKA_TO_NATS__INPUT__KAFKA__URL", "localhost:9092");
4217            std::env::set_var(
4218                "MQB__KAFKA_TO_NATS__INPUT__KAFKA__GROUP_ID",
4219                "my-consumer-group",
4220            );
4221            std::env::set_var("MQB__KAFKA_TO_NATS__INPUT__KAFKA__TLS__REQUIRED", "true");
4222            std::env::set_var(
4223                "MQB__KAFKA_TO_NATS__INPUT__KAFKA__TLS__CA_FILE",
4224                "/path_to_ca",
4225            );
4226            std::env::set_var(
4227                "MQB__KAFKA_TO_NATS__INPUT__KAFKA__TLS__ACCEPT_INVALID_CERTS",
4228                "true",
4229            );
4230            std::env::set_var(
4231                "MQB__KAFKA_TO_NATS__OUTPUT__NATS__SUBJECT",
4232                "output-subject",
4233            );
4234            std::env::set_var(
4235                "MQB__KAFKA_TO_NATS__OUTPUT__NATS__URL",
4236                "nats://localhost:4222",
4237            );
4238            std::env::set_var(
4239                "MQB__KAFKA_TO_NATS__INPUT__MIDDLEWARES__0__DLQ__ENDPOINT__NATS__SUBJECT",
4240                "dlq-subject",
4241            );
4242            std::env::set_var(
4243                "MQB__KAFKA_TO_NATS__INPUT__MIDDLEWARES__0__DLQ__ENDPOINT__NATS__URL",
4244                "nats://localhost:4222",
4245            );
4246        }
4247
4248        let builder = ConfigBuilder::builder()
4249            // Enable automatic type parsing for values from environment variables.
4250            .add_source(
4251                Environment::with_prefix("MQB")
4252                    .separator("__")
4253                    .try_parsing(true),
4254            );
4255
4256        let config: Config = builder
4257            .build()
4258            .expect("Failed to build config")
4259            .try_deserialize()
4260            .expect("Failed to deserialize config");
4261
4262        // We can't test all values from env, but we can check the ones we set.
4263        assert_eq!(config.get("kafka_to_nats").unwrap().options.concurrency, 10);
4264        if let EndpointType::Kafka(k) = &config.get("kafka_to_nats").unwrap().input.endpoint_type {
4265            assert_eq!(k.topic, Some("input-topic".to_string()));
4266            assert!(k.tls.required);
4267        } else {
4268            panic!("Expected Kafka endpoint");
4269        }
4270
4271        let input = &config.get("kafka_to_nats").unwrap().input;
4272        assert_eq!(input.middlewares.len(), 1);
4273        if let Middleware::Dlq(_) = &input.middlewares[0] {
4274            // Correctly parsed
4275        } else {
4276            panic!("Expected DLQ middleware");
4277        }
4278    }
4279
4280    #[test]
4281    fn test_extract_secrets() {
4282        let mut config = Config::new();
4283        let mut route = Route::default();
4284
4285        // Setup Kafka with secrets
4286        let mut kafka_config = KafkaConfig::new("kafka://user:pass@localhost:9092");
4287        kafka_config.username = Some("user".to_string());
4288        kafka_config.password = Some("pass".to_string());
4289        kafka_config.tls.cert_password = Some("certpass".to_string());
4290
4291        route.input = Endpoint {
4292            endpoint_type: EndpointType::Kafka(kafka_config),
4293            middlewares: vec![],
4294            handler: None,
4295        };
4296
4297        // Setup HTTP with basic auth
4298        let mut http_config = HttpConfig::new("http://httpuser:httppass@localhost");
4299        http_config.basic_auth = Some(("httpuser".to_string(), "httppass".to_string()));
4300        http_config
4301            .custom_headers
4302            .insert("X-API-Key".to_string(), "http-api-key".to_string());
4303        http_config.custom_headers.insert(
4304            "X-Access-Token".to_string(),
4305            "http-access-token".to_string(),
4306        );
4307        http_config.custom_headers.insert(
4308            "X-Authentication".to_string(),
4309            "http-authentication".to_string(),
4310        );
4311        http_config.custom_headers.insert(
4312            "Authorization".to_string(),
4313            "Bearer secret-token".to_string(),
4314        );
4315        http_config
4316            .custom_headers
4317            .insert("X-Trace-Id".to_string(), "trace-value".to_string());
4318
4319        route.output = Endpoint {
4320            endpoint_type: EndpointType::Http(http_config),
4321            middlewares: vec![],
4322            handler: None,
4323        };
4324
4325        config.insert("test_route".to_string(), route);
4326
4327        let secrets = extract_config_secrets(&mut config);
4328
4329        // Verify secrets extracted
4330        assert_eq!(
4331            secrets
4332                .get("MQB__TEST_ROUTE__INPUT__KAFKA__URL")
4333                .map(|s| s.as_str()),
4334            Some("kafka://user:pass@localhost:9092")
4335        );
4336        assert_eq!(
4337            secrets
4338                .get("MQB__TEST_ROUTE__INPUT__KAFKA__USERNAME")
4339                .map(|s| s.as_str()),
4340            Some("user")
4341        );
4342        assert_eq!(
4343            secrets
4344                .get("MQB__TEST_ROUTE__INPUT__KAFKA__PASSWORD")
4345                .map(|s| s.as_str()),
4346            Some("pass")
4347        );
4348        assert_eq!(
4349            secrets
4350                .get("MQB__TEST_ROUTE__INPUT__KAFKA__TLS__CERT_PASSWORD")
4351                .map(|s| s.as_str()),
4352            Some("certpass")
4353        );
4354        assert_eq!(
4355            secrets
4356                .get("MQB__TEST_ROUTE__OUTPUT__HTTP__URL")
4357                .map(|s| s.as_str()),
4358            Some("http://httpuser:httppass@localhost")
4359        );
4360        assert_eq!(
4361            secrets
4362                .get("MQB__TEST_ROUTE__OUTPUT__HTTP__BASIC_AUTH__0")
4363                .map(|s| s.as_str()),
4364            Some("httpuser")
4365        );
4366        assert_eq!(
4367            secrets
4368                .get("MQB__TEST_ROUTE__OUTPUT__HTTP__BASIC_AUTH__1")
4369                .map(|s| s.as_str()),
4370            Some("httppass")
4371        );
4372        assert_eq!(
4373            secrets
4374                .get("MQB__TEST_ROUTE__OUTPUT__HTTP__CUSTOM_HEADERS__X_API_KEY")
4375                .map(|s| s.as_str()),
4376            Some("http-api-key")
4377        );
4378        assert_eq!(
4379            secrets
4380                .get("MQB__TEST_ROUTE__OUTPUT__HTTP__CUSTOM_HEADERS__X_ACCESS_TOKEN")
4381                .map(|s| s.as_str()),
4382            Some("http-access-token")
4383        );
4384        assert_eq!(
4385            secrets
4386                .get("MQB__TEST_ROUTE__OUTPUT__HTTP__CUSTOM_HEADERS__X_AUTHENTICATION")
4387                .map(|s| s.as_str()),
4388            Some("http-authentication")
4389        );
4390        assert_eq!(
4391            secrets
4392                .get("MQB__TEST_ROUTE__OUTPUT__HTTP__CUSTOM_HEADERS__AUTHORIZATION")
4393                .map(|s| s.as_str()),
4394            Some("Bearer secret-token")
4395        );
4396
4397        // Verify config cleared
4398        let route = config.get("test_route").unwrap();
4399        if let EndpointType::Kafka(k) = &route.input.endpoint_type {
4400            assert!(k.url.is_empty());
4401            assert!(k.username.is_none());
4402            assert!(k.password.is_none());
4403            assert!(k.tls.cert_password.is_none());
4404        }
4405        if let EndpointType::Http(h) = &route.output.endpoint_type {
4406            assert!(h.url.is_empty());
4407            assert!(h.basic_auth.is_none());
4408            assert!(!h.custom_headers.contains_key("X-API-Key"));
4409            assert!(!h.custom_headers.contains_key("X-Access-Token"));
4410            assert!(!h.custom_headers.contains_key("X-Authentication"));
4411            assert!(!h.custom_headers.contains_key("Authorization"));
4412            assert_eq!(
4413                h.custom_headers.get("X-Trace-Id").map(|s| s.as_str()),
4414                Some("trace-value")
4415            );
4416        }
4417    }
4418
4419    #[test]
4420    fn test_extract_sensitive_url_only_strips_authority_credentials() {
4421        let mut config = Config::new();
4422        let path_at_route = Route {
4423            output: Endpoint {
4424                endpoint_type: EndpointType::Http(HttpConfig::new(
4425                    "https://example.com/path/user@example.com?email=a@b.test",
4426                )),
4427                middlewares: vec![],
4428                handler: None,
4429            },
4430            ..Default::default()
4431        };
4432        config.insert("path_at_route".to_string(), path_at_route);
4433
4434        let credential_route = Route {
4435            output: Endpoint {
4436                endpoint_type: EndpointType::Http(HttpConfig::new(
4437                    "https://user:pass@example.com/path",
4438                )),
4439                middlewares: vec![],
4440                handler: None,
4441            },
4442            ..Default::default()
4443        };
4444        config.insert("credential_route".to_string(), credential_route);
4445
4446        let query_at_route = Route {
4447            output: Endpoint {
4448                endpoint_type: EndpointType::Http(HttpConfig::new(
4449                    "https://example.com?next=a@b.test",
4450                )),
4451                middlewares: vec![],
4452                handler: None,
4453            },
4454            ..Default::default()
4455        };
4456        config.insert("query_at_route".to_string(), query_at_route);
4457
4458        let fragment_at_route = Route {
4459            output: Endpoint {
4460                endpoint_type: EndpointType::Http(HttpConfig::new(
4461                    "https://example.com#user@example.com",
4462                )),
4463                middlewares: vec![],
4464                handler: None,
4465            },
4466            ..Default::default()
4467        };
4468        config.insert("fragment_at_route".to_string(), fragment_at_route);
4469
4470        let secrets = extract_config_secrets(&mut config);
4471
4472        if let EndpointType::Http(http) = &config.get("path_at_route").unwrap().output.endpoint_type
4473        {
4474            assert_eq!(
4475                http.url,
4476                "https://example.com/path/user@example.com?email=a@b.test"
4477            );
4478        }
4479        if let EndpointType::Http(http) =
4480            &config.get("query_at_route").unwrap().output.endpoint_type
4481        {
4482            assert_eq!(http.url, "https://example.com?next=a@b.test");
4483        }
4484        if let EndpointType::Http(http) = &config
4485            .get("fragment_at_route")
4486            .unwrap()
4487            .output
4488            .endpoint_type
4489        {
4490            assert_eq!(http.url, "https://example.com#user@example.com");
4491        }
4492        if let EndpointType::Http(http) =
4493            &config.get("credential_route").unwrap().output.endpoint_type
4494        {
4495            assert!(http.url.is_empty());
4496        }
4497        assert_eq!(
4498            secrets
4499                .get("MQB__CREDENTIAL_ROUTE__OUTPUT__HTTP__URL")
4500                .map(String::as_str),
4501            Some("https://user:pass@example.com/path")
4502        );
4503        assert!(!secrets.contains_key("MQB__PATH_AT_ROUTE__OUTPUT__HTTP__URL"));
4504        assert!(!secrets.contains_key("MQB__QUERY_AT_ROUTE__OUTPUT__HTTP__URL"));
4505        assert!(!secrets.contains_key("MQB__FRAGMENT_AT_ROUTE__OUTPUT__HTTP__URL"));
4506    }
4507
4508    #[test]
4509    fn test_memory_config_requires_topic_or_url() {
4510        let err = serde_yaml_ng::from_str::<MemoryConfig>("{}").unwrap_err();
4511        assert!(err
4512            .to_string()
4513            .contains("MemoryConfig: 'topic' (or 'url' alias) is required."));
4514    }
4515
4516    #[test]
4517    fn test_file_config_inference() {
4518        let yaml = r#"
4519mode: group_subscribe
4520path: "/tmp/test"
4521group_id: "my_group"
4522"#;
4523        let config: FileConfig = serde_yaml_ng::from_str(yaml).unwrap();
4524        match config.mode {
4525            Some(FileConsumerMode::GroupSubscribe { group_id, .. }) => {
4526                assert_eq!(group_id, "my_group")
4527            }
4528            _ => panic!("Expected GroupSubscribe"),
4529        }
4530
4531        let yaml_queue = r#"
4532mode: consume
4533path: "/tmp/test"
4534"#;
4535        let config_queue: FileConfig = serde_yaml_ng::from_str(yaml_queue).unwrap();
4536        match config_queue.mode {
4537            Some(FileConsumerMode::Consume { delete }) => assert!(!delete),
4538            _ => panic!("Expected Consume"),
4539        }
4540    }
4541}
4542
4543#[cfg(all(test, feature = "schema"))]
4544mod schema_tests {
4545    use super::*;
4546
4547    #[test]
4548    fn generate_json_schema() {
4549        let schema = schemars::schema_for!(Config);
4550        let schema_json = serde_json::to_string_pretty(&schema).unwrap();
4551
4552        let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
4553        path.push("mq-bridge.schema.json");
4554        std::fs::write(path, schema_json).expect("Failed to write schema file");
4555    }
4556}