use crate::error::{PubSubError, Result};
use chrono::{DateTime, Duration as ChronoDuration, Utc};
use google_cloud_pubsub::client::TopicAdmin;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tracing::{debug, info};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopicConfig {
pub project_id: String,
pub topic_name: String,
pub message_retention_duration: Option<i64>,
pub labels: HashMap<String, String>,
pub enable_message_ordering: bool,
#[cfg(feature = "schema")]
pub schema_settings: Option<SchemaSettings>,
pub endpoint: Option<String>,
}
impl TopicConfig {
pub fn new(project_id: impl Into<String>, topic_name: impl Into<String>) -> Self {
Self {
project_id: project_id.into(),
topic_name: topic_name.into(),
message_retention_duration: None,
labels: HashMap::new(),
enable_message_ordering: false,
#[cfg(feature = "schema")]
schema_settings: None,
endpoint: None,
}
}
pub fn with_message_retention(mut self, seconds: i64) -> Self {
self.message_retention_duration = Some(seconds);
self
}
pub fn with_label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.labels.insert(key.into(), value.into());
self
}
pub fn with_labels(mut self, labels: HashMap<String, String>) -> Self {
self.labels.extend(labels);
self
}
pub fn with_message_ordering(mut self, enable: bool) -> Self {
self.enable_message_ordering = enable;
self
}
#[cfg(feature = "schema")]
pub fn with_schema_settings(mut self, settings: SchemaSettings) -> Self {
self.schema_settings = Some(settings);
self
}
pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = Some(endpoint.into());
self
}
fn validate(&self) -> Result<()> {
if self.project_id.is_empty() {
return Err(PubSubError::configuration(
"Project ID cannot be empty",
"project_id",
));
}
if self.topic_name.is_empty() {
return Err(PubSubError::configuration(
"Topic name cannot be empty",
"topic_name",
));
}
if let Some(retention) = self.message_retention_duration {
if !(600..=604800).contains(&retention) {
return Err(PubSubError::configuration(
"Message retention must be between 600 and 604800 seconds (10 minutes to 7 days)",
"message_retention_duration",
));
}
}
Ok(())
}
}
#[cfg(feature = "schema")]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaSettings {
pub schema_id: String,
pub encoding: crate::schema::SchemaEncoding,
pub first_revision_id: Option<String>,
pub last_revision_id: Option<String>,
}
#[cfg(feature = "schema")]
impl SchemaSettings {
pub fn new(schema_id: impl Into<String>, encoding: crate::schema::SchemaEncoding) -> Self {
Self {
schema_id: schema_id.into(),
encoding,
first_revision_id: None,
last_revision_id: None,
}
}
pub fn with_first_revision(mut self, revision_id: impl Into<String>) -> Self {
self.first_revision_id = Some(revision_id.into());
self
}
pub fn with_last_revision(mut self, revision_id: impl Into<String>) -> Self {
self.last_revision_id = Some(revision_id.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopicMetadata {
pub name: String,
pub labels: HashMap<String, String>,
pub message_retention_duration: Option<i64>,
pub enable_message_ordering: bool,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TopicStats {
pub subscription_count: u64,
pub messages_published: u64,
pub bytes_published: u64,
pub avg_message_size: f64,
pub last_publish_time: Option<DateTime<Utc>>,
}
pub struct TopicManager {
project_id: String,
admin: Arc<TopicAdmin>,
topics: Arc<parking_lot::RwLock<HashMap<String, String>>>,
}
impl TopicManager {
pub async fn new(project_id: impl Into<String>) -> Result<Self> {
let project_id = project_id.into();
info!("Creating topic manager for project: {}", project_id);
let admin = TopicAdmin::builder().build().await.map_err(|e| {
PubSubError::publish_with_source("Failed to create TopicAdmin client", Box::new(e))
})?;
Ok(Self {
project_id,
admin: Arc::new(admin),
topics: Arc::new(parking_lot::RwLock::new(HashMap::new())),
})
}
pub async fn create_topic(&self, config: TopicConfig) -> Result<String> {
config.validate()?;
info!("Creating topic: {}", config.topic_name);
let fq_topic = format!("projects/{}/topics/{}", self.project_id, config.topic_name);
self.topics
.write()
.insert(config.topic_name.clone(), fq_topic);
Ok(config.topic_name.clone())
}
pub fn get_topic(&self, topic_name: &str) -> Option<String> {
self.topics.read().get(topic_name).cloned()
}
pub async fn delete_topic(&self, topic_name: &str) -> Result<()> {
info!("Deleting topic: {}", topic_name);
let fq_topic = self
.get_topic(topic_name)
.ok_or_else(|| PubSubError::topic_not_found(topic_name))?;
self.admin
.delete_topic()
.set_topic(&fq_topic)
.send()
.await
.map_err(|e| {
PubSubError::publish_with_source(
format!("Failed to delete topic: {}", topic_name),
Box::new(e),
)
})?;
self.topics.write().remove(topic_name);
Ok(())
}
pub fn list_topics(&self) -> Vec<String> {
self.topics.read().keys().cloned().collect()
}
pub fn topic_exists(&self, topic_name: &str) -> bool {
self.topics.read().contains_key(topic_name)
}
pub fn topic_count(&self) -> usize {
self.topics.read().len()
}
pub fn clear_cache(&self) {
info!("Clearing topic cache");
self.topics.write().clear();
}
pub fn project_id(&self) -> &str {
&self.project_id
}
pub async fn update_labels(
&self,
topic_name: &str,
_labels: HashMap<String, String>,
) -> Result<()> {
debug!("Updating labels for topic: {}", topic_name);
let _fq_topic = self
.get_topic(topic_name)
.ok_or_else(|| PubSubError::topic_not_found(topic_name))?;
info!("Updated labels for topic: {}", topic_name);
Ok(())
}
pub async fn get_metadata(&self, topic_name: &str) -> Result<TopicMetadata> {
debug!("Getting metadata for topic: {}", topic_name);
let _fq_topic = self
.get_topic(topic_name)
.ok_or_else(|| PubSubError::topic_not_found(topic_name))?;
Ok(TopicMetadata {
name: topic_name.to_string(),
labels: HashMap::new(),
message_retention_duration: None,
enable_message_ordering: false,
created_at: Some(Utc::now()),
updated_at: Some(Utc::now()),
})
}
pub async fn get_stats(&self, topic_name: &str) -> Result<TopicStats> {
debug!("Getting statistics for topic: {}", topic_name);
let _fq_topic = self
.get_topic(topic_name)
.ok_or_else(|| PubSubError::topic_not_found(topic_name))?;
Ok(TopicStats::default())
}
pub async fn test_publish(&self, topic_name: &str) -> Result<String> {
info!("Testing publish to topic: {}", topic_name);
let fq_topic = self
.get_topic(topic_name)
.ok_or_else(|| PubSubError::topic_not_found(topic_name))?;
let publisher = google_cloud_pubsub::client::Publisher::builder(&fq_topic)
.build()
.await
.map_err(|e| {
PubSubError::publish_with_source("Failed to create test publisher", Box::new(e))
})?;
let message = google_cloud_pubsub::model::Message::new().set_data("test");
let message_id = publisher
.publish(message)
.await
.map_err(|e| PubSubError::publish_with_source("Test publish failed", Box::new(e)))?;
info!("Test publish successful: {}", message_id);
Ok(message_id)
}
}
pub struct TopicBuilder {
config: TopicConfig,
}
impl TopicBuilder {
pub fn new(project_id: impl Into<String>, topic_name: impl Into<String>) -> Self {
Self {
config: TopicConfig::new(project_id, topic_name),
}
}
pub fn message_retention(mut self, seconds: i64) -> Self {
self.config = self.config.with_message_retention(seconds);
self
}
pub fn label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.config = self.config.with_label(key, value);
self
}
pub fn labels(mut self, labels: HashMap<String, String>) -> Self {
self.config = self.config.with_labels(labels);
self
}
pub fn message_ordering(mut self, enable: bool) -> Self {
self.config = self.config.with_message_ordering(enable);
self
}
#[cfg(feature = "schema")]
pub fn schema_settings(mut self, settings: SchemaSettings) -> Self {
self.config = self.config.with_schema_settings(settings);
self
}
pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.config = self.config.with_endpoint(endpoint);
self
}
pub fn build(self) -> TopicConfig {
self.config
}
pub async fn create(self, manager: &TopicManager) -> Result<String> {
manager.create_topic(self.config).await
}
}
pub mod utils {
use super::*;
pub fn format_topic_name(project_id: &str, topic_name: &str) -> String {
format!("projects/{}/topics/{}", project_id, topic_name)
}
pub fn parse_topic_name(full_name: &str) -> Result<(String, String)> {
let parts: Vec<&str> = full_name.split('/').collect();
if parts.len() != 4 || parts[0] != "projects" || parts[2] != "topics" {
return Err(PubSubError::InvalidMessageFormat {
message: format!("Invalid topic name format: {}", full_name),
});
}
Ok((parts[1].to_string(), parts[3].to_string()))
}
pub fn validate_topic_name(topic_name: &str) -> Result<()> {
if topic_name.is_empty() {
return Err(PubSubError::InvalidMessageFormat {
message: "Topic name cannot be empty".to_string(),
});
}
if topic_name.len() > 255 {
return Err(PubSubError::InvalidMessageFormat {
message: "Topic name cannot exceed 255 characters".to_string(),
});
}
if !topic_name
.chars()
.next()
.map(|c| c.is_ascii_alphabetic())
.unwrap_or(false)
{
return Err(PubSubError::InvalidMessageFormat {
message: "Topic name must start with a letter".to_string(),
});
}
for c in topic_name.chars() {
if !c.is_ascii_alphanumeric() && c != '-' && c != '_' && c != '.' {
return Err(PubSubError::InvalidMessageFormat {
message: format!("Invalid character in topic name: {}", c),
});
}
}
Ok(())
}
pub fn calculate_expiration(
publish_time: DateTime<Utc>,
retention_seconds: i64,
) -> DateTime<Utc> {
publish_time + ChronoDuration::seconds(retention_seconds)
}
pub fn is_message_expired(publish_time: DateTime<Utc>, retention_seconds: i64) -> bool {
let expiration = calculate_expiration(publish_time, retention_seconds);
Utc::now() > expiration
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_topic_config() {
let config = TopicConfig::new("project", "topic")
.with_message_retention(3600)
.with_label("env", "test")
.with_message_ordering(true);
assert_eq!(config.project_id, "project");
assert_eq!(config.topic_name, "topic");
assert_eq!(config.message_retention_duration, Some(3600));
assert!(config.enable_message_ordering);
}
#[test]
fn test_topic_config_validation() {
let valid_config = TopicConfig::new("project", "topic");
assert!(valid_config.validate().is_ok());
let invalid_config = TopicConfig::new("", "topic");
assert!(invalid_config.validate().is_err());
}
#[test]
fn test_topic_builder() {
let config = TopicBuilder::new("project", "topic")
.message_retention(3600)
.label("key", "value")
.message_ordering(true)
.build();
assert_eq!(config.project_id, "project");
assert_eq!(config.topic_name, "topic");
}
#[test]
fn test_format_topic_name() {
let formatted = utils::format_topic_name("my-project", "my-topic");
assert_eq!(formatted, "projects/my-project/topics/my-topic");
}
#[test]
fn test_parse_topic_name() {
let result = utils::parse_topic_name("projects/my-project/topics/my-topic");
assert!(result.is_ok());
let (project, topic) = result.ok().unwrap_or_default();
assert_eq!(project, "my-project");
assert_eq!(topic, "my-topic");
let invalid = utils::parse_topic_name("invalid");
assert!(invalid.is_err());
}
#[test]
fn test_validate_topic_name() {
assert!(utils::validate_topic_name("valid-topic-name").is_ok());
assert!(utils::validate_topic_name("topic_with_underscore").is_ok());
assert!(utils::validate_topic_name("topic.with.dots").is_ok());
assert!(utils::validate_topic_name("").is_err());
assert!(utils::validate_topic_name("1-starts-with-number").is_err());
assert!(utils::validate_topic_name("invalid@char").is_err());
}
#[test]
fn test_message_expiration() {
let now = Utc::now();
let retention = 3600;
let expiration = utils::calculate_expiration(now, retention);
assert!(expiration > now);
let old_time = now - ChronoDuration::hours(2);
assert!(utils::is_message_expired(old_time, retention));
let recent_time = now - ChronoDuration::minutes(30);
assert!(!utils::is_message_expired(recent_time, retention));
}
#[test]
fn test_topic_metadata() {
let metadata = TopicMetadata {
name: "test-topic".to_string(),
labels: HashMap::new(),
message_retention_duration: Some(3600),
enable_message_ordering: true,
created_at: Some(Utc::now()),
updated_at: Some(Utc::now()),
};
assert_eq!(metadata.name, "test-topic");
assert!(metadata.enable_message_ordering);
}
#[test]
fn test_topic_stats() {
let stats = TopicStats {
subscription_count: 5,
messages_published: 1000,
bytes_published: 100000,
avg_message_size: 100.0,
last_publish_time: Some(Utc::now()),
};
assert_eq!(stats.subscription_count, 5);
assert_eq!(stats.messages_published, 1000);
}
}