aster_forge_config/notification/
config.rs1use serde::{Deserialize, Serialize};
2#[cfg(feature = "redis-pubsub")]
3use std::sync::Arc;
4
5use crate::{ConfigCoreError, Result};
6
7#[cfg(feature = "redis-pubsub")]
8use super::notifier::RedisConfigChangeNotifier;
9#[cfg(feature = "redis-pubsub")]
10use super::notifier::SharedConfigChangeNotifier;
11use super::runtime::ConfigSyncRuntime;
12
13pub const CONFIG_SYNC_BACKEND_DISABLED: &str = "disabled";
15pub const CONFIG_SYNC_BACKEND_REDIS: &str = "redis";
17
18#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(untagged)]
24pub enum ConfigSyncEndpoint {
25 Url(String),
27 Credentials {
29 base_url: String,
31 #[serde(default, skip_serializing)]
33 username: Option<String>,
34 #[serde(default, skip_serializing)]
36 password: Option<String>,
37 },
38}
39
40impl ConfigSyncEndpoint {
41 pub fn url(url: impl Into<String>) -> Self {
43 Self::Url(url.into())
44 }
45
46 pub fn credentials(
48 base_url: impl Into<String>,
49 username: Option<String>,
50 password: Option<String>,
51 ) -> Self {
52 Self::Credentials {
53 base_url: base_url.into(),
54 username,
55 password,
56 }
57 }
58}
59
60impl Default for ConfigSyncEndpoint {
61 fn default() -> Self {
62 Self::Url(String::new())
63 }
64}
65
66impl From<String> for ConfigSyncEndpoint {
67 fn from(url: String) -> Self {
68 Self::Url(url)
69 }
70}
71
72impl From<&str> for ConfigSyncEndpoint {
73 fn from(url: &str) -> Self {
74 Self::Url(url.to_string())
75 }
76}
77
78impl std::fmt::Debug for ConfigSyncEndpoint {
79 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 match self {
81 Self::Url(_) => formatter.write_str("ConfigSyncEndpoint::Url(<redacted>)"),
82 Self::Credentials { .. } => {
83 formatter.write_str("ConfigSyncEndpoint::Credentials(<redacted>)")
84 }
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct ConfigSyncConfig {
97 #[serde(default = "ConfigSyncConfig::default_backend")]
99 pub backend: String,
100 #[serde(default)]
102 pub endpoint: ConfigSyncEndpoint,
103 #[serde(default = "ConfigSyncConfig::default_topic")]
106 pub topic: String,
107}
108
109impl Default for ConfigSyncConfig {
110 fn default() -> Self {
111 Self {
112 backend: Self::default_backend(),
113 endpoint: ConfigSyncEndpoint::default(),
114 topic: Self::default_topic(),
115 }
116 }
117}
118
119impl ConfigSyncConfig {
120 #[must_use]
122 pub fn default_backend() -> String {
123 CONFIG_SYNC_BACKEND_DISABLED.to_string()
124 }
125
126 #[must_use]
128 pub fn default_topic() -> String {
129 "aster.config_reload".to_string()
130 }
131
132 #[must_use]
134 pub fn enabled(&self) -> bool {
135 !matches!(
136 self.backend.trim().to_ascii_lowercase().as_str(),
137 "" | "disabled" | "none"
138 )
139 }
140}
141
142#[must_use]
144pub fn default_config_sync_topic(namespace: &str) -> String {
145 format!("{}.config_reload", namespace.trim())
146}
147
148pub fn build_config_sync_runtime(
158 config: &ConfigSyncConfig,
159 namespace: &str,
160) -> Result<ConfigSyncRuntime> {
161 build_config_sync_runtime_with_runtime_id(
162 config,
163 namespace,
164 aster_forge_utils::id::new_runtime_id(),
165 )
166}
167
168pub fn build_config_sync_runtime_with_runtime_id(
177 config: &ConfigSyncConfig,
178 namespace: &str,
179 runtime_id: impl Into<String>,
180) -> Result<ConfigSyncRuntime> {
181 let namespace = namespace.trim();
182 let runtime_id = runtime_id.into();
183 let topic = config_sync_topic(config, namespace);
184 match config.backend.trim().to_ascii_lowercase().as_str() {
185 "" | "disabled" | "none" => Ok(ConfigSyncRuntime::disabled_with_runtime_id(
186 namespace, runtime_id,
187 )),
188 CONFIG_SYNC_BACKEND_REDIS => {
189 build_redis_config_sync_runtime(config, namespace, runtime_id, &topic)
190 }
191 backend => Err(ConfigCoreError::invalid_value(format!(
192 "unsupported config_sync.backend '{backend}'"
193 ))),
194 }
195}
196fn config_sync_topic(config: &ConfigSyncConfig, namespace: &str) -> String {
197 let topic = config.topic.trim();
198 if topic.is_empty() || topic == ConfigSyncConfig::default_topic() {
199 default_config_sync_topic(namespace)
200 } else {
201 topic.to_string()
202 }
203}
204
205#[cfg(feature = "redis-pubsub")]
206fn build_redis_config_sync_runtime(
207 config: &ConfigSyncConfig,
208 namespace: &str,
209 runtime_id: String,
210 topic: &str,
211) -> Result<ConfigSyncRuntime> {
212 let channel = redis_channel_from_topic(topic);
213 let notifier = match &config.endpoint {
214 ConfigSyncEndpoint::Url(endpoint) => {
215 if endpoint.trim().is_empty() {
216 return Err(ConfigCoreError::invalid_value(
217 "config_sync.endpoint is required when config_sync.backend is redis",
218 ));
219 }
220 RedisConfigChangeNotifier::from_url(endpoint.trim(), channel)?
221 }
222 ConfigSyncEndpoint::Credentials {
223 base_url,
224 username,
225 password,
226 } => {
227 if base_url.trim().is_empty() {
228 return Err(ConfigCoreError::invalid_value(
229 "config_sync.endpoint base_url is required when config_sync.backend is redis",
230 ));
231 }
232 RedisConfigChangeNotifier::from_credentials(
233 base_url.trim(),
234 username.as_deref(),
235 password.as_deref(),
236 channel,
237 )?
238 }
239 };
240 Ok(ConfigSyncRuntime::new(
241 namespace,
242 runtime_id,
243 Arc::new(notifier) as SharedConfigChangeNotifier,
244 ))
245}
246
247#[cfg(not(feature = "redis-pubsub"))]
248fn build_redis_config_sync_runtime(
249 _config: &ConfigSyncConfig,
250 _namespace: &str,
251 _runtime_id: String,
252 _topic: &str,
253) -> Result<ConfigSyncRuntime> {
254 Err(ConfigCoreError::invalid_value(
255 "config_sync.backend 'redis' requires the redis-pubsub feature",
256 ))
257}
258
259#[cfg(any(feature = "redis-pubsub", test))]
260pub(super) fn redis_channel_from_topic(topic: &str) -> String {
261 topic.trim().replace('.', ":")
262}