use std::borrow::Cow;
use std::fmt;
use std::future::{Future, ready};
use rumqttc::v5::mqttbytes::{QoS, valid_filter, valid_topic};
#[cfg(feature = "asyncapi")]
use ruststream::asyncapi::Bindings;
use ruststream::{
AddressedCopies, FromName, NamedCopies, RedeliveryAddress, RedeliveryAddressed,
SubscriptionSource,
};
#[cfg(feature = "asyncapi")]
use crate::asyncapi;
use crate::broker::ConnectedMqttBroker;
use crate::error::MqttError;
use crate::message::is_mqtt_text;
use crate::subscriber::{MqttSubscriber, OnDrop};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Qos {
AtMostOnce,
#[default]
AtLeastOnce,
ExactlyOnce,
}
impl Qos {
pub(crate) const fn from_client(qos: QoS) -> Self {
match qos {
QoS::AtMostOnce => Self::AtMostOnce,
QoS::AtLeastOnce => Self::AtLeastOnce,
QoS::ExactlyOnce => Self::ExactlyOnce,
}
}
pub(crate) fn to_client(self) -> QoS {
match self {
Self::AtMostOnce => QoS::AtMostOnce,
Self::AtLeastOnce => QoS::AtLeastOnce,
Self::ExactlyOnce => QoS::ExactlyOnce,
}
}
#[cfg(feature = "asyncapi")]
pub(crate) const fn level(self) -> u8 {
match self {
Self::AtMostOnce => 0,
Self::AtLeastOnce => 1,
Self::ExactlyOnce => 2,
}
}
}
fn validate_share_group(group: Option<&String>) -> Result<(), MqttError> {
if let Some(group) = group
&& (group.is_empty() || group.contains(['/', '+', '#']))
{
return Err(MqttError::Invalid(format!(
"'{group}' is not a valid share group name"
)));
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[must_use]
pub struct MqttFilter {
filter: String,
qos: Qos,
shared: Option<String>,
}
impl MqttFilter {
pub fn new(filter: impl Into<String>) -> Self {
Self {
filter: filter.into(),
qos: Qos::default(),
shared: None,
}
}
pub fn qos(mut self, qos: Qos) -> Self {
self.qos = qos;
self
}
pub fn shared(mut self, group: impl Into<String>) -> Self {
self.shared = Some(group.into());
self
}
#[must_use]
pub fn filter(&self) -> &str {
&self.filter
}
pub(crate) const fn qos_value(&self) -> Qos {
self.qos
}
pub(crate) fn wire_filter(&self) -> String {
self.shared.as_ref().map_or_else(
|| self.filter.clone(),
|group| format!("$share/{group}/{}", self.filter),
)
}
pub(crate) fn validate(&self) -> Result<(), MqttError> {
if !valid_filter(&self.filter) || !is_mqtt_text(&self.filter) {
return Err(MqttError::Invalid(format!(
"'{}' is not a valid MQTT topic filter",
self.filter
)));
}
validate_share_group(self.shared.as_ref())
}
}
impl FromName for MqttFilter {
fn from_name(name: impl Into<Cow<'static, str>>) -> Self {
Self::new(name.into().into_owned())
}
}
#[derive(Clone, PartialEq, Eq)]
#[must_use]
pub struct MqttTopic(MqttFilter);
impl fmt::Debug for MqttTopic {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MqttTopic")
.field("topic", &self.0.filter)
.field("qos", &self.0.qos)
.field("shared", &self.0.shared)
.finish()
}
}
impl MqttTopic {
pub fn new(topic: impl Into<String>) -> Self {
Self(MqttFilter::new(topic))
}
pub fn qos(mut self, qos: Qos) -> Self {
self.0 = self.0.qos(qos);
self
}
pub fn shared(mut self, group: impl Into<String>) -> Self {
self.0 = self.0.shared(group);
self
}
#[must_use]
pub fn topic(&self) -> &str {
self.0.filter()
}
#[cfg(feature = "asyncapi")]
pub(crate) const fn qos_value(&self) -> Qos {
self.0.qos_value()
}
pub(crate) fn into_filter(self) -> MqttFilter {
self.0
}
pub(crate) fn validate(&self) -> Result<(), MqttError> {
if self.0.filter.is_empty() {
return Err(MqttError::Invalid(
"an MQTT topic must not be empty".to_owned(),
));
}
if !valid_topic(&self.0.filter) {
return Err(MqttError::Invalid(format!(
"'{}' is not an MQTT topic: wildcards are subscribe-only, so a filter carrying \
'+' or '#' subscribes with MqttFilter instead",
self.0.filter
)));
}
if !is_mqtt_text(&self.0.filter) {
return Err(MqttError::Invalid(format!(
"{:?} is not an MQTT topic: it carries a control character or a Unicode \
non-character",
self.0.filter
)));
}
validate_share_group(self.0.shared.as_ref())
}
fn address(&self) -> Result<RedeliveryAddress, MqttError> {
self.validate()?;
Ok(RedeliveryAddress::new(self.0.filter.clone()))
}
}
impl FromName for MqttTopic {
fn from_name(name: impl Into<Cow<'static, str>>) -> Self {
Self::new(name.into().into_owned())
}
}
impl SubscriptionSource<ConnectedMqttBroker> for MqttTopic {
type Subscriber = MqttSubscriber;
type Copies = AddressedCopies;
fn name(&self) -> &str {
self.topic()
}
async fn subscribe(self, connected: &ConnectedMqttBroker) -> Result<MqttSubscriber, MqttError> {
connected.open_topic(self, OnDrop::Keep).await
}
#[cfg(feature = "asyncapi")]
fn operation_bindings(&self) -> Bindings {
asyncapi::receive_operation(self.qos_value())
}
#[cfg(feature = "asyncapi")]
fn message_bindings(&self) -> Bindings {
asyncapi::message()
}
}
impl RedeliveryAddressed<ConnectedMqttBroker> for MqttTopic {
fn redelivery_address(
&self,
_connected: &ConnectedMqttBroker,
) -> impl Future<Output = Result<RedeliveryAddress, MqttError>> + Send {
ready(self.address())
}
}
impl SubscriptionSource<ConnectedMqttBroker> for MqttFilter {
type Subscriber = MqttSubscriber;
type Copies = NamedCopies;
fn name(&self) -> &str {
self.filter()
}
async fn subscribe(self, connected: &ConnectedMqttBroker) -> Result<MqttSubscriber, MqttError> {
connected.open_filter(self, OnDrop::Keep).await
}
#[cfg(feature = "asyncapi")]
fn operation_bindings(&self) -> Bindings {
asyncapi::receive_operation(self.qos_value())
}
#[cfg(feature = "asyncapi")]
fn message_bindings(&self) -> Bindings {
asyncapi::message()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_filters_are_rejected_before_io() {
assert!(MqttFilter::new("a/#/b").validate().is_err());
assert!(MqttFilter::new("").validate().is_err());
}
#[test]
fn a_control_character_is_rejected_before_io() {
assert!(MqttFilter::new("devices/\n/+").validate().is_err());
assert!(MqttTopic::new("devices/a\tb").validate().is_err());
assert!(MqttTopic::new("devices/\u{fffe}").validate().is_err());
assert!(MqttTopic::new("devices/zürich").validate().is_ok());
}
#[test]
fn invalid_share_groups_are_rejected_before_io() {
assert!(MqttFilter::new("a").shared("g/1").validate().is_err());
assert!(MqttFilter::new("a").shared("").validate().is_err());
assert!(MqttTopic::new("a").shared("g/1").validate().is_err());
}
#[test]
fn shared_filters_wrap_on_the_wire_only() {
let filter = MqttFilter::new("orders/+").shared("workers");
assert_eq!(filter.filter(), "orders/+");
assert_eq!(filter.wire_filter(), "$share/workers/orders/+");
}
#[test]
fn a_topic_is_the_address_a_retry_is_published_to() {
assert_eq!(
MqttTopic::new("devices/dev42/telemetry")
.address()
.expect("a topic is its own address"),
RedeliveryAddress::new("devices/dev42/telemetry")
);
for wildcard in ["devices/+/telemetry", "devices/#"] {
let refused = MqttTopic::new(wildcard)
.address()
.expect_err("a filter is not an address");
assert!(
refused.to_string().contains("MqttFilter"),
"the refusal names the descriptor that takes a filter: {refused}"
);
}
assert!(MqttTopic::new("").address().is_err());
}
#[test]
fn a_shared_subscription_is_reached_through_its_plain_topic() {
assert_eq!(
MqttTopic::new("jobs")
.shared("workers")
.address()
.expect("a topic is its own address"),
RedeliveryAddress::new("jobs")
);
}
#[test]
fn a_descriptor_built_from_a_name_alone_is_the_plain_one() {
assert_eq!(
MqttFilter::from_name("devices/+/telemetry"),
MqttFilter::new("devices/+/telemetry")
);
assert_eq!(
MqttTopic::from_name("devices/dev42/telemetry"),
MqttTopic::new("devices/dev42/telemetry")
);
}
}