Repository: quartiq/minimq (rust)

Issue:
avoid Publication::finish
* [Publication::finish()](https://github.com/quartiq/minimq/blob/2b8b2ee7ca03ff2a65d420437133d4395a1cbd80/src/publication.rs#L193-L203): only checks for topic, leading to `unwrap()` all over in code that definitely sets a topic a few lines above. Change the API to require a topic in `new()`. ~~Make `reply()` (better `respond()`) a separate ctor remove the class of unwrap. (not a good idea since that requires checking the request properties~~

Pull request description:
Removing results from Publication API
Fixes #159 by refactoring the `Publication` API to avoid forcing an `unwrap()` on all users, and instead moves the `unwrap()` to a new `Publication::respond` API for constructing a new publication.

Users that have a predefined topic no longer need to unnecessarily unwrap or call `finish()` at all.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 99b6130..ea4e94a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,12 @@
 
 This document describes the changes to Minimq between releases.
 
+# Unreleased
+
+## Changed
+* The `Publication::finish()` API was removed in favor of a new `Publication::respond()` API for
+constructing replies to previously received messages.
+
 # [0.9.0] - 2024-04-29
 
 ## Fixed
@@ -146,7 +152,7 @@ keep-alive interval
 
 * Initial library release and publish to crates.io
 
-[0.8.0]: https://github.com/quartiq/minimq/releases/tag/0.9.0
+[0.9.0]: https://github.com/quartiq/minimq/releases/tag/0.9.0
 [0.8.0]: https://github.com/quartiq/minimq/releases/tag/0.8.0
 [0.7.0]: https://github.com/quartiq/minimq/releases/tag/0.7.0
 [0.6.2]: https://github.com/quartiq/minimq/releases/tag/0.6.2
diff --git a/src/lib.rs b/src/lib.rs
index d2b53dd..f3daa8f 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -52,8 +52,7 @@
 //!         match topic {
 //!             "topic" => {
 //!                println!("{:?}", message);
-//!                let response = Publication::new(message).topic("echo").finish().unwrap();
-//!                client.publish(response).unwrap();
+//!                client.publish(Publication::new("echo", message)).unwrap();
 //!             },
 //!             topic => println!("Unknown topic: {}", topic),
 //!         };
diff --git a/src/mqtt_client.rs b/src/mqtt_client.rs
index e768eaa..6d1a1b7 100644
--- a/src/mqtt_client.rs
+++ b/src/mqtt_client.rs
@@ -2,6 +2,7 @@ use crate::{
     de::{received_packet::ReceivedPacket, PacketReader},
     network_manager::InterfaceHolder,
     packets::{ConnAck, Connect, PingReq, Pub, PubAck, PubComp, PubRec, PubRel, SubAck, Subscribe},
+    publication::Publication,
     reason_codes::ReasonCode,
     session_state::SessionState,
     types::{Auth, Properties, TopicFilter, Utf8String},
@@ -374,17 +375,19 @@ impl<'buf, TcpStack: TcpClientStack, Clock: embedded_time::Clock, Broker: crate:
     /// If the client is not yet connected to the broker, the message will be silently ignored.
     ///
     /// # Args
-    /// * `publish` - The publication to generate. See [crate::Publication] for a builder pattern
+    /// * `publish` - The publication to generate.
     /// to generate a message.
     pub fn publish<P: crate::publication::ToPayload>(
         &mut self,
-        mut publish: Pub<'_, P>,
+        publish: Publication<'_, P>,
     ) -> Result<(), crate::PubError<TcpStack::Error, P::Error>> {
         // If we are not yet connected to the broker, we can't transmit a message.
         if !self.is_connected() {
             return Ok(());
         }
 
+        let mut publish: Pub<'_, P> = publish.into();
+
         if let Some(max) = &self.sm.context().max_qos {
             if self.downgrade_qos && publish.qos > *max {
                 publish.qos = *max
diff --git a/src/packets.rs b/src/packets.rs
index 4e52dc5..abc9f55 100644
--- a/src/packets.rs
+++ b/src/packets.rs
@@ -1,4 +1,5 @@
 use crate::{
+    publication::Publication,
     reason_codes::ReasonCode,
     types::{Auth, Properties, TopicFilter, Utf8String},
     will::SerializedWill,
@@ -129,6 +130,20 @@ impl<'a, P> core::fmt::Debug for Pub<'a, P> {
     }
 }
 
+impl<'a, P> From<Publication<'a, P>> for Pub<'a, P> {
+    fn from(publication: Publication<'a, P>) -> Self {
+        Self {
+            topic: Utf8String(publication.topic),
+            properties: publication.properties,
+            packet_id: None,
+            payload: publication.payload,
+            retain: publication.retain,
+            qos: publication.qos,
+            dup: false,
+        }
+    }
+}
+
 /// An MQTT SUBSCRIBE control packet
 #[derive(Debug, Serialize)]
 pub struct Subscribe<'a> {
diff --git a/src/publication.rs b/src/publication.rs
index 3e88ef4..20b2f62 100644
--- a/src/publication.rs
+++ b/src/publication.rs
@@ -1,7 +1,6 @@
 use crate::{
-    packets::Pub,
     properties::Property,
-    types::{BinaryData, Properties, Utf8String},
+    types::{BinaryData, Properties},
     ProtocolError, QoS, Retain,
 };
 
@@ -49,8 +48,15 @@ pub struct DeferredPublication<F> {
 }
 
 impl<E, F: FnOnce(&mut [u8]) -> Result<usize, E>> DeferredPublication<F> {
-    pub fn new<'a>(func: F) -> Publication<'a, Self> {
-        Publication::new(Self { func })
+    pub fn new<'a>(topic: &'a str, func: F) -> Publication<'a, Self> {
+        Publication::new(topic, Self { func })
+    }
+
+    pub fn respond<'a>(
+        received_properties: &'a Properties<'a>,
+        func: F,
+    ) -> Result<Publication<'a, Self>, ProtocolError> {
+        Publication::respond(received_properties, Self { func })
     }
 }
 
@@ -74,20 +80,48 @@ impl<E, F: FnOnce(&mut [u8]) -> Result<usize, E>> ToPayload for DeferredPublicat
 /// in [Publication::topic], or by parsing a topic from the [Property::ResponseTopic] property
 /// contained within received properties by using the [Publication::reply] API.
 pub struct Publication<'a, P> {
-    topic: Option<&'a str>,
-    properties: Properties<'a>,
-    qos: QoS,
-    payload: P,
-    retain: Retain,
+    pub(crate) topic: &'a str,
+    pub(crate) properties: Properties<'a>,
+    pub(crate) qos: QoS,
+    pub(crate) payload: P,
+    pub(crate) retain: Retain,
 }
 
 impl<'a, P: ToPayload> Publication<'a, P> {
+    /// Generate the publication as a reply to some other received message.
+    ///
+    /// # Note
+    /// The received message properties are parsed for both [Property::CorrelationData] and
+    /// [Property::ResponseTopic].
+    ///
+    /// * If correlation data is found, it is automatically appended to the
+    /// publication properties.
+    ///
+    /// * If a response topic is identified, the message topic will be
+    /// configured for it, which will override any previously-specified topic.
+    pub fn respond(
+        received_properties: &'a Properties<'a>,
+        payload: P,
+    ) -> Result<Self, ProtocolError> {
+        let Some(response_topic) = received_properties.into_iter().find_map(|p| {
+            if let Ok(Property::ResponseTopic(topic)) = p {
+                Some(topic.0)
+            } else {
+                None
+            }
+        }) else {
+            return Err(ProtocolError::NoTopic);
+        };
+
+        Ok(Self::new(response_topic, payload))
+    }
+
     /// Construct a new publication with a payload.
-    pub fn new(payload: P) -> Self {
+    pub fn new(topic: &'a str, payload: P) -> Self {
         Self {
             payload,
             qos: QoS::AtMostOnce,
-            topic: None,
+            topic,
             properties: Properties::Slice(&[]),
             retain: Retain::NotRetained,
         }
@@ -105,16 +139,6 @@ impl<'a, P: ToPayload> Publication<'a, P> {
         self
     }
 
-    /// Specify the publication topic for this message.
-    ///
-    /// # Note
-    /// If this is called after [Publication::reply] determines a response topic, the response
-    /// topic will be overridden.
-    pub fn topic(mut self, topic: &'a str) -> Self {
-        self.topic.replace(topic);
-        self
-    }
-
     /// Specify properties associated with this publication.
     pub fn properties(mut self, properties: &'a [Property<'a>]) -> Self {
         self.properties = match self.properties {
@@ -128,42 +152,6 @@ impl<'a, P: ToPayload> Publication<'a, P> {
         self
     }
 
-    /// Generate the publication as a reply to some other received message.
-    ///
-    /// # Note
-    /// The received message properties are parsed for both [Property::CorrelationData] and
-    /// [Property::ResponseTopic].
-    ///
-    /// * If correlation data is found, it is automatically appended to the
-    /// publication properties.
-    ///
-    /// * If a response topic is identified, the message topic will be
-    /// configured for it, which will override any previously-specified topic.
-    pub fn reply(mut self, received_properties: &'a Properties<'a>) -> Self {
-        if let Some(response_topic) = received_properties.into_iter().find_map(|p| {
-            if let Ok(Property::ResponseTopic(topic)) = p {
-                Some(topic.0)
-            } else {
-                None
-            }
-        }) {
-            self.topic.replace(response_topic);
-        }
-
-        // Next, copy over any correlation data to the outbound properties.
-        if let Some(correlation_data) = received_properties.into_iter().find_map(|p| {
-            if let Ok(Property::CorrelationData(data)) = p {
-                Some(data.0)
-            } else {
-                None
-            }
-        }) {
-            self.correlate(correlation_data)
-        } else {
-            self
-        }
-    }
-
     /// Include correlation data to the message
     ///
     /// # Note
@@ -184,21 +172,4 @@ impl<'a, P: ToPayload> Publication<'a, P> {
 
         self
     }
-
-    /// Generate the final publication.
-    ///
-    /// # Returns
-    /// The message to be published if a publication topic was specified. If no publication topic
-    /// was identified, an error is returned.
-    pub fn finish(self) -> Result<Pub<'a, P>, ProtocolError> {
-        Ok(Pub {
-            topic: Utf8String(self.topic.ok_or(ProtocolError::NoTopic)?),
-            properties: self.properties,
-            packet_id: None,
-            payload: self.payload,
-            retain: self.retain,
-            qos: self.qos,
-            dup: false,
-        })
-    }
 }


Test patch:
diff --git a/tests/at_least_once_subscription.rs b/tests/at_least_once_subscription.rs
index 382f2b7..dbbba2b 100644
--- a/tests/at_least_once_subscription.rs
+++ b/tests/at_least_once_subscription.rs
@@ -49,13 +49,7 @@ fn main() -> std::io::Result<()> {
 
         if !published && mqtt.client().can_publish(QoS::AtLeastOnce) {
             mqtt.client()
-                .publish(
-                    Publication::new("Ping")
-                        .topic("data")
-                        .qos(QoS::AtLeastOnce)
-                        .finish()
-                        .unwrap(),
-                )
+                .publish(Publication::new("data", "Ping").qos(QoS::AtLeastOnce))
                 .unwrap();
             log::info!("Publishing message");
             published = true;
diff --git a/tests/deferred_payload.rs b/tests/deferred_payload.rs
index c9394d9..fd4e90d 100644
--- a/tests/deferred_payload.rs
+++ b/tests/deferred_payload.rs
@@ -23,11 +23,7 @@ fn main() -> std::io::Result<()> {
 
     assert!(matches!(
         mqtt.client().publish(
-            DeferredPublication::new(|_buf| { Err("Oops!") })
-                .topic("data")
-                .qos(QoS::ExactlyOnce)
-                .finish()
-                .unwrap(),
+            DeferredPublication::new("data", |_buf| { Err("Oops!") }).qos(QoS::ExactlyOnce)
         ),
         Err(minimq::PubError::Serialization("Oops!"))
     ));
diff --git a/tests/exactly_once_subscription.rs b/tests/exactly_once_subscription.rs
index a046dad..fbd7c98 100644
--- a/tests/exactly_once_subscription.rs
+++ b/tests/exactly_once_subscription.rs
@@ -50,13 +50,7 @@ fn main() -> std::io::Result<()> {
 
         if !published && mqtt.client().can_publish(QoS::ExactlyOnce) {
             mqtt.client()
-                .publish(
-                    Publication::new("Ping")
-                        .topic("data")
-                        .qos(QoS::ExactlyOnce)
-                        .finish()
-                        .unwrap(),
-                )
+                .publish(Publication::new("data", b"Ping").qos(QoS::ExactlyOnce))
                 .unwrap();
             log::info!("Publishing message");
             published = true;
diff --git a/tests/exactly_once_transmission.rs b/tests/exactly_once_transmission.rs
index f0c57c7..bf10f09 100644
--- a/tests/exactly_once_transmission.rs
+++ b/tests/exactly_once_transmission.rs
@@ -29,13 +29,7 @@ fn main() -> std::io::Result<()> {
         if mqtt.client().is_connected() && !published && mqtt.client().can_publish(QoS::ExactlyOnce)
         {
             mqtt.client()
-                .publish(
-                    Publication::new("Ping".as_bytes())
-                        .topic("data")
-                        .qos(QoS::ExactlyOnce)
-                        .finish()
-                        .unwrap(),
-                )
+                .publish(Publication::new("data", b"Ping").qos(QoS::ExactlyOnce))
                 .unwrap();
             log::info!("Publishing message");
             published = true;
diff --git a/tests/integration_test.rs b/tests/integration_test.rs
index 1de1e77..98a6c1f 100644
--- a/tests/integration_test.rs
+++ b/tests/integration_test.rs
@@ -31,10 +31,7 @@ fn main() -> std::io::Result<()> {
             .poll(|client, topic, payload, properties| {
                 log::info!("{} < {}", topic, core::str::from_utf8(payload).unwrap());
 
-                if let Ok(response) = Publication::new("Pong".as_bytes())
-                    .reply(properties)
-                    .finish()
-                {
+                if let Ok(response) = Publication::respond(properties, b"Pong") {
                     client.publish(response).unwrap();
                 }
 
@@ -63,20 +60,14 @@ fn main() -> std::io::Result<()> {
         } else if !client.subscriptions_pending() && !published {
             println!("PUBLISH request");
             let properties = [Property::ResponseTopic(Utf8String("response"))];
-            let publication = Publication::new(b"Ping")
-                .topic("request")
-                .properties(&properties)
-                .finish()
-                .unwrap();
+            let publication = Publication::new("request", b"Ping").properties(&properties);
 
             client.publish(publication).unwrap();
 
-            let publication = Publication::new(b"Ping")
-                .topic("request")
+            let publication = Publication::new("request", b"Ping")
                 .properties(&properties)
-                .qos(QoS::AtLeastOnce)
-                .finish()
-                .unwrap();
+                .qos(QoS::AtLeastOnce);
+
             client.publish(publication).unwrap();
 
             // The message cannot be ack'd until the next poll call
diff --git a/tests/poll_result.rs b/tests/poll_result.rs
index 772284a..c90f00a 100644
--- a/tests/poll_result.rs
+++ b/tests/poll_result.rs
@@ -45,13 +45,7 @@ fn main() -> std::io::Result<()> {
             && mqtt.client().can_publish(QoS::ExactlyOnce)
         {
             mqtt.client()
-                .publish(
-                    Publication::new("Ping".as_bytes())
-                        .topic("data")
-                        .qos(QoS::ExactlyOnce)
-                        .finish()
-                        .unwrap(),
-                )
+                .publish(Publication::new("data", b"Ping").qos(QoS::ExactlyOnce))
                 .unwrap();
             log::info!("Publishing message");
             published = true;
diff --git a/tests/reconnection.rs b/tests/reconnection.rs
index e281388..844338a 100644
--- a/tests/reconnection.rs
+++ b/tests/reconnection.rs
@@ -40,13 +40,7 @@ fn main() -> std::io::Result<()> {
 
     // 2. Send a QoS::AtLeastOnce message
     mqtt.client()
-        .publish(
-            Publication::new("Ping".as_bytes())
-                .topic("test")
-                .qos(QoS::ExactlyOnce)
-                .finish()
-                .unwrap(),
-        )
+        .publish(Publication::new("test", b"Ping").qos(QoS::ExactlyOnce))
         .unwrap();
 
     // Force a disconnect from the broker.