ruststream_rdkafka/subscription/
topics.rs1#[cfg(feature = "asyncapi")]
4use ruststream::asyncapi::Bindings;
5use ruststream::runtime::IntoSource;
6use ruststream::{NamedCopies, SubscriptionSource};
7
8use super::{Assignment, Commit, GroupSettings, LaneKey, Reader, StartOffset, SubscriptionPlan};
9use crate::broker::ConnectedKafkaBroker;
10use crate::error::KafkaError;
11use crate::subscriber::KafkaSubscriber;
12
13#[derive(Debug, Clone)]
16enum Subscribed {
17 Topic(String),
18 Pattern(String),
19}
20
21impl Subscribed {
22 fn as_str(&self) -> &str {
23 match self {
24 Self::Topic(name) | Self::Pattern(name) => name,
25 }
26 }
27
28 fn validate(&self) -> Result<(), KafkaError> {
29 super::reject_empty(self.as_str())?;
30 match self {
31 Self::Topic(name) => super::reject_pattern(name, "a topic of `KafkaTopics`"),
32 Self::Pattern(pattern) if pattern.starts_with('^') => Ok(()),
33 Self::Pattern(pattern) => Err(KafkaError::InvalidOptions(format!(
34 "pattern {pattern:?} must start with '^' (librdkafka's anchor for topic \
35 regexes); without it the name would be subscribed literally"
36 ))),
37 }
38 }
39}
40
41#[derive(Debug, Clone)]
75pub struct KafkaTopics {
76 entries: Vec<Subscribed>,
77 name: String,
80 settings: GroupSettings,
81}
82
83impl KafkaTopics {
84 fn from_entries(entries: Vec<Subscribed>) -> Self {
85 let name = entries
86 .iter()
87 .map(Subscribed::as_str)
88 .collect::<Vec<_>>()
89 .join(",");
90 Self {
91 entries,
92 name,
93 settings: GroupSettings::default(),
94 }
95 }
96
97 fn push(mut self, entry: Subscribed) -> Self {
98 if !self.name.is_empty() {
99 self.name.push(',');
100 }
101 self.name.push_str(entry.as_str());
102 self.entries.push(entry);
103 self
104 }
105
106 #[must_use]
108 pub fn new(topics: impl IntoIterator<Item = impl Into<String>>) -> Self {
109 Self::from_entries(
110 topics
111 .into_iter()
112 .map(|topic| Subscribed::Topic(topic.into()))
113 .collect(),
114 )
115 }
116
117 #[must_use]
145 pub fn pattern(pattern: impl Into<String>) -> Self {
146 Self::from_entries(vec![Subscribed::Pattern(pattern.into())])
147 }
148
149 #[must_use]
151 pub fn and_topic(self, topic: impl Into<String>) -> Self {
152 self.push(Subscribed::Topic(topic.into()))
153 }
154
155 #[must_use]
157 pub fn and_pattern(self, pattern: impl Into<String>) -> Self {
158 self.push(Subscribed::Pattern(pattern.into()))
159 }
160
161 #[must_use]
164 pub fn group(mut self, group: impl Into<String>) -> Self {
165 self.settings.group = Some(group.into());
166 self
167 }
168
169 #[must_use]
171 pub fn start(mut self, start: StartOffset) -> Self {
172 self.settings.start = start;
173 self
174 }
175
176 #[must_use]
178 pub fn commit(mut self, commit: Commit) -> Self {
179 self.settings.commit = commit;
180 self
181 }
182
183 #[must_use]
186 pub fn assignment(mut self, assignment: Assignment) -> Self {
187 self.settings.assignment = Some(assignment);
188 self
189 }
190
191 #[must_use]
193 pub fn lane_key(mut self, lane_key: LaneKey) -> Self {
194 self.settings.lane_key = lane_key;
195 self
196 }
197
198 #[must_use]
204 pub fn config(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
205 self.settings.config.set(key.into(), value.into());
206 self
207 }
208
209 #[must_use]
211 pub fn topics(&self) -> &str {
212 &self.name
213 }
214
215 #[cfg(feature = "asyncapi")]
221 fn operation_bindings(&self) -> Bindings {
222 crate::bindings::operation(self.settings.group.as_deref(), &self.settings.config)
223 }
224
225 fn into_plan(self) -> Result<SubscriptionPlan, KafkaError> {
226 if self.entries.is_empty() {
227 return Err(KafkaError::InvalidOptions(
228 "`KafkaTopics` subscribes to at least one name; pass the topics the handler \
229 consumes"
230 .to_owned(),
231 ));
232 }
233 for entry in &self.entries {
234 entry.validate()?;
235 }
236 let name = self.name;
237 super::reject_commit_mode_clash(&name, &self.settings.commit, &self.settings.config)?;
238 let names = self
239 .entries
240 .into_iter()
241 .map(|entry| match entry {
242 Subscribed::Topic(name) | Subscribed::Pattern(name) => name,
243 })
244 .collect();
245 Ok(SubscriptionPlan {
246 name,
247 reader: Reader::Subscribed(names),
248 settings: self.settings,
249 })
250 }
251}
252
253impl IntoSource for KafkaTopics {
256 type Source = Self;
257
258 fn into_source(self) -> Self {
259 self
260 }
261}
262
263impl SubscriptionSource<ConnectedKafkaBroker> for KafkaTopics {
264 type Subscriber = KafkaSubscriber;
265 type Copies = NamedCopies;
266
267 fn name(&self) -> &str {
268 &self.name
269 }
270
271 async fn subscribe(
272 self,
273 connected: &ConnectedKafkaBroker,
274 ) -> Result<Self::Subscriber, KafkaError> {
275 connected.open(self.into_plan()?).await
276 }
277
278 #[cfg(feature = "asyncapi")]
279 fn operation_bindings(&self) -> Bindings {
280 Self::operation_bindings(self)
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 #[test]
289 fn a_list_subscribes_to_every_name() {
290 let plan = KafkaTopics::new(["orders", "cancellations"])
291 .group("orders-svc")
292 .into_plan()
293 .expect("two literal topics are a valid subscription");
294 assert_eq!(plan.name, "orders,cancellations");
295 assert!(
296 matches!(plan.reader, Reader::Subscribed(names) if names == ["orders", "cancellations"])
297 );
298 }
299
300 #[test]
301 fn an_unanchored_pattern_is_refused() {
302 let err = KafkaTopics::pattern("orders\\..*")
303 .into_plan()
304 .expect_err("an unanchored pattern would be subscribed literally");
305 let KafkaError::InvalidOptions(message) = err else {
306 panic!("a refused pattern must be an options error");
307 };
308 assert!(
309 message.contains('^'),
310 "the error must name the anchor, got: {message}"
311 );
312 }
313
314 #[test]
317 fn a_regex_passed_as_a_literal_topic_is_refused() {
318 let err = KafkaTopics::new(["orders"])
319 .and_topic("^audit\\..*")
320 .into_plan()
321 .expect_err("a regex is not a literal topic name");
322 assert!(matches!(err, KafkaError::InvalidOptions(_)));
323 }
324
325 #[test]
326 fn an_empty_set_is_refused() {
327 let err = KafkaTopics::new(Vec::<String>::new())
328 .into_plan()
329 .expect_err("a subscription to nothing is not a subscription");
330 assert!(matches!(err, KafkaError::InvalidOptions(_)));
331 }
332}