use core::fmt::{self, Debug};
use core::future::Future;
use core::pin::Pin;
use alloc::{
boxed::Box,
string::{String, ToString},
sync::Arc,
vec::Vec,
};
use alloc::format;
use crate::{builder::AimDb, DbResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SerializeError {
BufferTooSmall,
InvalidData,
}
#[cfg(feature = "defmt")]
impl defmt::Format for SerializeError {
fn format(&self, f: defmt::Formatter) {
match self {
Self::BufferTooSmall => defmt::write!(f, "BufferTooSmall"),
Self::InvalidData => defmt::write!(f, "InvalidData"),
}
}
}
#[cfg(feature = "std")]
impl std::fmt::Display for SerializeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BufferTooSmall => write!(f, "Output buffer too small"),
Self::InvalidData => write!(f, "Invalid data for serialization"),
}
}
}
#[cfg(feature = "std")]
impl std::error::Error for SerializeError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SerializedValue {
pub dest: Option<String>,
pub payload: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SerializedPayload {
Scratch {
len: usize,
},
Owned(Vec<u8>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SerializedValueInto {
pub dest: Option<String>,
pub payload: SerializedPayload,
}
pub type RecvSerializedFuture<'a> =
Pin<Box<dyn Future<Output = DbResult<SerializedValue>> + Send + 'a>>;
pub type RecvSerializedIntoFuture<'a> =
Pin<Box<dyn Future<Output = DbResult<SerializedValueInto>> + Send + 'a>>;
pub trait SerializedReader: Send {
fn recv<'a>(&'a mut self, ctx: &'a crate::RuntimeContext) -> RecvSerializedFuture<'a>;
fn recv_into<'a>(
&'a mut self,
ctx: &'a crate::RuntimeContext,
_scratch: &'a mut [u8],
) -> RecvSerializedIntoFuture<'a> {
Box::pin(async move {
let value = self.recv(ctx).await?;
Ok(SerializedValueInto {
dest: value.dest,
payload: SerializedPayload::Owned(value.payload),
})
})
}
}
pub trait SerializedSource: Send + Sync {
fn serializer_scratch_capacity(&self) -> Option<usize> {
None
}
fn subscribe(&self) -> Box<dyn SerializedReader>;
}
pub type SourceFactoryFn = Arc<dyn Fn(&AimDb) -> Box<dyn SerializedSource> + Send + Sync>;
pub trait TopicProvider<T>: Send + Sync {
fn topic(&self, value: &T) -> Option<String>;
}
#[derive(Clone, Debug, PartialEq)]
pub struct LinkAddress {
scheme: String,
resource: String,
}
impl LinkAddress {
pub fn parse(s: &str) -> DbResult<Self> {
use crate::DbError;
let (scheme, rest) = s
.split_once("://")
.ok_or_else(|| DbError::InvalidOperation {
operation: "LinkAddress::parse".into(),
reason: alloc::format!("link address '{s}' missing '://' separator"),
})?;
let resource = rest.split_once('?').map(|(r, _)| r).unwrap_or(rest);
let resource = resource.trim_start_matches('/');
if scheme.is_empty() || resource.is_empty() {
return Err(DbError::InvalidOperation {
operation: "LinkAddress::parse".into(),
reason: alloc::format!("link address '{s}' needs both a scheme and a resource"),
});
}
Ok(Self {
scheme: scheme.to_string(),
resource: resource.to_string(),
})
}
pub fn scheme(&self) -> &str {
&self.scheme
}
pub fn resource_id(&self) -> &str {
&self.resource
}
}
impl fmt::Display for LinkAddress {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}://{}", self.scheme, self.resource)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ConnectorUrl {
pub scheme: String,
pub host: String,
pub port: Option<u16>,
pub path: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
pub query_params: Vec<(String, String)>,
}
impl ConnectorUrl {
pub fn parse(url: &str) -> DbResult<Self> {
parse_connector_url(url)
}
pub fn default_port(&self) -> Option<u16> {
match self.scheme.as_str() {
"mqtt" | "ws" => Some(1883),
"mqtts" | "wss" => Some(8883),
_ => None,
}
}
pub fn effective_port(&self) -> Option<u16> {
self.port.or_else(|| self.default_port())
}
pub fn is_secure(&self) -> bool {
matches!(self.scheme.as_str(), "mqtts" | "https" | "wss")
}
pub fn scheme(&self) -> &str {
&self.scheme
}
pub fn path(&self) -> &str {
self.path.as_deref().unwrap_or("/")
}
}
impl fmt::Display for ConnectorUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}://", self.scheme)?;
if let Some(ref username) = self.username {
write!(f, "{}", username)?;
if self.password.is_some() {
write!(f, ":****")?; }
write!(f, "@")?;
}
write!(f, "{}", self.host)?;
if let Some(port) = self.port {
write!(f, ":{}", port)?;
}
if let Some(ref path) = self.path {
if !path.starts_with('/') {
write!(f, "/")?;
}
write!(f, "{}", path)?;
}
Ok(())
}
}
#[derive(Clone)]
pub struct ConnectorLink {
pub url: LinkAddress,
pub config: Vec<(String, String)>,
pub source_factory: SourceFactoryFn,
}
impl Debug for ConnectorLink {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ConnectorLink")
.field("url", &self.url)
.field("config", &self.config)
.field("source_factory", &"<factory>")
.finish()
}
}
impl ConnectorLink {
pub fn new(url: LinkAddress, source_factory: SourceFactoryFn) -> Self {
Self {
url,
config: Vec::new(),
source_factory,
}
}
pub fn create_source(&self, db: &AimDb) -> Box<dyn SerializedSource> {
(self.source_factory)(db)
}
}
pub type IngestFn = Arc<dyn Fn(&crate::RuntimeContext, &[u8]) -> Result<(), String> + Send + Sync>;
pub type IngestFactoryFn = Arc<dyn Fn(&AimDb) -> IngestFn + Send + Sync>;
pub type TopicResolverFn = Arc<dyn Fn() -> Option<String> + Send + Sync>;
#[derive(Clone)]
pub struct InboundConnectorLink {
pub url: LinkAddress,
pub config: Vec<(String, String)>,
pub ingest_factory: IngestFactoryFn,
pub topic_resolver: Option<TopicResolverFn>,
}
impl Debug for InboundConnectorLink {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("InboundConnectorLink")
.field("url", &self.url)
.field("config", &self.config)
.field("ingest_factory", &"<factory>")
.field(
"topic_resolver",
&self.topic_resolver.as_ref().map(|_| "<function>"),
)
.finish()
}
}
impl InboundConnectorLink {
pub fn new(url: LinkAddress, ingest_factory: IngestFactoryFn) -> Self {
Self {
url,
config: Vec::new(),
ingest_factory,
topic_resolver: None,
}
}
pub fn create_ingest(&self, db: &AimDb) -> IngestFn {
(self.ingest_factory)(db)
}
pub fn resolve_topic(&self) -> String {
self.topic_resolver
.as_ref()
.and_then(|resolver| resolver())
.unwrap_or_else(|| self.url.resource_id().to_string())
}
}
fn parse_connector_url(url: &str) -> DbResult<ConnectorUrl> {
use crate::DbError;
let (scheme, rest) = url
.split_once("://")
.ok_or_else(|| DbError::InvalidOperation {
operation: "parse_connector_url".into(),
reason: format!("Missing scheme in URL: {}", url),
})?;
let (credentials, host_part) = if let Some(at_idx) = rest.find('@') {
let creds = &rest[..at_idx];
let host = &rest[at_idx + 1..];
(Some(creds), host)
} else {
(None, rest)
};
let (username, password) = if let Some(creds) = credentials {
if let Some((user, pass)) = creds.split_once(':') {
(Some(user.to_string()), Some(pass.to_string()))
} else {
(Some(creds.to_string()), None)
}
} else {
(None, None)
};
let (host_port, path, query_params) = if let Some(slash_idx) = host_part.find('/') {
let hp = &host_part[..slash_idx];
let path_query = &host_part[slash_idx..];
let (path_part, query_part) = if let Some(q_idx) = path_query.find('?') {
(&path_query[..q_idx], Some(&path_query[q_idx + 1..]))
} else {
(path_query, None)
};
let params = if let Some(query) = query_part {
query
.split('&')
.filter_map(|pair| {
let (k, v) = pair.split_once('=')?;
Some((k.to_string(), v.to_string()))
})
.collect()
} else {
Vec::new()
};
(hp, Some(path_part.to_string()), params)
} else {
(host_part, None, Vec::new())
};
let (host, port) = if let Some(colon_idx) = host_port.rfind(':') {
let h = &host_port[..colon_idx];
let p = &host_port[colon_idx + 1..];
let port_num = p.parse::<u16>().ok();
(h.to_string(), port_num)
} else {
(host_port.to_string(), None)
};
Ok(ConnectorUrl {
scheme: scheme.to_string(),
host,
port,
path,
username,
password,
query_params,
})
}
pub trait ConnectorBuilder: Send + Sync {
#[allow(clippy::type_complexity)]
fn build<'a>(
&'a self,
db: &'a AimDb,
) -> Pin<
Box<
dyn Future<Output = DbResult<Vec<Pin<Box<dyn Future<Output = ()> + Send + 'static>>>>>
+ Send
+ 'a,
>,
>;
fn scheme(&self) -> &str;
fn owns_scheme(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::format;
#[test]
fn test_parse_simple_mqtt() {
let url = ConnectorUrl::parse("mqtt://broker.example.com:1883").unwrap();
assert_eq!(url.scheme, "mqtt");
assert_eq!(url.host, "broker.example.com");
assert_eq!(url.port, Some(1883));
assert_eq!(url.username, None);
assert_eq!(url.password, None);
}
#[test]
fn test_parse_mqtt_with_credentials() {
let url = ConnectorUrl::parse("mqtt://user:pass@broker.example.com:1883").unwrap();
assert_eq!(url.scheme, "mqtt");
assert_eq!(url.host, "broker.example.com");
assert_eq!(url.port, Some(1883));
assert_eq!(url.username, Some("user".to_string()));
assert_eq!(url.password, Some("pass".to_string()));
}
#[test]
fn test_parse_https_with_path() {
let url = ConnectorUrl::parse("https://api.example.com:8443/events").unwrap();
assert_eq!(url.scheme, "https");
assert_eq!(url.host, "api.example.com");
assert_eq!(url.port, Some(8443));
assert_eq!(url.path, Some("/events".to_string()));
}
#[test]
fn test_parse_with_query_params() {
let url = ConnectorUrl::parse("http://api.example.com/data?key=value&foo=bar").unwrap();
assert_eq!(url.scheme, "http");
assert_eq!(url.host, "api.example.com");
assert_eq!(url.path, Some("/data".to_string()));
assert_eq!(url.query_params.len(), 2);
assert_eq!(
url.query_params[0],
("key".to_string(), "value".to_string())
);
assert_eq!(url.query_params[1], ("foo".to_string(), "bar".to_string()));
}
#[test]
fn test_default_ports() {
let mqtt = ConnectorUrl::parse("mqtt://broker.local").unwrap();
assert_eq!(mqtt.default_port(), Some(1883));
assert_eq!(mqtt.effective_port(), Some(1883));
let mqtts = ConnectorUrl::parse("mqtts://broker.local").unwrap();
assert_eq!(mqtts.default_port(), Some(8883));
let knx = ConnectorUrl::parse("knx://gateway.local").unwrap();
assert_eq!(knx.default_port(), None);
}
#[test]
fn test_is_secure() {
assert!(ConnectorUrl::parse("mqtts://broker.local")
.unwrap()
.is_secure());
assert!(ConnectorUrl::parse("https://api.example.com")
.unwrap()
.is_secure());
assert!(ConnectorUrl::parse("wss://ws.example.com")
.unwrap()
.is_secure());
assert!(!ConnectorUrl::parse("mqtt://broker.local")
.unwrap()
.is_secure());
assert!(!ConnectorUrl::parse("http://api.example.com")
.unwrap()
.is_secure());
assert!(!ConnectorUrl::parse("ws://ws.example.com")
.unwrap()
.is_secure());
}
#[test]
fn test_display_hides_password() {
let url = ConnectorUrl::parse("mqtt://user:secret@broker.local:1883").unwrap();
let display = format!("{}", url);
assert!(display.contains("user:****"));
assert!(!display.contains("secret"));
}
#[test]
fn test_parse_kafka_style() {
let url =
ConnectorUrl::parse("kafka://broker1.local:9092,broker2.local:9092/my-topic").unwrap();
assert_eq!(url.scheme, "kafka");
assert!(url.host.contains("broker1.local"));
assert!(url.host.contains("broker2.local"));
assert_eq!(url.path, Some("/my-topic".to_string()));
}
#[test]
fn test_parse_missing_scheme() {
let result = ConnectorUrl::parse("broker.example.com:1883");
assert!(result.is_err());
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
struct TestTemperature {
sensor_id: String,
celsius: f32,
}
struct TestTopicProvider;
impl super::TopicProvider<TestTemperature> for TestTopicProvider {
fn topic(&self, value: &TestTemperature) -> Option<String> {
Some(format!("sensors/temp/{}", value.sensor_id))
}
}
#[test]
fn test_topic_provider_as_trait_object() {
let provider: Arc<dyn super::TopicProvider<TestTemperature>> = Arc::new(TestTopicProvider);
let temp = TestTemperature {
sensor_id: "kitchen-001".into(),
celsius: 22.5,
};
assert_eq!(
provider.topic(&temp),
Some("sensors/temp/kitchen-001".into())
);
}
#[test]
fn test_topic_provider_returns_none() {
struct OptionalTopicProvider;
impl super::TopicProvider<TestTemperature> for OptionalTopicProvider {
fn topic(&self, temp: &TestTemperature) -> Option<String> {
if temp.sensor_id.is_empty() {
None } else {
Some(format!("sensors/{}", temp.sensor_id))
}
}
}
let provider: Arc<dyn super::TopicProvider<TestTemperature>> =
Arc::new(OptionalTopicProvider);
let temp_with_id = TestTemperature {
sensor_id: "abc".into(),
celsius: 20.0,
};
assert_eq!(provider.topic(&temp_with_id), Some("sensors/abc".into()));
let temp_without_id = TestTemperature {
sensor_id: String::new(),
celsius: 20.0,
};
assert_eq!(provider.topic(&temp_without_id), None);
}
#[test]
fn test_topic_resolver_returns_some() {
let resolver: super::TopicResolverFn = Arc::new(|| Some("resolved/topic".into()));
assert_eq!(resolver(), Some("resolved/topic".into()));
}
#[test]
fn test_topic_resolver_returns_none() {
let resolver: super::TopicResolverFn = Arc::new(|| None);
assert_eq!(resolver(), None);
}
#[cfg(feature = "std")]
#[test]
fn test_topic_resolver_with_captured_state() {
use std::sync::Mutex;
let config = Arc::new(Mutex::new(Some("dynamic/topic".to_string())));
let config_clone = config.clone();
let resolver: super::TopicResolverFn =
Arc::new(move || config_clone.lock().unwrap().clone());
assert_eq!(resolver(), Some("dynamic/topic".into()));
*config.lock().unwrap() = None;
assert_eq!(resolver(), None);
}
fn dummy_ingest_factory() -> super::IngestFactoryFn {
Arc::new(|_db| Arc::new(|_ctx: &crate::RuntimeContext, _bytes: &[u8]| Ok(())))
}
#[test]
fn test_inbound_connector_link_resolve_topic_default() {
use super::{InboundConnectorLink, LinkAddress};
let url = LinkAddress::parse("mqtt://sensors/temperature").unwrap();
let link = InboundConnectorLink::new(url, dummy_ingest_factory());
assert_eq!(link.resolve_topic(), "sensors/temperature");
}
#[test]
fn test_inbound_connector_link_resolve_topic_dynamic() {
use super::{InboundConnectorLink, LinkAddress};
let url = LinkAddress::parse("mqtt://sensors/default").unwrap();
let mut link = InboundConnectorLink::new(url, dummy_ingest_factory());
link.topic_resolver = Some(Arc::new(|| Some("sensors/dynamic/kitchen".into())));
assert_eq!(link.resolve_topic(), "sensors/dynamic/kitchen");
}
#[test]
fn test_inbound_connector_link_resolve_topic_fallback() {
use super::{InboundConnectorLink, LinkAddress};
let url = LinkAddress::parse("mqtt://sensors/fallback").unwrap();
let mut link = InboundConnectorLink::new(url, dummy_ingest_factory());
link.topic_resolver = Some(Arc::new(|| None));
assert_eq!(link.resolve_topic(), "sensors/fallback");
}
}