platform_core/runtime_config/
snapshot.rs1use crate::error::{AppError, AppResult, ErrorCode};
2use crate::runtime_config::descriptor::RuntimeConfigRegistry;
3use serde::Serialize;
4use serde::de::DeserializeOwned;
5use serde_json::{Map, Value};
6use std::collections::BTreeMap;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
10#[serde(rename_all = "snake_case")]
11pub enum RuntimeConfigSource {
12 Override,
14 Shared,
16 Default,
18}
19
20#[derive(Debug, Clone, Default)]
23pub struct RuntimeConfigSnapshot {
24 values: BTreeMap<String, (Value, RuntimeConfigSource)>,
26}
27
28impl RuntimeConfigSnapshot {
29 #[must_use]
35 pub fn resolve(
36 registry: &RuntimeConfigRegistry,
37 service_key: &str,
38 stored: &BTreeMap<(String, String), Value>,
39 ) -> Self {
40 let mut values = BTreeMap::new();
41 for descriptor in registry.iter() {
42 let applies = descriptor.scope.as_service_key() == service_key
44 || descriptor.scope.as_service_key() == "*";
45 if !applies {
46 continue;
47 }
48 let key = descriptor.key.to_owned();
49 let service_row = stored.get(&(service_key.to_owned(), key.clone()));
50 let shared_row = stored.get(&("*".to_owned(), key.clone()));
51
52 let (value, source) = match (service_row, shared_row) {
53 (Some(v), _) if descriptor.validate(v).is_ok() => {
54 (v.clone(), RuntimeConfigSource::Override)
55 }
56 (_, Some(v)) if descriptor.validate(v).is_ok() => {
57 (v.clone(), RuntimeConfigSource::Shared)
58 }
59 _ => (descriptor.default.clone(), RuntimeConfigSource::Default),
60 };
61 values.insert(key, (value, source));
62 }
63 Self { values }
64 }
65
66 #[must_use]
68 pub fn raw(&self, key: &str) -> Option<&Value> {
69 self.values.get(key).map(|(value, _)| value)
70 }
71
72 #[must_use]
74 pub fn source(&self, key: &str) -> Option<RuntimeConfigSource> {
75 self.values.get(key).map(|(_, source)| *source)
76 }
77
78 pub fn get_value<T: DeserializeOwned>(&self, key: &str) -> AppResult<T> {
80 let value = self.raw(key).ok_or_else(|| {
81 AppError::new(ErrorCode::Internal, format!("unknown setting key `{key}`"))
82 })?;
83 serde_json::from_value(value.clone()).map_err(|source| {
84 AppError::new(
85 ErrorCode::Internal,
86 format!("setting `{key}` deserialize failed"),
87 )
88 .with_source(source)
89 })
90 }
91
92 pub fn get<T: DeserializeOwned>(&self, prefix: &str) -> AppResult<T> {
97 let dotted = format!("{prefix}.");
98 let mut object = Map::new();
99 for (key, (value, _)) in &self.values {
100 if let Some(field) = key.strip_prefix(&dotted) {
101 object.insert(field.to_owned(), value.clone());
102 }
103 }
104 serde_json::from_value(Value::Object(object)).map_err(|source| {
105 AppError::new(
106 ErrorCode::Internal,
107 format!("settings `{prefix}` deserialize failed"),
108 )
109 .with_source(source)
110 })
111 }
112
113 pub fn entries(&self) -> impl Iterator<Item = (&str, &Value, RuntimeConfigSource)> {
115 self.values.iter().map(|(k, (v, s))| (k.as_str(), v, *s))
116 }
117
118 #[must_use]
121 pub fn with_overrides(
122 mut self,
123 overrides: &std::collections::BTreeMap<String, (Value, RuntimeConfigSource)>,
124 ) -> Self {
125 for (key, entry) in overrides {
126 if self.values.contains_key(key) {
127 self.values.insert(key.clone(), entry.clone());
128 }
129 }
130 self
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137 use crate::runtime_config::descriptor::{
138 RuntimeConfigDescriptor, RuntimeConfigScope, RuntimeConfigType,
139 };
140 use serde::Deserialize;
141 use serde_json::json;
142
143 fn registry() -> RuntimeConfigRegistry {
144 RuntimeConfigRegistry::try_new(vec![
145 RuntimeConfigDescriptor {
146 key: "identity.password_reset_ttl_minutes".to_owned(),
147 scope: RuntimeConfigScope::Shared,
148 group: None,
149 section: None,
150 order: 0,
151 visible_when: None,
152 generated: None,
153 value_type: RuntimeConfigType::Int {
154 min: Some(1),
155 max: Some(1440),
156 },
157 default: json!(30),
158 editable: true,
159 restart_only: false,
160 description: "ttl",
161 },
162 RuntimeConfigDescriptor {
163 key: "api.feature.enabled".to_owned(),
164 scope: RuntimeConfigScope::Service("api"),
165 group: None,
166 section: None,
167 order: 0,
168 visible_when: None,
169 generated: None,
170 value_type: RuntimeConfigType::Bool,
171 default: json!(false),
172 editable: true,
173 restart_only: false,
174 description: "flag",
175 },
176 ])
177 .unwrap()
178 }
179
180 #[test]
181 fn falls_back_to_default() {
182 let snapshot = RuntimeConfigSnapshot::resolve(®istry(), "api", &BTreeMap::new());
183 assert_eq!(
184 snapshot.raw("identity.password_reset_ttl_minutes"),
185 Some(&json!(30))
186 );
187 assert_eq!(
188 snapshot.source("api.feature.enabled"),
189 Some(RuntimeConfigSource::Default)
190 );
191 }
192
193 #[test]
194 fn service_row_overrides_shared_and_default() {
195 let mut stored = BTreeMap::new();
196 stored.insert(
197 ("api".to_owned(), "api.feature.enabled".to_owned()),
198 json!(true),
199 );
200 let snapshot = RuntimeConfigSnapshot::resolve(®istry(), "api", &stored);
201 assert_eq!(snapshot.raw("api.feature.enabled"), Some(&json!(true)));
202 assert_eq!(
203 snapshot.source("api.feature.enabled"),
204 Some(RuntimeConfigSource::Override)
205 );
206 }
207
208 #[test]
209 fn invalid_stored_value_falls_back_to_default() {
210 let mut stored = BTreeMap::new();
211 stored.insert(
212 (
213 "*".to_owned(),
214 "identity.password_reset_ttl_minutes".to_owned(),
215 ),
216 json!(99999),
217 );
218 let snapshot = RuntimeConfigSnapshot::resolve(®istry(), "api", &stored);
219 assert_eq!(
220 snapshot.raw("identity.password_reset_ttl_minutes"),
221 Some(&json!(30))
222 );
223 assert_eq!(
224 snapshot.source("identity.password_reset_ttl_minutes"),
225 Some(RuntimeConfigSource::Default)
226 );
227 }
228
229 #[test]
230 fn typed_struct_get_by_prefix() {
231 #[derive(Debug, Deserialize, PartialEq)]
232 struct IdentityConfig {
233 password_reset_ttl_minutes: u64,
234 }
235 let snapshot = RuntimeConfigSnapshot::resolve(®istry(), "api", &BTreeMap::new());
236 let cfg: IdentityConfig = snapshot.get("identity").unwrap();
237 assert_eq!(
238 cfg,
239 IdentityConfig {
240 password_reset_ttl_minutes: 30
241 }
242 );
243 }
244
245 #[test]
246 fn service_scoped_key_excluded_for_other_service() {
247 let snapshot = RuntimeConfigSnapshot::resolve(®istry(), "worker", &BTreeMap::new());
248 assert!(snapshot.raw("api.feature.enabled").is_none());
249 }
250
251 #[test]
252 fn get_value_reads_single_key_and_errors_on_unknown() {
253 let snapshot = RuntimeConfigSnapshot::resolve(®istry(), "api", &BTreeMap::new());
254 let ttl: u64 = snapshot
255 .get_value("identity.password_reset_ttl_minutes")
256 .unwrap();
257 assert_eq!(ttl, 30);
258 assert!(snapshot.get_value::<u64>("does.not.exist").is_err());
259 }
260
261 #[test]
262 fn with_overrides_replaces_present_keys_only() {
263 let snapshot = RuntimeConfigSnapshot::resolve(®istry(), "api", &BTreeMap::new());
264 let mut overrides = BTreeMap::new();
265 overrides.insert(
266 "identity.password_reset_ttl_minutes".to_owned(),
267 (json!(99), RuntimeConfigSource::Override),
268 );
269 overrides.insert(
271 "not.present".to_owned(),
272 (json!(1), RuntimeConfigSource::Override),
273 );
274 let result = snapshot.with_overrides(&overrides);
275 assert_eq!(
276 result.raw("identity.password_reset_ttl_minutes"),
277 Some(&json!(99))
278 );
279 assert_eq!(
280 result.source("identity.password_reset_ttl_minutes"),
281 Some(RuntimeConfigSource::Override)
282 );
283 assert!(result.raw("not.present").is_none());
284 }
285}