Skip to main content

platform_core/runtime_config/
snapshot.rs

1use 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/// Where an effective value came from, for display in the console.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
10#[serde(rename_all = "snake_case")]
11pub enum RuntimeConfigSource {
12    /// A stored row scoped to this service.
13    Override,
14    /// A stored row scoped to `*` (shared).
15    Shared,
16    /// No stored row; the descriptor default.
17    Default,
18}
19
20/// Effective configuration for a single running service: every registered key
21/// resolved to a concrete value plus the source it came from.
22#[derive(Debug, Clone, Default)]
23pub struct RuntimeConfigSnapshot {
24    /// key -> (value, source)
25    values: BTreeMap<String, (Value, RuntimeConfigSource)>,
26}
27
28impl RuntimeConfigSnapshot {
29    /// Resolve every descriptor for `service_key` against the stored rows.
30    ///
31    /// `stored` maps `(service_key, key)` to the stored JSON value. Resolution
32    /// order per key: a row for this service, else a `*` row, else the default.
33    /// Stored values failing validation fall back to the default.
34    #[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            // Only descriptors applicable to this service or shared.
43            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    /// The raw effective value for a key, if registered.
67    #[must_use]
68    pub fn raw(&self, key: &str) -> Option<&Value> {
69        self.values.get(key).map(|(value, _)| value)
70    }
71
72    /// The source of a key's effective value, if registered.
73    #[must_use]
74    pub fn source(&self, key: &str) -> Option<RuntimeConfigSource> {
75        self.values.get(key).map(|(_, source)| *source)
76    }
77
78    /// Deserialize a single key into a typed value.
79    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    /// Build a typed struct whose fields are keys sharing `prefix` + `.`.
93    ///
94    /// Example: prefix `"identity"` with key `"identity.password_reset_ttl_minutes"`
95    /// produces an object field `password_reset_ttl_minutes`.
96    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    /// All resolved keys with their value and source, for the console values API.
114    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    /// Override specific keys' resolved entries. Used to carry forward values
119    /// that must not change after startup (e.g. restart-only config values).
120    #[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(&registry(), "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(&registry(), "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(&registry(), "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(&registry(), "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(&registry(), "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(&registry(), "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(&registry(), "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        // A key not applicable to this resolution must be ignored.
270        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}