tether_agent/channels/
tether_compliant_topic.rs

1use anyhow::anyhow;
2use log::*;
3use serde::{Deserialize, Serialize};
4
5use crate::TetherAgent;
6
7#[derive(Clone, Serialize, Deserialize, Debug)]
8pub struct TetherCompliantTopic {
9    role: String,
10    id: Option<String>,
11    channel_name: String,
12    full_topic: String,
13}
14
15#[derive(Clone, Serialize, Deserialize, Debug)]
16pub enum TetherOrCustomTopic {
17    Tether(TetherCompliantTopic),
18    Custom(String),
19}
20
21impl TetherOrCustomTopic {
22    pub fn full_topic_string(&self) -> String {
23        match self {
24            TetherOrCustomTopic::Tether(three_part_topic) => String::from(three_part_topic.topic()),
25            TetherOrCustomTopic::Custom(t) => String::from(t),
26        }
27    }
28}
29
30impl TetherCompliantTopic {
31    /// Publish topics fall back to the ID and/or role associated with the agent, if not explicitly provided
32    pub fn new_for_publish(
33        agent: &TetherAgent,
34        channel_name: &str,
35        role_part_override: Option<&str>,
36        id_part_override: Option<&str>,
37    ) -> TetherCompliantTopic {
38        let role = role_part_override.unwrap_or(agent.role());
39        let full_topic = build_publish_topic(role, channel_name, id_part_override);
40        TetherCompliantTopic {
41            role: role.into(),
42            id: id_part_override.map(String::from),
43            channel_name: channel_name.into(),
44            full_topic,
45        }
46    }
47
48    /// Subscribe topics fall back to wildcard `+` for role if not explicitly provided.
49    pub fn new_for_subscribe(
50        channel_name: &str,
51        role_part_override: Option<&str>,
52        id_part_override: Option<&str>,
53    ) -> TetherCompliantTopic {
54        let role = role_part_override.unwrap_or("+");
55        let full_topic = build_subscribe_topic(role, channel_name, id_part_override);
56
57        TetherCompliantTopic {
58            role: role.into(),
59            id: id_part_override.map(String::from),
60            channel_name: channel_name.into(),
61            full_topic,
62        }
63    }
64
65    /// Directly constructs a Three Part Topic with explicitly provided role, channel_name, and id.
66    pub fn new_three(role: &str, channel_name: &str, id: &str) -> TetherCompliantTopic {
67        TetherCompliantTopic {
68            role: role.into(),
69            id: Some(id.into()),
70            channel_name: channel_name.into(),
71            full_topic: build_subscribe_topic(role, channel_name, Some(id)),
72        }
73    }
74
75    /// Directly constructs a Three Part Topic with explicitly provided role, channel_name, and id.
76    pub fn new_two(role: &str, channel_name: &str) -> TetherCompliantTopic {
77        TetherCompliantTopic {
78            role: role.into(),
79            id: None,
80            channel_name: channel_name.into(),
81            full_topic: format!("{role}/{channel_name}"),
82        }
83    }
84
85    pub fn topic(&self) -> &str {
86        &self.full_topic
87    }
88
89    pub fn role(&self) -> &str {
90        &self.role
91    }
92
93    pub fn id(&self) -> Option<&str> {
94        self.id.as_deref()
95    }
96
97    pub fn channel_name(&self) -> &str {
98        &self.channel_name
99    }
100
101    pub fn set_role(&mut self, role: &str) {
102        self.role = role.into();
103        self.update_full_topic();
104    }
105
106    pub fn set_id(&mut self, id: Option<&str>) {
107        self.id = id.map(|id| id.into());
108        self.update_full_topic();
109    }
110
111    pub fn set_channel_name(&mut self, channel_name: &str) {
112        self.channel_name = channel_name.into();
113        self.update_full_topic();
114    }
115
116    fn update_full_topic(&mut self) {
117        self.full_topic = build_subscribe_topic(&self.role, &self.channel_name, self.id.as_deref());
118    }
119}
120
121impl TryFrom<&str> for TetherCompliantTopic {
122    type Error = anyhow::Error;
123
124    /// Try to convert a topic string into a valid Tether Three Part Topic
125    fn try_from(value: &str) -> Result<Self, Self::Error> {
126        let parts = value.split('/').collect::<Vec<&str>>();
127
128        if parts.len() < 2 || parts.len() > 3 {
129            return Err(anyhow!(
130                "Did not find exactly 2 or 3 parts in the topic {}",
131                value
132            ));
133        } else {
134            debug!("parts: {:?}", parts);
135        }
136
137        let role = parts.first().expect("the role part should exist");
138
139        let channel_name = parts.get(1).expect("the channel_name part should exist");
140
141        match parts.get(2) {
142            Some(id_part) => {
143                if *id_part == "#" {
144                    debug!("This must be a topic used for subscribing");
145                    Ok(TetherCompliantTopic::new_for_subscribe(
146                        channel_name,
147                        Some(role),
148                        Some("#"),
149                    ))
150                } else {
151                    Ok(TetherCompliantTopic::new_three(role, channel_name, id_part))
152                }
153            }
154            None => Ok(TetherCompliantTopic::new_two(role, channel_name)),
155        }
156    }
157}
158
159pub fn build_subscribe_topic(role: &str, channel_name: &str, id: Option<&str>) -> String {
160    match id {
161        Some(id) => format!("{role}/{channel_name}/{id}"),
162        None => format!("{role}/{channel_name}/#"),
163    }
164}
165
166pub fn build_publish_topic(role: &str, channel_name: &str, id: Option<&str>) -> String {
167    match id {
168        Some(id) => format!("{role}/{channel_name}/{id}"),
169        None => format!("{role}/{channel_name}"),
170    }
171}
172
173pub fn parse_channel_name(topic: &str) -> Option<&str> {
174    let parts: Vec<&str> = topic.split('/').collect();
175    parts.get(1).copied()
176}
177
178pub fn parse_agent_id(topic: &str) -> Option<&str> {
179    let parts: Vec<&str> = topic.split('/').collect();
180    parts.get(2).copied()
181}
182
183pub fn parse_agent_role(topic: &str) -> Option<&str> {
184    let parts: Vec<&str> = topic.split('/').collect();
185    parts.first().copied()
186}
187
188#[cfg(test)]
189mod tests {
190    use crate::{
191        agent::builder::TetherAgentBuilder,
192        tether_compliant_topic::{parse_agent_id, parse_agent_role, parse_channel_name},
193    };
194
195    use super::TetherCompliantTopic;
196
197    #[test]
198    fn util_parsers() {
199        assert_eq!(parse_agent_role("one/two/three"), Some("one"));
200        assert_eq!(parse_agent_id("one/two/three"), Some("three"));
201        assert_eq!(parse_channel_name("one/two/three"), Some("two"));
202        assert_eq!(parse_channel_name("just/two"), Some("two"));
203    }
204
205    #[test]
206    fn build_full_topic() {
207        let agent = TetherAgentBuilder::new("testingRole")
208            .auto_connect(false)
209            .build()
210            .expect("failed to construct agent");
211        let publishing_chanel_topic =
212            TetherCompliantTopic::new_for_publish(&agent, "testChannel", None, None);
213        assert_eq!(
214            &publishing_chanel_topic.full_topic,
215            "testingRole/testChannel"
216        );
217    }
218}