use async_trait::async_trait;
use serde::Serialize;
use super::{SubscriptionError, transport::TransportAdapter, types::SubscriptionEvent};
#[derive(Debug, Clone)]
pub struct KafkaConfig {
pub endpoint: String,
pub default_topic: String,
pub client_id: String,
pub acks: String,
pub timeout_ms: u64,
pub compression: Option<String>,
}
impl KafkaConfig {
#[must_use]
pub fn new(endpoint: impl Into<String>, default_topic: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
default_topic: default_topic.into(),
client_id: "fraiseql".to_string(),
acks: "all".to_string(),
timeout_ms: 5_000,
compression: None,
}
}
#[must_use]
pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {
self.client_id = client_id.into();
self
}
#[must_use]
pub fn with_acks(mut self, acks: impl Into<String>) -> Self {
self.acks = acks.into();
self
}
#[must_use]
pub const fn with_timeout(mut self, timeout_ms: u64) -> Self {
self.timeout_ms = timeout_ms;
self
}
#[must_use]
pub fn with_compression(mut self, compression: impl Into<String>) -> Self {
self.compression = Some(compression.into());
self
}
}
#[derive(Debug, Clone, Serialize)]
pub struct KafkaMessage {
pub event_id: String,
pub subscription_name: String,
pub entity_type: String,
pub entity_id: String,
pub operation: String,
pub data: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub old_data: Option<serde_json::Value>,
pub timestamp: String,
pub sequence_number: u64,
}
impl KafkaMessage {
#[must_use]
pub fn from_event(event: &SubscriptionEvent, subscription_name: &str) -> Self {
Self {
event_id: event.event_id.clone(),
subscription_name: subscription_name.to_string(),
entity_type: event.entity_type.clone(),
entity_id: event.entity_id.clone(),
operation: format!("{:?}", event.operation),
data: event.data.clone(),
old_data: event.old_data.clone(),
timestamp: event.timestamp.to_rfc3339(),
sequence_number: event.sequence_number,
}
}
#[must_use]
pub fn key(&self) -> &str {
&self.entity_id
}
}
#[cfg(feature = "kafka")]
pub struct KafkaAdapter {
config: KafkaConfig,
egress: fraiseql_kafka::KafkaEgress,
}
#[cfg(feature = "kafka")]
impl std::fmt::Debug for KafkaAdapter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("KafkaAdapter")
.field("endpoint", &self.config.endpoint)
.field("default_topic", &self.config.default_topic)
.field("client_id", &self.config.client_id)
.finish_non_exhaustive()
}
}
#[cfg(feature = "kafka")]
impl KafkaAdapter {
pub fn new(config: KafkaConfig) -> Result<Self, SubscriptionError> {
use std::time::Duration;
let egress_config = fraiseql_kafka::KafkaEgressConfig::new(config.client_id.clone())
.with_timeouts(
Duration::from_millis(config.timeout_ms),
Duration::from_millis(config.timeout_ms.min(1_000)),
)
.with_compression(config.compression.clone());
let egress = fraiseql_kafka::KafkaEgress::connect(&config.endpoint, &egress_config)
.map_err(|error| {
SubscriptionError::Internal(format!("Failed to create Kafka producer: {error}"))
})?;
tracing::info!(
endpoint = %config.endpoint,
topic = %config.default_topic,
client_id = %config.client_id,
"KafkaAdapter connected through the shared Kafka egress"
);
Ok(Self { config, egress })
}
fn get_topic(&self, _subscription_name: &str) -> &str {
&self.config.default_topic
}
}
#[cfg(feature = "kafka")]
#[async_trait]
impl TransportAdapter for KafkaAdapter {
async fn deliver(
&self,
event: &SubscriptionEvent,
subscription_name: &str,
) -> Result<(), SubscriptionError> {
use fraiseql_kafka::{EgressOutcome, EgressRecord};
let message = KafkaMessage::from_event(event, subscription_name);
let topic = self.get_topic(subscription_name);
let payload = serde_json::to_string(&message).map_err(|e| {
SubscriptionError::Internal(format!("Failed to serialize message: {e}"))
})?;
let outcome = self
.egress
.send(EgressRecord {
topic,
key: message.key(),
payload: payload.as_bytes(),
headers: &[("fraiseql-event-id", &message.event_id)],
})
.await;
match outcome {
EgressOutcome::Delivered => {
tracing::debug!(
topic = topic,
key = message.key(),
event_id = %event.event_id,
"Kafka message delivered successfully"
);
Ok(())
},
EgressOutcome::Transient(reason) => {
tracing::error!(
topic = topic,
key = message.key(),
event_id = %event.event_id,
%reason,
"Kafka delivery failed; at-most-once, so this event is not retried"
);
Err(SubscriptionError::DeliveryFailed {
transport: "kafka".to_string(),
reason,
})
},
EgressOutcome::Permanent(reason) => {
tracing::error!(
topic = topic,
key = message.key(),
event_id = %event.event_id,
%reason,
"Kafka refused this message permanently; redelivery could not succeed"
);
Err(SubscriptionError::DeliveryFailed {
transport: "kafka".to_string(),
reason,
})
},
}
}
fn name(&self) -> &'static str {
"kafka"
}
async fn health_check(&self) -> bool {
self.egress.is_reachable(std::time::Duration::from_secs(5))
}
}
#[cfg(not(feature = "kafka"))]
#[derive(Debug)]
pub struct KafkaAdapter {
config: KafkaConfig,
}
#[cfg(not(feature = "kafka"))]
impl KafkaAdapter {
pub fn new(config: KafkaConfig) -> Result<Self, SubscriptionError> {
tracing::warn!(
endpoint = %config.endpoint,
topic = %config.default_topic,
"KafkaAdapter created (STUB - enable 'kafka' feature for real Kafka support)"
);
Ok(Self { config })
}
fn get_topic(&self, _subscription_name: &str) -> &str {
&self.config.default_topic
}
}
#[cfg(not(feature = "kafka"))]
#[async_trait]
impl TransportAdapter for KafkaAdapter {
async fn deliver(
&self,
event: &SubscriptionEvent,
subscription_name: &str,
) -> Result<(), SubscriptionError> {
let topic = self.get_topic(subscription_name);
Err(SubscriptionError::Internal(format!(
"Kafka transport is not compiled into this binary (event {event_id} for topic \
'{topic}' not delivered). Rebuild with `--features kafka`.",
event_id = event.event_id,
)))
}
fn name(&self) -> &'static str {
"kafka"
}
async fn health_check(&self) -> bool {
tracing::debug!("Kafka health check (STUB): kafka feature not compiled in — unhealthy");
false
}
}