mod partitions;
mod topic;
mod topics;
pub use partitions::KafkaPartitions;
pub use topic::KafkaTopic;
pub use topics::KafkaTopics;
use rdkafka::error::KafkaError as RdKafkaError;
use crate::error::KafkaError;
use crate::properties::Properties;
#[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, Default)]
pub(crate) struct GroupSettings {
pub(crate) group: Option<String>,
pub(crate) start: StartOffset,
pub(crate) commit: Commit,
pub(crate) assignment: Option<Assignment>,
pub(crate) lane_key: LaneKey,
pub(crate) config: Properties,
}
#[derive(Debug)]
pub(crate) enum Reader {
Subscribed(Vec<String>),
Assigned { topic: String, partitions: Vec<i32> },
}
pub(crate) fn legal_topic(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 249
&& name != "."
&& name != ".."
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
}
pub(crate) fn refuse_illegal_topics(reader: &Reader) -> Result<(), KafkaError> {
let illegal = match reader {
Reader::Subscribed(names) => names
.iter()
.find(|name| !name.starts_with('^') && !legal_topic(name)),
Reader::Assigned { topic, .. } => Some(topic).filter(|topic| !legal_topic(topic)),
};
illegal.map_or(Ok(()), |topic| {
Err(KafkaError::subscribe(RdKafkaError::Subscription(format!(
"{topic:?} is not a legal Kafka topic name"
))))
})
}
#[derive(Debug)]
pub(crate) struct SubscriptionPlan {
pub(crate) name: String,
pub(crate) reader: Reader,
pub(crate) settings: GroupSettings,
}
pub(crate) fn reject_pattern(name: &str, form: &str) -> Result<(), KafkaError> {
if name.starts_with('^') {
return Err(KafkaError::InvalidOptions(format!(
"{form} reads one topic, and {name:?} is a librdkafka topic regex (the leading '^' \
is its anchor); subscribe to a pattern with `KafkaTopics::pattern`"
)));
}
Ok(())
}
const MODE_OWNED_PROPERTIES: [&str; 2] = ["enable.auto.offset.store", "enable.auto.commit"];
pub(crate) fn reject_commit_mode_clash(
name: &str,
commit: &Commit,
config: &[(String, String)],
) -> Result<(), KafkaError> {
let mode = match commit {
Commit::Auto => return Ok(()),
Commit::Tracked => "`Commit::Tracked`",
Commit::Transactional(_) => "`Commit::Transactional`",
};
for (key, value) in config {
let Some(owned) = MODE_OWNED_PROPERTIES
.iter()
.find(|owned| key.eq_ignore_ascii_case(owned))
else {
continue;
};
return Err(KafkaError::InvalidOptions(format!(
"subscription to {name:?} sets {owned}={value:?} through its config passthrough, \
and {mode} owns that property: the passthrough would leave the committed position \
to librdkafka while every ack decided nothing. Drop it, or take `Commit::Auto`, \
which owns neither"
)));
}
Ok(())
}
pub(crate) fn reject_empty(name: &str) -> Result<(), KafkaError> {
if name.is_empty() {
return Err(KafkaError::InvalidOptions(
"topic name must not be empty; name the topic the handler consumes".to_owned(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn topic_names_follow_kafkas_rule() {
assert!(legal_topic("orders.eu-1_v2"));
for bad in ["", ".", "..", "orders eu", "orders/eu", &"x".repeat(250)] {
assert!(!legal_topic(bad), "{bad:?} must be refused");
}
}
}