#[cfg(feature = "asyncapi")]
use ruststream::asyncapi::Bindings;
use ruststream::runtime::IntoSource;
use ruststream::{NamedCopies, SubscriptionSource};
use super::{Assignment, Commit, GroupSettings, LaneKey, Reader, StartOffset, SubscriptionPlan};
use crate::broker::ConnectedKafkaBroker;
use crate::error::KafkaError;
use crate::subscriber::KafkaSubscriber;
#[derive(Debug, Clone)]
enum Subscribed {
Topic(String),
Pattern(String),
}
impl Subscribed {
fn as_str(&self) -> &str {
match self {
Self::Topic(name) | Self::Pattern(name) => name,
}
}
fn validate(&self) -> Result<(), KafkaError> {
super::reject_empty(self.as_str())?;
match self {
Self::Topic(name) => super::reject_pattern(name, "a topic of `KafkaTopics`"),
Self::Pattern(pattern) if pattern.starts_with('^') => Ok(()),
Self::Pattern(pattern) => Err(KafkaError::InvalidOptions(format!(
"pattern {pattern:?} must start with '^' (librdkafka's anchor for topic \
regexes); without it the name would be subscribed literally"
))),
}
}
}
#[derive(Debug, Clone)]
pub struct KafkaTopics {
entries: Vec<Subscribed>,
name: String,
settings: GroupSettings,
}
impl KafkaTopics {
fn from_entries(entries: Vec<Subscribed>) -> Self {
let name = entries
.iter()
.map(Subscribed::as_str)
.collect::<Vec<_>>()
.join(",");
Self {
entries,
name,
settings: GroupSettings::default(),
}
}
fn push(mut self, entry: Subscribed) -> Self {
if !self.name.is_empty() {
self.name.push(',');
}
self.name.push_str(entry.as_str());
self.entries.push(entry);
self
}
#[must_use]
pub fn new(topics: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self::from_entries(
topics
.into_iter()
.map(|topic| Subscribed::Topic(topic.into()))
.collect(),
)
}
#[must_use]
pub fn pattern(pattern: impl Into<String>) -> Self {
Self::from_entries(vec![Subscribed::Pattern(pattern.into())])
}
#[must_use]
pub fn and_topic(self, topic: impl Into<String>) -> Self {
self.push(Subscribed::Topic(topic.into()))
}
#[must_use]
pub fn and_pattern(self, pattern: impl Into<String>) -> Self {
self.push(Subscribed::Pattern(pattern.into()))
}
#[must_use]
pub fn group(mut self, group: impl Into<String>) -> Self {
self.settings.group = Some(group.into());
self
}
#[must_use]
pub fn start(mut self, start: StartOffset) -> Self {
self.settings.start = start;
self
}
#[must_use]
pub fn commit(mut self, commit: Commit) -> Self {
self.settings.commit = commit;
self
}
#[must_use]
pub fn assignment(mut self, assignment: Assignment) -> Self {
self.settings.assignment = Some(assignment);
self
}
#[must_use]
pub fn lane_key(mut self, lane_key: LaneKey) -> Self {
self.settings.lane_key = lane_key;
self
}
#[must_use]
pub fn config(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.settings.config.set(key.into(), value.into());
self
}
#[must_use]
pub fn topics(&self) -> &str {
&self.name
}
#[cfg(feature = "asyncapi")]
fn operation_bindings(&self) -> Bindings {
crate::bindings::operation(self.settings.group.as_deref(), &self.settings.config)
}
fn into_plan(self) -> Result<SubscriptionPlan, KafkaError> {
if self.entries.is_empty() {
return Err(KafkaError::InvalidOptions(
"`KafkaTopics` subscribes to at least one name; pass the topics the handler \
consumes"
.to_owned(),
));
}
for entry in &self.entries {
entry.validate()?;
}
let name = self.name;
super::reject_commit_mode_clash(&name, &self.settings.commit, &self.settings.config)?;
let names = self
.entries
.into_iter()
.map(|entry| match entry {
Subscribed::Topic(name) | Subscribed::Pattern(name) => name,
})
.collect();
Ok(SubscriptionPlan {
name,
reader: Reader::Subscribed(names),
settings: self.settings,
})
}
}
impl IntoSource for KafkaTopics {
type Source = Self;
fn into_source(self) -> Self {
self
}
}
impl SubscriptionSource<ConnectedKafkaBroker> for KafkaTopics {
type Subscriber = KafkaSubscriber;
type Copies = NamedCopies;
fn name(&self) -> &str {
&self.name
}
async fn subscribe(
self,
connected: &ConnectedKafkaBroker,
) -> Result<Self::Subscriber, KafkaError> {
connected.open(self.into_plan()?).await
}
#[cfg(feature = "asyncapi")]
fn operation_bindings(&self) -> Bindings {
Self::operation_bindings(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_list_subscribes_to_every_name() {
let plan = KafkaTopics::new(["orders", "cancellations"])
.group("orders-svc")
.into_plan()
.expect("two literal topics are a valid subscription");
assert_eq!(plan.name, "orders,cancellations");
assert!(
matches!(plan.reader, Reader::Subscribed(names) if names == ["orders", "cancellations"])
);
}
#[test]
fn an_unanchored_pattern_is_refused() {
let err = KafkaTopics::pattern("orders\\..*")
.into_plan()
.expect_err("an unanchored pattern would be subscribed literally");
let KafkaError::InvalidOptions(message) = err else {
panic!("a refused pattern must be an options error");
};
assert!(
message.contains('^'),
"the error must name the anchor, got: {message}"
);
}
#[test]
fn a_regex_passed_as_a_literal_topic_is_refused() {
let err = KafkaTopics::new(["orders"])
.and_topic("^audit\\..*")
.into_plan()
.expect_err("a regex is not a literal topic name");
assert!(matches!(err, KafkaError::InvalidOptions(_)));
}
#[test]
fn an_empty_set_is_refused() {
let err = KafkaTopics::new(Vec::<String>::new())
.into_plan()
.expect_err("a subscription to nothing is not a subscription");
assert!(matches!(err, KafkaError::InvalidOptions(_)));
}
}