aimdb_core/connector.rs
1//! Connector infrastructure for external protocol integration
2//!
3//! Provides the `.link_to()` / `.link_from()` builder API for ergonomic
4//! connector setup with automatic client lifecycle management. Connectors
5//! bridge AimDB records to external systems (MQTT, KNX, WebSocket, …).
6//!
7//! # Design Philosophy
8//!
9//! - **User Extensions**: Connector implementations are provided by users
10//! - **Shared Clients**: Single client instance shared across tasks (Arc or static)
11//! - **No Buffering**: Direct access to protocol clients, no intermediate queues
12//! - **Type Safety**: Compile-time guarantees via typed handlers
13//!
14//! # Example
15//!
16//! ```no_run
17//! # use aimdb_core::AimDbBuilder;
18//! # #[derive(Clone, Debug)] struct WeatherAlert { level: u8 }
19//! # fn wire(builder: &mut AimDbBuilder) {
20//! builder.configure::<WeatherAlert>("weather.alert", |reg| {
21//! // .buffer(BufferCfg::SingleLatest) — via your runtime adapter's ext trait
22//! reg.link_to("mqtt://alerts/weather")
23//! .with_serializer(|_ctx, alert: &WeatherAlert| Ok(vec![alert.level]))
24//! .finish();
25//! });
26//! # }
27//! ```
28
29use core::fmt::{self, Debug};
30use core::future::Future;
31use core::pin::Pin;
32
33use alloc::{
34 boxed::Box,
35 string::{String, ToString},
36 sync::Arc,
37 vec::Vec,
38};
39
40use alloc::format;
41
42use crate::{builder::AimDb, DbResult};
43
44/// Error shared by outbound record serialization operations.
45///
46/// Deliberately separate from the session `CodecError`, which describes
47/// failures in an envelope codec (AimX, WebSocket, and similar framed
48/// protocols). A link codec sits one layer higher: it turns a typed record into
49/// the opaque payload passed to a connector.
50///
51/// Uses an enum instead of `String` for predictable `no_std` behavior and
52/// `defmt` logging support on Embassy targets.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum SerializeError {
55 /// Output buffer is too small for the serialized data
56 BufferTooSmall,
57
58 /// Invalid data that cannot be serialized
59 InvalidData,
60}
61
62#[cfg(feature = "defmt")]
63impl defmt::Format for SerializeError {
64 fn format(&self, f: defmt::Formatter) {
65 match self {
66 Self::BufferTooSmall => defmt::write!(f, "BufferTooSmall"),
67 Self::InvalidData => defmt::write!(f, "InvalidData"),
68 }
69 }
70}
71
72#[cfg(feature = "std")]
73impl std::fmt::Display for SerializeError {
74 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 match self {
76 Self::BufferTooSmall => write!(f, "Output buffer too small"),
77 Self::InvalidData => write!(f, "Invalid data for serialization"),
78 }
79 }
80}
81
82#[cfg(feature = "std")]
83impl std::error::Error for SerializeError {}
84
85/// One serialized record update, produced by a fused [`SerializedReader`]
86///
87/// Carries the wire payload plus the destination resolved by the link's
88/// [`TopicProvider`] while the typed value was still in hand — the last
89/// erasure crossing the old `topic_any(&dyn Any)` path required.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct SerializedValue {
92 /// Dynamic destination resolved by the link's `TopicProvider<T>`;
93 /// `None` means use the route's default topic (from the URL).
94 pub dest: Option<String>,
95 /// Wire payload from the link's serializer.
96 ///
97 /// `Vec<u8>` requires heap allocation; works on `std` and
98 /// `no_std + alloc` (not bare-metal without an allocator).
99 pub payload: Vec<u8>,
100}
101
102/// Location of a payload produced by [`SerializedReader::recv_into`].
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum SerializedPayload {
105 /// The initialized prefix of the caller-provided scratch buffer.
106 ///
107 /// A custom reader returning this variant must write the prefix during the
108 /// same `recv_into` call and its source must advertise a sufficient
109 /// [`SerializedSource::serializer_scratch_capacity`]. The pump validates
110 /// the length before publishing.
111 Scratch {
112 /// Number of initialized payload bytes in the scratch buffer.
113 len: usize,
114 },
115 /// Compatibility fallback from the existing `Vec` serializer.
116 Owned(Vec<u8>),
117}
118
119/// One serialized record produced into caller-owned scratch storage or an owned fallback.
120///
121/// No reference escapes the async reader call. The pump validates `len`, then
122/// borrows its own scratch buffer only for the subsequent `publish().await`.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct SerializedValueInto {
125 /// Dynamic destination resolved while the typed record was available.
126 pub dest: Option<String>,
127 /// Scratch prefix metadata or an owned compatibility payload.
128 pub payload: SerializedPayload,
129}
130
131/// Type alias for the future returned by [`SerializedReader::recv`]
132///
133/// Manual boxed future for object safety — same pattern as the rest of this
134/// module (`#[async_trait]` would drag in `std`).
135pub type RecvSerializedFuture<'a> =
136 Pin<Box<dyn Future<Output = DbResult<SerializedValue>> + Send + 'a>>;
137
138/// Future returned by [`SerializedReader::recv_into`].
139pub type RecvSerializedIntoFuture<'a> =
140 Pin<Box<dyn Future<Output = DbResult<SerializedValueInto>> + Send + 'a>>;
141
142/// A subscription to one record, fused with destination resolution and
143/// serialization at registration time — no `dyn Any` crosses this boundary
144///.
145pub trait SerializedReader: Send {
146 /// Yield the next successfully serialized value.
147 ///
148 /// `ctx` is threaded per call (not captured) for context-aware
149 /// serializers. Buffer errors propagate unchanged:
150 /// `DbError::BufferLagged` means values were skipped but the reader
151 /// recovered; any other error means the buffer is gone. Serialization
152 /// failures are logged and skipped inside the reader.
153 fn recv<'a>(&'a mut self, ctx: &'a crate::RuntimeContext) -> RecvSerializedFuture<'a>;
154
155 /// Yield the next serialized value into caller-owned scratch storage.
156 ///
157 /// Third-party readers remain source-compatible: the default adapter calls
158 /// [`recv`](Self::recv) and returns its `Vec<u8>` as
159 /// [`SerializedPayload::Owned`]. AimDB's fused reader overrides this method
160 /// when an into-slice serializer was registered.
161 fn recv_into<'a>(
162 &'a mut self,
163 ctx: &'a crate::RuntimeContext,
164 _scratch: &'a mut [u8],
165 ) -> RecvSerializedIntoFuture<'a> {
166 Box::pin(async move {
167 let value = self.recv(ctx).await?;
168 Ok(SerializedValueInto {
169 dest: value.dest,
170 payload: SerializedPayload::Owned(value.payload),
171 })
172 })
173 }
174}
175
176/// A record's outbound wire interface, built where the record type `T` is
177/// known (`OutboundConnectorBuilder::finish`) and consumed by the pumps as
178/// bytes. Replaces the erased `ConsumerTrait` + serializer + topic-provider
179/// triple.
180pub trait SerializedSource: Send + Sync {
181 /// Scratch capacity requested by this source's into-slice serializer.
182 ///
183 /// `None` means the source only supports the existing owned serializer. A
184 /// source whose reader can return [`SerializedPayload::Scratch`] must
185 /// return `Some(capacity)` here so the route pump provides that storage.
186 fn serializer_scratch_capacity(&self) -> Option<usize> {
187 None
188 }
189
190 /// Subscribe to the record's updates.
191 ///
192 /// Synchronous and infallible — the buffer handle is pre-resolved at
193 /// construction.
194 fn subscribe(&self) -> Box<dyn SerializedReader>;
195}
196
197/// Type alias for source factory callback (alloc feature)
198///
199/// Takes the live [`AimDb`] and returns the fused [`SerializedSource`].
200/// This allows capturing the record type T at link_to() time while storing
201/// the factory in a type-erased ConnectorLink. The factory runs once at
202/// route-collection time, not per message.
203///
204/// Available in both `std` and `no_std + alloc` environments.
205pub type SourceFactoryFn = Arc<dyn Fn(&AimDb) -> Box<dyn SerializedSource> + Send + Sync>;
206
207// ============================================================================
208// TopicProvider - Dynamic topic/destination routing
209// ============================================================================
210
211/// Trait for dynamic topic providers (outbound only)
212///
213/// Implement this trait to dynamically determine MQTT topics (or KNX group addresses)
214/// based on the data being published. This enables reusable routing logic that
215/// can be shared across multiple record types.
216///
217/// # Type Safety
218///
219/// The trait is generic over `T`, providing compile-time type safety
220/// at the implementation site. The provider stays typed end-to-end: it is
221/// fused into the link's [`SerializedSource`] at registration time and
222/// called with `&T` while the value is in hand.
223///
224/// # no_std Compatibility
225///
226/// Works in both `std` and `no_std + alloc` environments.
227///
228/// # Example
229///
230/// ```rust
231/// use aimdb_core::connector::TopicProvider;
232/// # #[derive(Clone, Debug)] struct Temperature { sensor_id: u32 }
233///
234/// struct SensorTopicProvider;
235///
236/// impl TopicProvider<Temperature> for SensorTopicProvider {
237/// fn topic(&self, value: &Temperature) -> Option<String> {
238/// Some(format!("sensors/temp/{}", value.sensor_id))
239/// }
240/// }
241/// ```
242pub trait TopicProvider<T>: Send + Sync {
243 /// Determine the topic/destination for a given value
244 ///
245 /// Returns `Some(topic)` to use a dynamic topic, or `None` to fall back
246 /// to the static topic from the `link_to()` URL.
247 fn topic(&self, value: &T) -> Option<String>;
248}
249
250/// Address of a record link: `scheme://resource` (e.g. `mqtt://sensors/temp`).
251///
252/// A link address names a topic/resource on a connector's already-configured
253/// endpoint — it is *not* a URL: there is no host, port, or credential
254/// component (the connector constructor takes the endpoint
255/// [`ConnectorUrl`]). The scheme selects which registered connector serves
256/// the link; the resource is passed to it verbatim (e.g. an MQTT topic, a
257/// KNX group address).
258///
259/// A trailing `?key=value` query is accepted and ignored; per-link options
260/// are passed via `.with_config()` / `.with_timeout_ms()`.
261#[derive(Clone, Debug, PartialEq)]
262pub struct LinkAddress {
263 scheme: String,
264 resource: String,
265}
266
267impl LinkAddress {
268 /// Parses a `scheme://resource` link address.
269 ///
270 /// # Example
271 ///
272 /// ```rust
273 /// use aimdb_core::connector::LinkAddress;
274 ///
275 /// let addr = LinkAddress::parse("mqtt://sensors/temp").unwrap();
276 /// assert_eq!(addr.scheme(), "mqtt");
277 /// assert_eq!(addr.resource_id(), "sensors/temp");
278 /// ```
279 pub fn parse(s: &str) -> DbResult<Self> {
280 use crate::DbError;
281
282 let (scheme, rest) = s
283 .split_once("://")
284 .ok_or_else(|| DbError::InvalidOperation {
285 operation: "LinkAddress::parse".into(),
286 reason: alloc::format!("link address '{s}' missing '://' separator"),
287 })?;
288
289 // Per-link options travel via .with_config(); a query here is legal
290 // input but carries no meaning — strip it.
291 let resource = rest.split_once('?').map(|(r, _)| r).unwrap_or(rest);
292 let resource = resource.trim_start_matches('/');
293
294 if scheme.is_empty() || resource.is_empty() {
295 return Err(DbError::InvalidOperation {
296 operation: "LinkAddress::parse".into(),
297 reason: alloc::format!("link address '{s}' needs both a scheme and a resource"),
298 });
299 }
300
301 Ok(Self {
302 scheme: scheme.to_string(),
303 resource: resource.to_string(),
304 })
305 }
306
307 /// Returns the scheme (selects the registered connector, e.g. `"mqtt"`).
308 pub fn scheme(&self) -> &str {
309 &self.scheme
310 }
311
312 /// Returns the resource identifier the connector addresses (topic, group
313 /// address, path — passed to the connector verbatim).
314 pub fn resource_id(&self) -> &str {
315 &self.resource
316 }
317}
318
319impl fmt::Display for LinkAddress {
320 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
321 write!(f, "{}://{}", self.scheme, self.resource)
322 }
323}
324
325/// Parsed connector *endpoint* URL with protocol, host, port, and credentials
326///
327/// This is the URL a connector is constructed with (broker/gateway/server
328/// endpoint) — record links use the far simpler [`LinkAddress`] instead.
329/// The parser is scheme-agnostic: any `scheme://…` URL parses. Connectors in
330/// this workspace use e.g.:
331/// - MQTT: `mqtt://host:port`, `mqtts://host:port`
332/// - KNX: `knx://gateway:3671`
333/// - WebSocket: `ws://host:port/path`, `wss://host:port/path`
334#[derive(Clone, Debug, PartialEq)]
335pub struct ConnectorUrl {
336 /// Protocol scheme (e.g. mqtt, mqtts, knx, ws, wss, uds, serial)
337 pub scheme: String,
338
339 /// Host, or a comma-separated host list (preserved verbatim)
340 pub host: String,
341
342 /// Port number (optional, protocol-specific defaults)
343 pub port: Option<u16>,
344
345 /// Path component (optional)
346 pub path: Option<String>,
347
348 /// Username for authentication (optional)
349 pub username: Option<String>,
350
351 /// Password for authentication (optional)
352 pub password: Option<String>,
353
354 /// Query parameters (optional, parsed from URL)
355 pub query_params: Vec<(String, String)>,
356}
357
358impl ConnectorUrl {
359 /// Parses a connector URL string
360 ///
361 /// # Supported Formats
362 ///
363 /// - `mqtt://host:port`
364 /// - `mqtt://user:pass@host:port`
365 /// - `mqtts://host:port` (TLS)
366 /// - `knx://gateway:3671`
367 /// - `ws://host:port/path?key=value` (WebSocket)
368 /// - `wss://host:port/path` (WebSocket Secure)
369 ///
370 /// Any other `scheme://…` parses the same way; comma-separated host
371 /// lists are preserved verbatim in [`host`](ConnectorUrl::host).
372 ///
373 /// # Example
374 ///
375 /// ```rust
376 /// use aimdb_core::connector::ConnectorUrl;
377 ///
378 /// let url = ConnectorUrl::parse("mqtt://user:pass@broker.example.com:1883").unwrap();
379 /// assert_eq!(url.scheme, "mqtt");
380 /// assert_eq!(url.host, "broker.example.com");
381 /// assert_eq!(url.port, Some(1883));
382 /// assert_eq!(url.username, Some("user".to_string()));
383 /// ```
384 pub fn parse(url: &str) -> DbResult<Self> {
385 parse_connector_url(url)
386 }
387
388 /// Returns the default port for this protocol scheme
389 pub fn default_port(&self) -> Option<u16> {
390 match self.scheme.as_str() {
391 "mqtt" | "ws" => Some(1883),
392 "mqtts" | "wss" => Some(8883),
393 _ => None,
394 }
395 }
396
397 /// Returns the effective port (explicit or default)
398 pub fn effective_port(&self) -> Option<u16> {
399 self.port.or_else(|| self.default_port())
400 }
401
402 /// Returns true if this is a secure connection (TLS)
403 pub fn is_secure(&self) -> bool {
404 matches!(self.scheme.as_str(), "mqtts" | "https" | "wss")
405 }
406
407 /// Returns the URL scheme (protocol)
408 pub fn scheme(&self) -> &str {
409 &self.scheme
410 }
411
412 /// Returns the path component, or "/" if not specified
413 pub fn path(&self) -> &str {
414 self.path.as_deref().unwrap_or("/")
415 }
416}
417
418impl fmt::Display for ConnectorUrl {
419 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
420 write!(f, "{}://", self.scheme)?;
421
422 if let Some(ref username) = self.username {
423 write!(f, "{}", username)?;
424 if self.password.is_some() {
425 write!(f, ":****")?; // Don't expose password in Display
426 }
427 write!(f, "@")?;
428 }
429
430 write!(f, "{}", self.host)?;
431
432 if let Some(port) = self.port {
433 write!(f, ":{}", port)?;
434 }
435
436 if let Some(ref path) = self.path {
437 if !path.starts_with('/') {
438 write!(f, "/")?;
439 }
440 write!(f, "{}", path)?;
441 }
442
443 Ok(())
444 }
445}
446
447/// Configuration for a connector link
448///
449/// Stores the parsed URL, configuration, and the fused source factory until
450/// the record is built. The actual client creation and handler spawning
451/// happens during the build phase.
452#[derive(Clone)]
453pub struct ConnectorLink {
454 /// Parsed link address (`scheme://resource`)
455 pub url: LinkAddress,
456
457 /// Additional configuration options (protocol-specific)
458 pub config: Vec<(String, String)>,
459
460 /// Fused source factory (alloc feature)
461 ///
462 /// Takes the live [`AimDb`] and returns the [`SerializedSource`] whose
463 /// readers yield destination + payload directly (subscribe → recv →
464 /// resolve topic → serialize, all typed inside). Captures the record
465 /// type T at link_to() configuration time — `finish()` validates the
466 /// serializer is present before registering the link, so the factory is
467 /// always set.
468 ///
469 /// Available in both `std` and `no_std + alloc` environments.
470 pub source_factory: SourceFactoryFn,
471}
472
473impl Debug for ConnectorLink {
474 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
475 f.debug_struct("ConnectorLink")
476 .field("url", &self.url)
477 .field("config", &self.config)
478 .field("source_factory", &"<factory>")
479 .finish()
480 }
481}
482
483impl ConnectorLink {
484 /// Creates a new connector link from a link address and source factory
485 pub fn new(url: LinkAddress, source_factory: SourceFactoryFn) -> Self {
486 Self {
487 url,
488 config: Vec::new(),
489 source_factory,
490 }
491 }
492
493 /// Creates the fused serialized source using the stored factory.
494 ///
495 /// Runs once at route-collection time; the readers it hands out are the
496 /// per-message path (no `Box<dyn Any>`).
497 ///
498 /// Available in both `std` and `no_std + alloc` environments.
499 pub fn create_source(&self, db: &AimDb) -> Box<dyn SerializedSource> {
500 (self.source_factory)(db)
501 }
502}
503
504/// Fused inbound ingest callback: deserialize + produce in one typed closure
505///
506/// Built where the record type `T` is known (`InboundConnectorBuilder::finish`),
507/// so no `Box<dyn Any>` crosses the connector boundary per message. The closure
508/// captures the typed producer and deserializer; callers only see bytes.
509///
510/// Synchronous by design: `Producer<T>::produce` is sync and infallible
511///, so the only failure is the user
512/// deserializer's — reported as the same `String` the deserializer API uses.
513///
514/// The [`RuntimeContext`](crate::RuntimeContext) is threaded per call (not
515/// captured) for context-aware deserializers.
516pub type IngestFn = Arc<dyn Fn(&crate::RuntimeContext, &[u8]) -> Result<(), String> + Send + Sync>;
517
518/// Type alias for ingest factory callback (alloc feature)
519///
520/// Takes the live [`AimDb`] and returns the fused [`IngestFn`]. This allows
521/// capturing the record type T at link_from() time while storing the factory
522/// in a type-erased InboundConnectorLink. The factory runs once at
523/// route-collection time, not per message.
524///
525/// Available in both `std` and `no_std + alloc` environments.
526pub type IngestFactoryFn = Arc<dyn Fn(&AimDb) -> IngestFn + Send + Sync>;
527
528/// Topic resolver function for inbound connections (late-binding)
529///
530/// Called once at connector startup to resolve the subscription topic.
531/// Returns `Some(topic)` to use a dynamic topic, or `None` to fall back
532/// to the static topic from the `link_from()` URL.
533///
534/// # Use Cases
535///
536/// - Topics determined from smart contracts at runtime
537/// - Service discovery integration
538/// - Environment-specific topic configuration
539/// - Topics read from configuration files or databases
540///
541/// # no_std Compatibility
542///
543/// Works in both `std` and `no_std + alloc` environments.
544pub type TopicResolverFn = Arc<dyn Fn() -> Option<String> + Send + Sync>;
545
546/// Configuration for an inbound connector link (External → AimDB)
547///
548/// Stores the parsed URL, configuration, and the fused ingest factory. The
549/// factory captures the type T at creation time, allowing type-safe
550/// deserialize+produce later without needing PhantomData or type parameters.
551#[derive(Clone)]
552pub struct InboundConnectorLink {
553 /// Parsed link address (`scheme://resource`)
554 pub url: LinkAddress,
555
556 /// Additional configuration options (protocol-specific)
557 pub config: Vec<(String, String)>,
558
559 /// Fused ingest factory (alloc feature)
560 ///
561 /// Takes the live [`AimDb`] and returns the [`IngestFn`] that
562 /// deserializes bytes and produces into the record's buffer in one typed
563 /// closure. Captures the record type T at link_from() call time —
564 /// `finish()` validates the deserializer is present before registering
565 /// the link, so the factory is always set.
566 ///
567 /// Available in both `std` and `no_std + alloc` environments.
568 pub ingest_factory: IngestFactoryFn,
569
570 /// Optional dynamic topic resolver (late-binding)
571 ///
572 /// Called once at connector startup to determine the subscription topic.
573 /// If the resolver returns `None`, the static topic from the URL is used.
574 ///
575 /// Available in both `std` and `no_std + alloc` environments.
576 pub topic_resolver: Option<TopicResolverFn>,
577}
578
579impl Debug for InboundConnectorLink {
580 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
581 f.debug_struct("InboundConnectorLink")
582 .field("url", &self.url)
583 .field("config", &self.config)
584 .field("ingest_factory", &"<factory>")
585 .field(
586 "topic_resolver",
587 &self.topic_resolver.as_ref().map(|_| "<function>"),
588 )
589 .finish()
590 }
591}
592
593impl InboundConnectorLink {
594 /// Creates a new inbound connector link from a link address and ingest factory
595 pub fn new(url: LinkAddress, ingest_factory: IngestFactoryFn) -> Self {
596 Self {
597 url,
598 config: Vec::new(),
599 ingest_factory,
600 topic_resolver: None,
601 }
602 }
603
604 /// Creates the fused ingest callback using the stored factory.
605 ///
606 /// Runs once at route-collection time; the returned [`IngestFn`] is the
607 /// per-message path (deserialize + produce, no erasure crossing).
608 ///
609 /// Available in both `std` and `no_std + alloc` environments.
610 pub fn create_ingest(&self, db: &AimDb) -> IngestFn {
611 (self.ingest_factory)(db)
612 }
613
614 /// Resolves the subscription topic for this link
615 ///
616 /// If a topic resolver is configured, calls it to determine the topic.
617 /// Otherwise, returns the static topic from the URL.
618 ///
619 /// This is called once at connector startup.
620 pub fn resolve_topic(&self) -> String {
621 self.topic_resolver
622 .as_ref()
623 .and_then(|resolver| resolver())
624 .unwrap_or_else(|| self.url.resource_id().to_string())
625 }
626}
627
628/// Parses a connector URL string into structured components
629///
630/// This is a simple parser that handles the most common URL formats.
631/// For production use, consider using the `url` crate with feature flags.
632fn parse_connector_url(url: &str) -> DbResult<ConnectorUrl> {
633 use crate::DbError;
634
635 // Split scheme from rest
636 let (scheme, rest) = url
637 .split_once("://")
638 .ok_or_else(|| DbError::InvalidOperation {
639 operation: "parse_connector_url".into(),
640 reason: format!("Missing scheme in URL: {}", url),
641 })?;
642
643 // Extract credentials if present (user:pass@host)
644 let (credentials, host_part) = if let Some(at_idx) = rest.find('@') {
645 let creds = &rest[..at_idx];
646 let host = &rest[at_idx + 1..];
647 (Some(creds), host)
648 } else {
649 (None, rest)
650 };
651
652 let (username, password) = if let Some(creds) = credentials {
653 if let Some((user, pass)) = creds.split_once(':') {
654 (Some(user.to_string()), Some(pass.to_string()))
655 } else {
656 (Some(creds.to_string()), None)
657 }
658 } else {
659 (None, None)
660 };
661
662 // Split path and query from host:port
663 let (host_port, path, query_params) = if let Some(slash_idx) = host_part.find('/') {
664 let hp = &host_part[..slash_idx];
665 let path_query = &host_part[slash_idx..];
666
667 // Split query parameters
668 let (path_part, query_part) = if let Some(q_idx) = path_query.find('?') {
669 (&path_query[..q_idx], Some(&path_query[q_idx + 1..]))
670 } else {
671 (path_query, None)
672 };
673
674 // Parse query parameters
675 let params = if let Some(query) = query_part {
676 query
677 .split('&')
678 .filter_map(|pair| {
679 let (k, v) = pair.split_once('=')?;
680 Some((k.to_string(), v.to_string()))
681 })
682 .collect()
683 } else {
684 Vec::new()
685 };
686
687 (hp, Some(path_part.to_string()), params)
688 } else {
689 (host_part, None, Vec::new())
690 };
691
692 // Split host and port
693 let (host, port) = if let Some(colon_idx) = host_port.rfind(':') {
694 let h = &host_port[..colon_idx];
695 let p = &host_port[colon_idx + 1..];
696 let port_num = p.parse::<u16>().ok();
697 (h.to_string(), port_num)
698 } else {
699 (host_port.to_string(), None)
700 };
701
702 Ok(ConnectorUrl {
703 scheme: scheme.to_string(),
704 host,
705 port,
706 path,
707 username,
708 password,
709 query_params,
710 })
711}
712
713/// Trait for building connectors after the database is constructed
714///
715/// Connectors that need to collect routes from the database (for inbound routing)
716/// implement this trait. The builder pattern allows connectors to be constructed
717/// in two phases:
718///
719/// 1. Configuration phase: User provides broker URLs and settings
720/// 2. Build phase: Connector collects routes from the database and initializes
721///
722/// # Example
723///
724/// Illustrative sketch of a connector author's `build()` (not compiled: the
725/// client types are fictional — see `aimdb-mqtt-connector` for a real one):
726///
727/// ```rust,ignore
728/// pub struct MqttConnectorBuilder {
729/// broker_url: String,
730/// }
731///
732/// impl ConnectorBuilder for MqttConnectorBuilder {
733/// fn build<'a>(
734/// &'a self,
735/// db: &'a AimDb,
736/// ) -> Pin<Box<dyn Future<Output = DbResult<Vec<BoxFuture>>> + Send + 'a>> {
737/// Box::pin(async move {
738/// let routes = db.collect_inbound_routes(self.scheme());
739/// let router = RouterBuilder::from_routes(routes).build();
740/// let connector = MqttConnector::new(&self.broker_url, router).await?;
741/// Ok(connector.futures())
742/// })
743/// }
744///
745/// fn scheme(&self) -> &str {
746/// "mqtt"
747/// }
748/// }
749/// ```
750pub trait ConnectorBuilder: Send + Sync {
751 /// Build the connector and return its driving futures.
752 ///
753 /// Called during `AimDbBuilder::build()` after the database has been
754 /// constructed. The returned futures (infrastructure loops + per-route
755 /// publishers) are appended to the builder's accumulator and driven by
756 /// `AimDbRunner::run()`.
757 ///
758 /// # Arguments
759 /// * `db` - The constructed database instance
760 ///
761 /// # Returns
762 /// All `BoxFuture`s the connector needs to operate. Empty if the connector
763 /// has no work to drive.
764 #[allow(clippy::type_complexity)]
765 fn build<'a>(
766 &'a self,
767 db: &'a AimDb,
768 ) -> Pin<
769 Box<
770 dyn Future<Output = DbResult<Vec<Pin<Box<dyn Future<Output = ()> + Send + 'static>>>>>
771 + Send
772 + 'a,
773 >,
774 >;
775
776 /// The URL scheme this connector handles
777 ///
778 /// Returns the scheme (e.g., "mqtt", "knx", "uds") that this connector
779 /// will be registered under. Used for routing `.link_from()` and `.link_to()`
780 /// declarations to the appropriate connector.
781 fn scheme(&self) -> &str;
782
783 /// Whether registering a second connector under this scheme is an error.
784 ///
785 /// Say `true` when [`build`](Self::build) claims every route for its
786 /// scheme — [`collect_inbound_routes`](crate::AimDb::collect_inbound_routes),
787 /// [`collect_outbound_routes`](crate::AimDb::collect_outbound_routes),
788 /// and `crate::session`'s `pump_source`, `pump_sink` and `pump_client`
789 /// (left unlinked: that module is behind `connector-session`, and this
790 /// trait is not) all filter by scheme alone, so two such connectors each
791 /// collect *all* of it: every `link_to` gets two publishers, and the routes
792 /// cannot be divided between the two endpoints because nothing in a route
793 /// names which connector it belongs to. That misconfiguration is otherwise
794 /// silent, and it fails as duplicated or misdirected traffic at runtime
795 /// rather than at build.
796 ///
797 /// Leave it `false` — the default — for a connector that only serves what
798 /// it is given, such as a session *server*: it binds its own listener and
799 /// collects no routes, so two of them under one scheme are two endpoints
800 /// onto the same dispatch, which is useful rather than broken.
801 fn owns_scheme(&self) -> bool {
802 false
803 }
804}
805
806#[cfg(test)]
807mod tests {
808 use super::*;
809 use alloc::format;
810
811 #[test]
812 fn test_parse_simple_mqtt() {
813 let url = ConnectorUrl::parse("mqtt://broker.example.com:1883").unwrap();
814 assert_eq!(url.scheme, "mqtt");
815 assert_eq!(url.host, "broker.example.com");
816 assert_eq!(url.port, Some(1883));
817 assert_eq!(url.username, None);
818 assert_eq!(url.password, None);
819 }
820
821 #[test]
822 fn test_parse_mqtt_with_credentials() {
823 let url = ConnectorUrl::parse("mqtt://user:pass@broker.example.com:1883").unwrap();
824 assert_eq!(url.scheme, "mqtt");
825 assert_eq!(url.host, "broker.example.com");
826 assert_eq!(url.port, Some(1883));
827 assert_eq!(url.username, Some("user".to_string()));
828 assert_eq!(url.password, Some("pass".to_string()));
829 }
830
831 #[test]
832 fn test_parse_https_with_path() {
833 let url = ConnectorUrl::parse("https://api.example.com:8443/events").unwrap();
834 assert_eq!(url.scheme, "https");
835 assert_eq!(url.host, "api.example.com");
836 assert_eq!(url.port, Some(8443));
837 assert_eq!(url.path, Some("/events".to_string()));
838 }
839
840 #[test]
841 fn test_parse_with_query_params() {
842 let url = ConnectorUrl::parse("http://api.example.com/data?key=value&foo=bar").unwrap();
843 assert_eq!(url.scheme, "http");
844 assert_eq!(url.host, "api.example.com");
845 assert_eq!(url.path, Some("/data".to_string()));
846 assert_eq!(url.query_params.len(), 2);
847 assert_eq!(
848 url.query_params[0],
849 ("key".to_string(), "value".to_string())
850 );
851 assert_eq!(url.query_params[1], ("foo".to_string(), "bar".to_string()));
852 }
853
854 #[test]
855 fn test_default_ports() {
856 let mqtt = ConnectorUrl::parse("mqtt://broker.local").unwrap();
857 assert_eq!(mqtt.default_port(), Some(1883));
858 assert_eq!(mqtt.effective_port(), Some(1883));
859
860 let mqtts = ConnectorUrl::parse("mqtts://broker.local").unwrap();
861 assert_eq!(mqtts.default_port(), Some(8883));
862
863 // No connector, no default: unknown schemes carry no port opinion.
864 let knx = ConnectorUrl::parse("knx://gateway.local").unwrap();
865 assert_eq!(knx.default_port(), None);
866 }
867
868 #[test]
869 fn test_is_secure() {
870 assert!(ConnectorUrl::parse("mqtts://broker.local")
871 .unwrap()
872 .is_secure());
873 assert!(ConnectorUrl::parse("https://api.example.com")
874 .unwrap()
875 .is_secure());
876 assert!(ConnectorUrl::parse("wss://ws.example.com")
877 .unwrap()
878 .is_secure());
879
880 assert!(!ConnectorUrl::parse("mqtt://broker.local")
881 .unwrap()
882 .is_secure());
883 assert!(!ConnectorUrl::parse("http://api.example.com")
884 .unwrap()
885 .is_secure());
886 assert!(!ConnectorUrl::parse("ws://ws.example.com")
887 .unwrap()
888 .is_secure());
889 }
890
891 #[test]
892 fn test_display_hides_password() {
893 let url = ConnectorUrl::parse("mqtt://user:secret@broker.local:1883").unwrap();
894 let display = format!("{}", url);
895 assert!(display.contains("user:****"));
896 assert!(!display.contains("secret"));
897 }
898
899 #[test]
900 fn test_parse_kafka_style() {
901 let url =
902 ConnectorUrl::parse("kafka://broker1.local:9092,broker2.local:9092/my-topic").unwrap();
903 assert_eq!(url.scheme, "kafka");
904 // Note: Our simple parser doesn't handle the second port in comma-separated hosts perfectly
905 // It parses "broker1.local:9092,broker2.local" as the host and "9092" as the port
906 // This is acceptable for now - production connectors can handle this in their client factories
907 assert!(url.host.contains("broker1.local"));
908 assert!(url.host.contains("broker2.local"));
909 assert_eq!(url.path, Some("/my-topic".to_string()));
910 }
911
912 #[test]
913 fn test_parse_missing_scheme() {
914 let result = ConnectorUrl::parse("broker.example.com:1883");
915 assert!(result.is_err());
916 }
917
918 // ========================================================================
919 // TopicProvider Tests
920 // ========================================================================
921
922 #[allow(dead_code)]
923 #[derive(Debug, Clone)]
924 struct TestTemperature {
925 sensor_id: String,
926 celsius: f32,
927 }
928
929 struct TestTopicProvider;
930
931 impl super::TopicProvider<TestTemperature> for TestTopicProvider {
932 fn topic(&self, value: &TestTemperature) -> Option<String> {
933 Some(format!("sensors/temp/{}", value.sensor_id))
934 }
935 }
936
937 #[test]
938 fn test_topic_provider_as_trait_object() {
939 // Providers are stored as Arc<dyn TopicProvider<T>> — typed, no
940 // erasure.
941 let provider: Arc<dyn super::TopicProvider<TestTemperature>> = Arc::new(TestTopicProvider);
942 let temp = TestTemperature {
943 sensor_id: "kitchen-001".into(),
944 celsius: 22.5,
945 };
946
947 assert_eq!(
948 provider.topic(&temp),
949 Some("sensors/temp/kitchen-001".into())
950 );
951 }
952
953 #[test]
954 fn test_topic_provider_returns_none() {
955 struct OptionalTopicProvider;
956
957 impl super::TopicProvider<TestTemperature> for OptionalTopicProvider {
958 fn topic(&self, temp: &TestTemperature) -> Option<String> {
959 if temp.sensor_id.is_empty() {
960 None // Fall back to default topic
961 } else {
962 Some(format!("sensors/{}", temp.sensor_id))
963 }
964 }
965 }
966
967 let provider: Arc<dyn super::TopicProvider<TestTemperature>> =
968 Arc::new(OptionalTopicProvider);
969
970 // Non-empty sensor_id returns dynamic topic
971 let temp_with_id = TestTemperature {
972 sensor_id: "abc".into(),
973 celsius: 20.0,
974 };
975 assert_eq!(provider.topic(&temp_with_id), Some("sensors/abc".into()));
976
977 // Empty sensor_id returns None (fallback)
978 let temp_without_id = TestTemperature {
979 sensor_id: String::new(),
980 celsius: 20.0,
981 };
982 assert_eq!(provider.topic(&temp_without_id), None);
983 }
984
985 // ========================================================================
986 // TopicResolverFn Tests
987 // ========================================================================
988
989 #[test]
990 fn test_topic_resolver_returns_some() {
991 let resolver: super::TopicResolverFn = Arc::new(|| Some("resolved/topic".into()));
992
993 assert_eq!(resolver(), Some("resolved/topic".into()));
994 }
995
996 #[test]
997 fn test_topic_resolver_returns_none() {
998 let resolver: super::TopicResolverFn = Arc::new(|| None);
999
1000 // Returns None, should fall back to default topic
1001 assert_eq!(resolver(), None);
1002 }
1003
1004 #[cfg(feature = "std")]
1005 #[test]
1006 fn test_topic_resolver_with_captured_state() {
1007 use std::sync::Mutex;
1008
1009 let config = Arc::new(Mutex::new(Some("dynamic/topic".to_string())));
1010 let config_clone = config.clone();
1011
1012 let resolver: super::TopicResolverFn =
1013 Arc::new(move || config_clone.lock().unwrap().clone());
1014
1015 assert_eq!(resolver(), Some("dynamic/topic".into()));
1016
1017 // Clear config
1018 *config.lock().unwrap() = None;
1019 assert_eq!(resolver(), None);
1020 }
1021
1022 /// Dummy ingest factory for link-construction tests (never invoked).
1023 fn dummy_ingest_factory() -> super::IngestFactoryFn {
1024 Arc::new(|_db| Arc::new(|_ctx: &crate::RuntimeContext, _bytes: &[u8]| Ok(())))
1025 }
1026
1027 #[test]
1028 fn test_inbound_connector_link_resolve_topic_default() {
1029 use super::{InboundConnectorLink, LinkAddress};
1030
1031 let url = LinkAddress::parse("mqtt://sensors/temperature").unwrap();
1032 let link = InboundConnectorLink::new(url, dummy_ingest_factory());
1033
1034 // No resolver configured, should return static topic from URL
1035 assert_eq!(link.resolve_topic(), "sensors/temperature");
1036 }
1037
1038 #[test]
1039 fn test_inbound_connector_link_resolve_topic_dynamic() {
1040 use super::{InboundConnectorLink, LinkAddress};
1041
1042 let url = LinkAddress::parse("mqtt://sensors/default").unwrap();
1043 let mut link = InboundConnectorLink::new(url, dummy_ingest_factory());
1044
1045 // Configure dynamic resolver
1046 link.topic_resolver = Some(Arc::new(|| Some("sensors/dynamic/kitchen".into())));
1047
1048 // Should return resolved topic, not URL topic
1049 assert_eq!(link.resolve_topic(), "sensors/dynamic/kitchen");
1050 }
1051
1052 #[test]
1053 fn test_inbound_connector_link_resolve_topic_fallback() {
1054 use super::{InboundConnectorLink, LinkAddress};
1055
1056 let url = LinkAddress::parse("mqtt://sensors/fallback").unwrap();
1057 let mut link = InboundConnectorLink::new(url, dummy_ingest_factory());
1058
1059 // Configure resolver that returns None
1060 link.topic_resolver = Some(Arc::new(|| None));
1061
1062 // Should fall back to static topic from URL
1063 assert_eq!(link.resolve_topic(), "sensors/fallback");
1064 }
1065}