@@ -16,6 +16,84 @@ use futures_util::FutureExt;
1616use tokio:: runtime:: { self , Runtime } ;
1717use tokio:: time:: timeout;
1818
19+ /// An error returned when a topic string fails validation against the MQTT specification.
20+ #[ derive( Debug , thiserror:: Error , Clone , PartialEq , Eq ) ]
21+ #[ error( "Invalid MQTT topic: '{0}'" ) ]
22+ pub struct InvalidTopic ( String ) ;
23+
24+ /// A newtype wrapper that guarantees its inner `String` is a valid MQTT topic.
25+ ///
26+ /// This type prevents the cost of repeated validation for topics that are used
27+ /// frequently. It can only be constructed via [`ValidatedTopic::new`], which
28+ /// performs a one-time validation check.
29+ #[ derive( Debug , Clone , PartialEq ) ]
30+ pub struct ValidatedTopic ( String ) ;
31+
32+ impl ValidatedTopic {
33+ /// Constructs a new `ValidatedTopic` after validating the input string.
34+ ///
35+ /// # Errors
36+ ///
37+ /// Returns [`InvalidTopic`] if the topic string does not conform to the MQTT specification.
38+ pub fn new < S : Into < String > > ( topic : S ) -> Result < Self , InvalidTopic > {
39+ let topic_string = topic. into ( ) ;
40+ if valid_topic ( & topic_string) {
41+ Ok ( Self ( topic_string) )
42+ } else {
43+ Err ( InvalidTopic ( topic_string) )
44+ }
45+ }
46+ }
47+
48+ impl From < ValidatedTopic > for String {
49+ fn from ( topic : ValidatedTopic ) -> Self {
50+ topic. 0
51+ }
52+ }
53+
54+ /// A private module to seal the [`Topic`] trait.
55+ /// Sealing the trait prevents users from implementing [`Topic`]
56+ /// for their own type, which would circumvent validation
57+ mod private {
58+ use super :: ValidatedTopic ;
59+ pub trait Sealed { }
60+ impl Sealed for ValidatedTopic { }
61+ impl Sealed for String { }
62+ impl < ' a > Sealed for & ' a str { }
63+ }
64+
65+ /// Abstracts over topic types for publishing (as opposed to filters).
66+ ///
67+ /// This sealed trait is implemented for string types (`String`, `&str`) and
68+ /// for [`ValidatedTopic`]. It allows client methods to efficiently handle
69+ /// both pre-validated and unvalidated topic inputs.
70+ pub trait Topic : private:: Sealed {
71+ /// Indicates whether the topic requires validation.
72+ const NEEDS_VALIDATION : bool ;
73+ fn into_string ( self ) -> String ;
74+ }
75+
76+ impl Topic for ValidatedTopic {
77+ const NEEDS_VALIDATION : bool = false ;
78+ fn into_string ( self ) -> String {
79+ self . 0
80+ }
81+ }
82+
83+ impl Topic for String {
84+ const NEEDS_VALIDATION : bool = true ;
85+ fn into_string ( self ) -> String {
86+ self
87+ }
88+ }
89+
90+ impl < ' a > Topic for & ' a str {
91+ const NEEDS_VALIDATION : bool = true ;
92+ fn into_string ( self ) -> String {
93+ self . to_owned ( )
94+ }
95+ }
96+
1997/// Client Error
2098#[ derive( Debug , thiserror:: Error ) ]
2199pub enum ClientError {
@@ -80,16 +158,21 @@ impl AsyncClient {
80158 properties : Option < PublishProperties > ,
81159 ) -> Result < ( ) , ClientError >
82160 where
83- S : Into < String > ,
161+ S : Topic ,
84162 P : Into < Bytes > ,
85163 {
86- let topic = topic. into ( ) ;
87- let mut publish = Publish :: new ( & topic, qos, payload, properties) ;
164+ let topic = topic. into_string ( ) ;
165+ let mut publish = Publish :: new ( topic. as_str ( ) , qos, payload, properties) ;
88166 publish. retain = retain;
89167 let publish = Request :: Publish ( publish) ;
90- if !valid_topic ( & topic) {
168+
169+ // This is zero-cost for `ValidatedTopic`,
170+ // `S::NEEDS_VALIDATION` is false, and the entire conditional is
171+ // removed.
172+ if S :: NEEDS_VALIDATION && !valid_topic ( & topic) {
91173 return Err ( ClientError :: Request ( publish) ) ;
92174 }
175+
93176 self . request_tx . send_async ( publish) . await ?;
94177 Ok ( ( ) )
95178 }
@@ -103,7 +186,7 @@ impl AsyncClient {
103186 properties : PublishProperties ,
104187 ) -> Result < ( ) , ClientError >
105188 where
106- S : Into < String > ,
189+ S : Topic ,
107190 P : Into < Bytes > ,
108191 {
109192 self . handle_publish ( topic, qos, retain, payload, Some ( properties) )
@@ -118,7 +201,7 @@ impl AsyncClient {
118201 payload : P ,
119202 ) -> Result < ( ) , ClientError >
120203 where
121- S : Into < String > ,
204+ S : Topic ,
122205 P : Into < Bytes > ,
123206 {
124207 self . handle_publish ( topic, qos, retain, payload, None ) . await
@@ -134,16 +217,18 @@ impl AsyncClient {
134217 properties : Option < PublishProperties > ,
135218 ) -> Result < ( ) , ClientError >
136219 where
137- S : Into < String > ,
220+ S : Topic ,
138221 P : Into < Bytes > ,
139222 {
140- let topic = topic. into ( ) ;
141- let mut publish = Publish :: new ( & topic, qos, payload, properties) ;
223+ let topic = topic. into_string ( ) ;
224+ let mut publish = Publish :: new ( topic. as_str ( ) , qos, payload, properties) ;
142225 publish. retain = retain;
143226 let publish = Request :: Publish ( publish) ;
144- if !valid_topic ( & topic) {
227+
228+ if S :: NEEDS_VALIDATION && !valid_topic ( & topic) {
145229 return Err ( ClientError :: TryRequest ( publish) ) ;
146230 }
231+
147232 self . request_tx . try_send ( publish) ?;
148233 Ok ( ( ) )
149234 }
@@ -157,7 +242,7 @@ impl AsyncClient {
157242 properties : PublishProperties ,
158243 ) -> Result < ( ) , ClientError >
159244 where
160- S : Into < String > ,
245+ S : Topic ,
161246 P : Into < Bytes > ,
162247 {
163248 self . handle_try_publish ( topic, qos, retain, payload, Some ( properties) )
@@ -171,7 +256,7 @@ impl AsyncClient {
171256 payload : P ,
172257 ) -> Result < ( ) , ClientError >
173258 where
174- S : Into < String > ,
259+ S : Topic ,
175260 P : Into < Bytes > ,
176261 {
177262 self . handle_try_publish ( topic, qos, retain, payload, None )
@@ -505,17 +590,19 @@ impl Client {
505590 properties : Option < PublishProperties > ,
506591 ) -> Result < ( ) , ClientError >
507592 where
508- S : Into < String > ,
593+ S : Topic ,
509594 P : Into < Bytes > ,
510595 {
511- let topic = topic. into ( ) ;
512- let mut publish = Publish :: new ( & topic, qos, payload, properties) ;
596+ let topic = topic. into_string ( ) ;
597+ let mut publish = Publish :: new ( topic. as_str ( ) , qos, payload, properties) ;
513598 publish. retain = retain;
514- let publish = Request :: Publish ( publish) ;
515- if !valid_topic ( & topic) {
516- return Err ( ClientError :: Request ( publish) ) ;
599+ let request = Request :: Publish ( publish) ;
600+
601+ if S :: NEEDS_VALIDATION && !valid_topic ( & topic) {
602+ return Err ( ClientError :: Request ( request) ) ;
517603 }
518- self . client . request_tx . send ( publish) ?;
604+
605+ self . client . request_tx . send ( request) ?;
519606 Ok ( ( ) )
520607 }
521608
@@ -528,7 +615,7 @@ impl Client {
528615 properties : PublishProperties ,
529616 ) -> Result < ( ) , ClientError >
530617 where
531- S : Into < String > ,
618+ S : Topic ,
532619 P : Into < Bytes > ,
533620 {
534621 self . handle_publish ( topic, qos, retain, payload, Some ( properties) )
@@ -542,7 +629,7 @@ impl Client {
542629 payload : P ,
543630 ) -> Result < ( ) , ClientError >
544631 where
545- S : Into < String > ,
632+ S : Topic ,
546633 P : Into < Bytes > ,
547634 {
548635 self . handle_publish ( topic, qos, retain, payload, None )
@@ -557,7 +644,7 @@ impl Client {
557644 properties : PublishProperties ,
558645 ) -> Result < ( ) , ClientError >
559646 where
560- S : Into < String > ,
647+ S : Topic ,
561648 P : Into < Bytes > ,
562649 {
563650 self . client
@@ -572,7 +659,7 @@ impl Client {
572659 payload : P ,
573660 ) -> Result < ( ) , ClientError >
574661 where
575- S : Into < String > ,
662+ S : Topic ,
576663 P : Into < Bytes > ,
577664 {
578665 self . client . try_publish ( topic, qos, retain, payload)
@@ -896,4 +983,31 @@ mod test {
896983 . expect ( "Should be able to publish" ) ;
897984 let _ = rx. try_recv ( ) . expect ( "Should have message" ) ;
898985 }
986+
987+ #[ test]
988+ fn can_publish_with_validated_topic ( ) {
989+ let ( tx, rx) = flume:: bounded ( 1 ) ;
990+ let client = Client :: from_sender ( tx) ;
991+ let valid_topic = ValidatedTopic :: new ( "hello/world" ) . unwrap ( ) ;
992+ client
993+ . publish ( valid_topic, QoS :: ExactlyOnce , false , "good bye" )
994+ . expect ( "Should be able to publish" ) ;
995+ let _ = rx. try_recv ( ) . expect ( "Should have message" ) ;
996+ }
997+
998+ #[ test]
999+ fn validated_topic_ergonomics ( ) {
1000+ let valid_topic = ValidatedTopic :: new ( "hello/world" ) . unwrap ( ) ;
1001+ let valid_topic_can_be_cloned = valid_topic. clone ( ) ;
1002+ // ValidatedTopic can be compared
1003+ assert_eq ! ( valid_topic, valid_topic_can_be_cloned) ;
1004+ }
1005+
1006+ #[ test]
1007+ fn creating_invalid_validated_topic_fails ( ) {
1008+ assert_eq ! (
1009+ ValidatedTopic :: new( "a/+/b" ) ,
1010+ Err ( InvalidTopic ( "a/+/b" . to_string( ) ) )
1011+ ) ;
1012+ }
8991013}
0 commit comments