use ruststream::SubscriptionSource;
use crate::broker::KafkaBroker;
use crate::error::KafkaError;
use crate::retry::Retry;
use crate::subscriber::KafkaSubscriber;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum StartOffset {
#[default]
Committed,
Earliest,
Latest,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Assignment {
Range,
RoundRobin,
CooperativeSticky,
}
impl Assignment {
pub(crate) fn as_config_value(self) -> &'static str {
match self {
Self::Range => "range",
Self::RoundRobin => "roundrobin",
Self::CooperativeSticky => "cooperative-sticky",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum LaneKey {
#[default]
Partition,
RecordKey,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum Commit {
#[default]
Auto,
Tracked,
Transactional(String),
}
#[derive(Debug, Clone)]
pub struct KafkaTopic {
topics: Vec<String>,
name: String,
requires_pattern: bool,
group: Option<String>,
start: StartOffset,
commit: Commit,
assignment: Option<Assignment>,
lane_key: LaneKey,
partitions: Vec<i32>,
retry: Option<Retry>,
max_deliveries: Option<u32>,
dead_letter: Option<String>,
config: Vec<(String, String)>,
}
impl KafkaTopic {
fn with_first(first: String, requires_pattern: bool) -> Self {
Self {
name: first.clone(),
topics: vec![first],
requires_pattern,
group: None,
start: StartOffset::default(),
commit: Commit::default(),
assignment: None,
lane_key: LaneKey::default(),
partitions: Vec::new(),
retry: None,
max_deliveries: None,
dead_letter: None,
config: Vec::new(),
}
}
#[must_use]
pub fn new(topic: impl Into<String>) -> Self {
Self::with_first(topic.into(), false)
}
#[must_use]
pub fn pattern(pattern: impl Into<String>) -> Self {
Self::with_first(pattern.into(), true)
}
#[must_use]
pub fn and_topic(mut self, topic: impl Into<String>) -> Self {
let topic = topic.into();
self.name.push(',');
self.name.push_str(&topic);
self.topics.push(topic);
self
}
#[must_use]
pub fn group(mut self, group: impl Into<String>) -> Self {
self.group = Some(group.into());
self
}
#[must_use]
pub fn start(mut self, start: StartOffset) -> Self {
self.start = start;
self
}
#[must_use]
pub fn commit(mut self, commit: Commit) -> Self {
self.commit = commit;
self
}
#[must_use]
pub fn assignment(mut self, assignment: Assignment) -> Self {
self.assignment = Some(assignment);
self
}
#[must_use]
pub fn lane_key(mut self, lane_key: LaneKey) -> Self {
self.lane_key = lane_key;
self
}
#[must_use]
pub fn partitions(mut self, partitions: impl IntoIterator<Item = i32>) -> Self {
self.partitions = partitions.into_iter().collect();
self
}
#[must_use]
pub fn retry(mut self, retry: Retry) -> Self {
self.retry = Some(retry);
self
}
#[must_use]
pub fn max_deliveries(mut self, max_deliveries: u32) -> Self {
self.max_deliveries = Some(max_deliveries);
self
}
#[must_use]
pub fn dead_letter(mut self, topic: impl Into<String>) -> Self {
self.dead_letter = Some(topic.into());
self
}
#[must_use]
pub fn config(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.config.push((key.into(), value.into()));
self
}
#[must_use]
pub fn topic(&self) -> &str {
&self.name
}
pub(crate) fn subscribed_topics(&self) -> &[String] {
&self.topics
}
pub(crate) fn validate(&self) -> Result<(), KafkaError> {
if self.requires_pattern && !self.topics[0].starts_with('^') {
return Err(KafkaError::InvalidOptions(format!(
"pattern {:?} must start with '^' (librdkafka's anchor for topic regexes); \
without it the name would be subscribed literally",
self.topics[0],
)));
}
if !self.partitions.is_empty() && (self.topics.len() > 1 || self.requires_pattern) {
return Err(KafkaError::InvalidOptions(
"manual partition assignment names exact partitions of one topic; it does \
not combine with `and_topic` or `pattern`"
.to_owned(),
));
}
Ok(())
}
pub(crate) fn group_or<'a>(&'a self, fallback: Option<&'a str>) -> Option<&'a str> {
self.group.as_deref().or(fallback)
}
pub(crate) fn start_offset(&self) -> StartOffset {
self.start
}
pub(crate) fn commit_mode(&self) -> &Commit {
&self.commit
}
pub(crate) fn assignment_strategy(&self) -> Option<Assignment> {
self.assignment
}
pub(crate) fn lane_key_choice(&self) -> LaneKey {
self.lane_key
}
pub(crate) fn retry_policy(&self) -> Option<&Retry> {
self.retry.as_ref()
}
pub(crate) fn max_deliveries_cap(&self) -> Option<u32> {
self.max_deliveries
}
pub(crate) fn dead_letter_topic(&self) -> Option<&str> {
self.dead_letter.as_deref()
}
pub(crate) fn assigned_partitions(&self) -> &[i32] {
&self.partitions
}
pub(crate) fn config_entries(&self) -> &[(String, String)] {
&self.config
}
}
impl SubscriptionSource<KafkaBroker> for KafkaTopic {
type Subscriber = KafkaSubscriber;
fn name(&self) -> &str {
&self.name
}
async fn subscribe(self, broker: &KafkaBroker) -> Result<Self::Subscriber, KafkaError> {
broker.subscribe(self).await
}
}
#[cfg(feature = "testing")]
impl SubscriptionSource<crate::testing::KafkaTestBroker> for KafkaTopic {
type Subscriber = crate::testing::KafkaTestSubscriber;
fn name(&self) -> &str {
&self.name
}
async fn subscribe(
self,
broker: &crate::testing::KafkaTestBroker,
) -> Result<Self::Subscriber, KafkaError> {
if !self.partitions.is_empty() {
return Err(KafkaError::InvalidOptions(
"the in-process test broker does not simulate partitions; manual partition \
assignment needs a real cluster"
.to_owned(),
));
}
broker.subscribe_topics(&self.topics).await
}
}