ruststream_pulsar/topic.rs
1//! [`PulsarTopic`]: validated first-class topic addressing.
2//!
3//! A Pulsar topic name carries four independent meanings (persistence, tenant, namespace,
4//! topic); the client treats it as a plain string and defers errors to the broker. The
5//! newtype validates on construction instead, so a malformed name fails before any I/O.
6
7use crate::error::PulsarError;
8
9/// A validated Pulsar topic name.
10///
11/// # Examples
12///
13/// ```
14/// # mod demo {
15/// use ruststream_pulsar::prelude::*;
16/// use serde::{Deserialize, Serialize};
17///
18/// #[derive(Deserialize, Outgoing, Serialize)]
19/// struct Order {
20/// tenant: String,
21/// id: u64,
22/// }
23///
24/// #[derive(OutSlot)]
25/// #[publishes(Order)]
26/// struct TenantOrders;
27///
28/// /// Fans orders out to their tenant's own topic. The tenant comes off the wire, so its topic
29/// /// is parsed, and an order naming a tenant Pulsar would refuse is dropped here.
30/// #[subscriber(PulsarSubscription::new("orders", "router"))]
31/// async fn route(order: &Order, Out(tenants): Out<impl Publisher, TenantOrders>) -> HandlerOutcome {
32/// let Ok(topic) = PulsarTopic::parse(&format!("{}/orders/created", order.tenant)) else {
33/// return HandlerOutcome::drop();
34/// };
35/// match tenants.message(order).to(topic.as_str()).publish().await {
36/// Ok(()) => HandlerOutcome::ack(),
37/// Err(_) => HandlerOutcome::retry(),
38/// }
39/// }
40/// #
41/// # #[ruststream::app]
42/// # fn app() -> impl App {
43/// # RustStream::new(AppInfo::new("router", "0.1.0")).with_broker(
44/// # PulsarBroker::new("pulsar://localhost:6650"),
45/// # |b| {
46/// # b.include(route).out(TenantOrders, Publish).build();
47/// # },
48/// # )
49/// # }
50/// # }
51/// # fn main() {}
52/// ```
53#[derive(Debug, Clone, PartialEq, Eq, Hash)]
54#[must_use]
55pub struct PulsarTopic {
56 full: String,
57}
58
59/// Whether `part` is a tenant, namespace or topic name Pulsar accepts.
60pub(crate) fn valid_part(part: &str) -> bool {
61 !part.is_empty()
62 && part
63 .chars()
64 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
65}
66
67impl PulsarTopic {
68 fn of(scheme: &str, tenant: &str, namespace: &str, topic: &str) -> Result<Self, PulsarError> {
69 for (label, part) in [
70 ("tenant", tenant),
71 ("namespace", namespace),
72 ("topic", topic),
73 ] {
74 if !valid_part(part) {
75 return Err(PulsarError::Invalid(format!(
76 "{label} '{part}' must be non-empty and contain only alphanumerics, '-', '_', '.'"
77 )));
78 }
79 }
80 Ok(Self {
81 full: format!("{scheme}://{tenant}/{namespace}/{topic}"),
82 })
83 }
84
85 /// A persistent topic: `persistent://tenant/namespace/topic`.
86 ///
87 /// # Panics
88 ///
89 /// Panics when a component is empty or carries characters Pulsar rejects; use
90 /// [`parse`](Self::parse) for fallible construction from untrusted input.
91 pub fn persistent(tenant: &str, namespace: &str, topic: &str) -> Self {
92 Self::of("persistent", tenant, namespace, topic).expect("invalid pulsar topic component")
93 }
94
95 /// A non-persistent topic: `non-persistent://tenant/namespace/topic`.
96 ///
97 /// # Panics
98 ///
99 /// Panics when a component is empty or carries characters Pulsar rejects; use
100 /// [`parse`](Self::parse) for fallible construction from untrusted input.
101 pub fn non_persistent(tenant: &str, namespace: &str, topic: &str) -> Self {
102 Self::of("non-persistent", tenant, namespace, topic)
103 .expect("invalid pulsar topic component")
104 }
105
106 /// Parses a fully qualified name (`persistent://t/ns/topic`), a `tenant/namespace/topic`
107 /// triple (defaulting to persistent), or a bare topic name (defaulting to
108 /// `persistent://public/default/`).
109 ///
110 /// # Errors
111 ///
112 /// Returns [`PulsarError::Invalid`] when the shape or a component is invalid.
113 pub fn parse(name: &str) -> Result<Self, PulsarError> {
114 let (scheme, rest) = name.strip_prefix("non-persistent://").map_or_else(
115 || {
116 name.strip_prefix("persistent://")
117 .map_or(("persistent", name), |rest| ("persistent", rest))
118 },
119 |rest| ("non-persistent", rest),
120 );
121 let parts: Vec<&str> = rest.split('/').collect();
122 match parts.as_slice() {
123 [topic] => Self::of(scheme, "public", "default", topic),
124 [tenant, namespace, topic] => Self::of(scheme, tenant, namespace, topic),
125 _ => Err(PulsarError::Invalid(format!(
126 "topic '{name}' must be 'topic', 'tenant/namespace/topic', or fully qualified"
127 ))),
128 }
129 }
130
131 /// The fully qualified name sent to the broker.
132 #[must_use]
133 pub fn as_str(&self) -> &str {
134 &self.full
135 }
136
137 /// The persistence of the topic: `"persistent"` or `"non-persistent"`.
138 ///
139 /// # Examples
140 ///
141 /// ```
142 /// # mod demo {
143 /// # use ruststream_pulsar::prelude::*;
144 /// # use serde::Deserialize;
145 /// # #[derive(Deserialize)]
146 /// # struct Order {
147 /// # id: u64,
148 /// # }
149 /// # fn orders() -> PulsarTopic {
150 /// # PulsarTopic::persistent("acme", "orders", "created")
151 /// # }
152 /// # #[subscriber(PulsarSubscription::new(orders().as_str(), "workers"))]
153 /// # async fn handle(order: &Order) -> HandlerOutcome {
154 /// # tracing::info!(order.id, "accepted");
155 /// # HandlerOutcome::retry()
156 /// # }
157 /// #[ruststream::app]
158 /// fn app() -> impl App {
159 /// let orders = orders();
160 /// // Spent orders land beside the topic they came from, as durable as it is.
161 /// let dead_letters = format!(
162 /// "{}://{}/{}/{}-dlq",
163 /// orders.persistence(),
164 /// orders.tenant(),
165 /// orders.namespace(),
166 /// orders.name(),
167 /// );
168 /// RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
169 /// PulsarBroker::new("pulsar://localhost:6650"),
170 /// |b| {
171 /// b.include(handle).max_attempts(nonzero!(5)).dead_letter(dead_letters);
172 /// },
173 /// )
174 /// }
175 /// # }
176 /// # fn main() {}
177 /// ```
178 #[must_use]
179 pub fn persistence(&self) -> &str {
180 self.parts().0
181 }
182
183 /// The tenant the topic belongs to; `public` for a bare name.
184 ///
185 /// # Examples
186 ///
187 /// ```
188 /// # mod demo {
189 /// # use ruststream_pulsar::prelude::*;
190 /// # use serde::Deserialize;
191 /// # #[derive(Deserialize)]
192 /// # struct Order {
193 /// # id: u64,
194 /// # }
195 /// # fn orders() -> PulsarTopic {
196 /// # PulsarTopic::persistent("acme", "orders", "created")
197 /// # }
198 /// # #[subscriber(PulsarSubscription::new(orders().as_str(), "workers"))]
199 /// # async fn handle(order: &Order) -> HandlerOutcome {
200 /// # tracing::info!(order.id, "accepted");
201 /// # HandlerOutcome::retry()
202 /// # }
203 /// #[ruststream::app]
204 /// fn app() -> impl App {
205 /// let orders = orders();
206 /// // Every service of the tenant parks its spent messages in one namespace.
207 /// let dead_letters = PulsarTopic::persistent(orders.tenant(), "dead-letters", orders.name())
208 /// .as_str()
209 /// .to_owned();
210 /// RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
211 /// PulsarBroker::new("pulsar://localhost:6650"),
212 /// |b| {
213 /// b.include(handle).max_attempts(nonzero!(5)).dead_letter(dead_letters);
214 /// },
215 /// )
216 /// }
217 /// # }
218 /// # fn main() {}
219 /// ```
220 #[must_use]
221 pub fn tenant(&self) -> &str {
222 self.parts().1
223 }
224
225 /// The namespace the topic lives in; `default` for a bare name.
226 ///
227 /// # Examples
228 ///
229 /// ```
230 /// # mod demo {
231 /// # use ruststream_pulsar::prelude::*;
232 /// # use serde::Deserialize;
233 /// # #[derive(Deserialize)]
234 /// # struct Order {
235 /// # id: u64,
236 /// # }
237 /// # fn orders() -> PulsarTopic {
238 /// # PulsarTopic::persistent("acme", "orders", "created")
239 /// # }
240 /// # #[subscriber(PulsarSubscription::new(orders().as_str(), "workers"))]
241 /// # async fn handle(order: &Order) -> HandlerOutcome {
242 /// # tracing::info!(order.id, "accepted");
243 /// # HandlerOutcome::retry()
244 /// # }
245 /// #[ruststream::app]
246 /// fn app() -> impl App {
247 /// let orders = orders();
248 /// // The namespace keeps one dead-letter topic for all of its topics.
249 /// let dead_letters = PulsarTopic::persistent(orders.tenant(), orders.namespace(), "dlq")
250 /// .as_str()
251 /// .to_owned();
252 /// RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
253 /// PulsarBroker::new("pulsar://localhost:6650"),
254 /// |b| {
255 /// b.include(handle).max_attempts(nonzero!(5)).dead_letter(dead_letters);
256 /// },
257 /// )
258 /// }
259 /// # }
260 /// # fn main() {}
261 /// ```
262 #[must_use]
263 pub fn namespace(&self) -> &str {
264 self.parts().2
265 }
266
267 /// The topic's own name, without tenant, namespace or scheme.
268 ///
269 /// # Examples
270 ///
271 /// ```
272 /// # mod demo {
273 /// # use ruststream_pulsar::prelude::*;
274 /// # use serde::Deserialize;
275 /// # #[derive(Deserialize)]
276 /// # struct Order {
277 /// # id: u64,
278 /// # }
279 /// # fn orders() -> PulsarTopic {
280 /// # PulsarTopic::persistent("acme", "orders", "created")
281 /// # }
282 /// # #[subscriber(PulsarSubscription::new(orders().as_str(), "workers"))]
283 /// # async fn handle(order: &Order) -> HandlerOutcome {
284 /// # tracing::info!(order.id, "accepted");
285 /// # HandlerOutcome::retry()
286 /// # }
287 /// #[ruststream::app]
288 /// fn app() -> impl App {
289 /// let orders = orders();
290 /// // Spent orders go to a topic named after the one they came from.
291 /// let dead_letters = format!("{}-dlq", orders.name());
292 /// RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
293 /// PulsarBroker::new("pulsar://localhost:6650"),
294 /// |b| {
295 /// b.include(handle).max_attempts(nonzero!(5)).dead_letter(dead_letters);
296 /// },
297 /// )
298 /// }
299 /// # }
300 /// # fn main() {}
301 /// ```
302 #[must_use]
303 pub fn name(&self) -> &str {
304 self.parts().3
305 }
306
307 /// Splits the qualified name back into its four meanings.
308 ///
309 /// Every value this type holds was built by [`Self::of`], which writes all four and rejects
310 /// an empty one, so the split cannot come up short.
311 fn parts(&self) -> (&str, &str, &str, &str) {
312 let (scheme, rest) = self
313 .full
314 .split_once("://")
315 .expect("a validated topic carries its scheme");
316 let mut parts = rest.split('/');
317 let mut next = || {
318 parts
319 .next()
320 .expect("a validated topic carries tenant, namespace and name")
321 };
322 let tenant = next();
323 let namespace = next();
324 (scheme, tenant, namespace, next())
325 }
326}
327
328impl std::fmt::Display for PulsarTopic {
329 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
330 f.write_str(&self.full)
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn constructors_qualify_fully() {
340 assert_eq!(
341 PulsarTopic::persistent("acme", "orders", "created").as_str(),
342 "persistent://acme/orders/created"
343 );
344 assert_eq!(
345 PulsarTopic::non_persistent("acme", "telemetry", "ticks").as_str(),
346 "non-persistent://acme/telemetry/ticks"
347 );
348 }
349
350 #[test]
351 fn parse_defaults_bare_names_to_public_default() {
352 assert_eq!(
353 PulsarTopic::parse("orders").expect("parses").as_str(),
354 "persistent://public/default/orders"
355 );
356 }
357
358 /// The four meanings a name carries come back out of it, which is what the generated
359 /// document reports about a channel.
360 #[test]
361 fn a_validated_topic_reports_its_four_parts() {
362 let qualified =
363 PulsarTopic::parse("non-persistent://acme/telemetry/ticks").expect("parses");
364 assert_eq!(qualified.persistence(), "non-persistent");
365 assert_eq!(qualified.tenant(), "acme");
366 assert_eq!(qualified.namespace(), "telemetry");
367 assert_eq!(qualified.name(), "ticks");
368
369 let bare = PulsarTopic::parse("orders").expect("parses");
370 assert_eq!(bare.persistence(), "persistent");
371 assert_eq!(bare.tenant(), "public");
372 assert_eq!(bare.namespace(), "default");
373 assert_eq!(bare.name(), "orders");
374 }
375
376 #[test]
377 fn parse_rejects_malformed_shapes() {
378 assert!(PulsarTopic::parse("a/b").is_err());
379 assert!(PulsarTopic::parse("persistent://a//c").is_err());
380 assert!(PulsarTopic::parse("bad name").is_err());
381 }
382}