Skip to main content

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}