use crate::error::{AppError, AppResult, ErrorCode};
use crate::runtime_config::descriptor::RuntimeConfigRegistry;
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeConfigSource {
Override,
Shared,
Default,
}
#[derive(Debug, Clone, Default)]
pub struct RuntimeConfigSnapshot {
values: BTreeMap<String, (Value, RuntimeConfigSource)>,
}
impl RuntimeConfigSnapshot {
#[must_use]
pub fn resolve(
registry: &RuntimeConfigRegistry,
service_key: &str,
stored: &BTreeMap<(String, String), Value>,
) -> Self {
let mut values = BTreeMap::new();
for descriptor in registry.iter() {
let applies = descriptor.scope.as_service_key() == service_key
|| descriptor.scope.as_service_key() == "*";
if !applies {
continue;
}
let key = descriptor.key.to_owned();
let service_row = stored.get(&(service_key.to_owned(), key.clone()));
let shared_row = stored.get(&("*".to_owned(), key.clone()));
let (value, source) = match (service_row, shared_row) {
(Some(v), _) if descriptor.validate(v).is_ok() => {
(v.clone(), RuntimeConfigSource::Override)
}
(_, Some(v)) if descriptor.validate(v).is_ok() => {
(v.clone(), RuntimeConfigSource::Shared)
}
_ => (descriptor.default.clone(), RuntimeConfigSource::Default),
};
values.insert(key, (value, source));
}
Self { values }
}
#[must_use]
pub fn raw(&self, key: &str) -> Option<&Value> {
self.values.get(key).map(|(value, _)| value)
}
#[must_use]
pub fn source(&self, key: &str) -> Option<RuntimeConfigSource> {
self.values.get(key).map(|(_, source)| *source)
}
pub fn get_value<T: DeserializeOwned>(&self, key: &str) -> AppResult<T> {
let value = self.raw(key).ok_or_else(|| {
AppError::new(ErrorCode::Internal, format!("unknown setting key `{key}`"))
})?;
serde_json::from_value(value.clone()).map_err(|source| {
AppError::new(
ErrorCode::Internal,
format!("setting `{key}` deserialize failed"),
)
.with_source(source)
})
}
pub fn get<T: DeserializeOwned>(&self, prefix: &str) -> AppResult<T> {
let dotted = format!("{prefix}.");
let mut object = Map::new();
for (key, (value, _)) in &self.values {
if let Some(field) = key.strip_prefix(&dotted) {
object.insert(field.to_owned(), value.clone());
}
}
serde_json::from_value(Value::Object(object)).map_err(|source| {
AppError::new(
ErrorCode::Internal,
format!("settings `{prefix}` deserialize failed"),
)
.with_source(source)
})
}
pub fn entries(&self) -> impl Iterator<Item = (&str, &Value, RuntimeConfigSource)> {
self.values.iter().map(|(k, (v, s))| (k.as_str(), v, *s))
}
#[must_use]
pub fn with_overrides(
mut self,
overrides: &std::collections::BTreeMap<String, (Value, RuntimeConfigSource)>,
) -> Self {
for (key, entry) in overrides {
if self.values.contains_key(key) {
self.values.insert(key.clone(), entry.clone());
}
}
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime_config::descriptor::{
RuntimeConfigDescriptor, RuntimeConfigScope, RuntimeConfigType,
};
use serde::Deserialize;
use serde_json::json;
fn registry() -> RuntimeConfigRegistry {
RuntimeConfigRegistry::try_new(vec![
RuntimeConfigDescriptor {
key: "identity.password_reset_ttl_minutes".to_owned(),
scope: RuntimeConfigScope::Shared,
group: None,
section: None,
order: 0,
visible_when: None,
generated: None,
value_type: RuntimeConfigType::Int {
min: Some(1),
max: Some(1440),
},
default: json!(30),
editable: true,
restart_only: false,
description: "ttl",
},
RuntimeConfigDescriptor {
key: "api.feature.enabled".to_owned(),
scope: RuntimeConfigScope::Service("api"),
group: None,
section: None,
order: 0,
visible_when: None,
generated: None,
value_type: RuntimeConfigType::Bool,
default: json!(false),
editable: true,
restart_only: false,
description: "flag",
},
])
.unwrap()
}
#[test]
fn falls_back_to_default() {
let snapshot = RuntimeConfigSnapshot::resolve(®istry(), "api", &BTreeMap::new());
assert_eq!(
snapshot.raw("identity.password_reset_ttl_minutes"),
Some(&json!(30))
);
assert_eq!(
snapshot.source("api.feature.enabled"),
Some(RuntimeConfigSource::Default)
);
}
#[test]
fn service_row_overrides_shared_and_default() {
let mut stored = BTreeMap::new();
stored.insert(
("api".to_owned(), "api.feature.enabled".to_owned()),
json!(true),
);
let snapshot = RuntimeConfigSnapshot::resolve(®istry(), "api", &stored);
assert_eq!(snapshot.raw("api.feature.enabled"), Some(&json!(true)));
assert_eq!(
snapshot.source("api.feature.enabled"),
Some(RuntimeConfigSource::Override)
);
}
#[test]
fn invalid_stored_value_falls_back_to_default() {
let mut stored = BTreeMap::new();
stored.insert(
(
"*".to_owned(),
"identity.password_reset_ttl_minutes".to_owned(),
),
json!(99999),
);
let snapshot = RuntimeConfigSnapshot::resolve(®istry(), "api", &stored);
assert_eq!(
snapshot.raw("identity.password_reset_ttl_minutes"),
Some(&json!(30))
);
assert_eq!(
snapshot.source("identity.password_reset_ttl_minutes"),
Some(RuntimeConfigSource::Default)
);
}
#[test]
fn typed_struct_get_by_prefix() {
#[derive(Debug, Deserialize, PartialEq)]
struct IdentityConfig {
password_reset_ttl_minutes: u64,
}
let snapshot = RuntimeConfigSnapshot::resolve(®istry(), "api", &BTreeMap::new());
let cfg: IdentityConfig = snapshot.get("identity").unwrap();
assert_eq!(
cfg,
IdentityConfig {
password_reset_ttl_minutes: 30
}
);
}
#[test]
fn service_scoped_key_excluded_for_other_service() {
let snapshot = RuntimeConfigSnapshot::resolve(®istry(), "worker", &BTreeMap::new());
assert!(snapshot.raw("api.feature.enabled").is_none());
}
#[test]
fn get_value_reads_single_key_and_errors_on_unknown() {
let snapshot = RuntimeConfigSnapshot::resolve(®istry(), "api", &BTreeMap::new());
let ttl: u64 = snapshot
.get_value("identity.password_reset_ttl_minutes")
.unwrap();
assert_eq!(ttl, 30);
assert!(snapshot.get_value::<u64>("does.not.exist").is_err());
}
#[test]
fn with_overrides_replaces_present_keys_only() {
let snapshot = RuntimeConfigSnapshot::resolve(®istry(), "api", &BTreeMap::new());
let mut overrides = BTreeMap::new();
overrides.insert(
"identity.password_reset_ttl_minutes".to_owned(),
(json!(99), RuntimeConfigSource::Override),
);
overrides.insert(
"not.present".to_owned(),
(json!(1), RuntimeConfigSource::Override),
);
let result = snapshot.with_overrides(&overrides);
assert_eq!(
result.raw("identity.password_reset_ttl_minutes"),
Some(&json!(99))
);
assert_eq!(
result.source("identity.password_reset_ttl_minutes"),
Some(RuntimeConfigSource::Override)
);
assert!(result.raw("not.present").is_none());
}
}