Skip to main content

dynamic_config_server/
document.rs

1//! The served document: one resolved section, as data rather than a struct.
2//!
3//! A configuration server does not know its callers' types — that is the
4//! whole point of it — so the served shape is schemaless. Everything else in
5//! this workspace resolves a section *into* a struct; here the section is
6//! resolved into JSON and handed over, which is what makes one server able
7//! to serve a Rust service, a Python service and a shell script.
8//!
9//! JSON rather than [`dynamic_config::Value`] because the wire format is
10//! JSON and the conversion has to happen exactly once. `Value` is the
11//! library's owned mirror for boundaries that are *not* serde; this boundary
12//! is serde, and routing through a second tree would only add a place for
13//! the two renderings to disagree.
14
15use std::fmt;
16
17use serde::{Deserialize, Serialize};
18
19/// A resolved configuration section.
20///
21/// # `Debug` prints shape, never values
22///
23/// Hand-written for the reason `Snapshot` and `Value` are: this type holds
24/// the resolved configuration, passwords included, and `{:?}` in a log line
25/// is exactly how a resolved secret escapes. The values leave this process
26/// through one door — the document endpoint — and a `Debug` that rendered
27/// them would quietly open a second.
28#[derive(Clone, Deserialize, Serialize)]
29#[serde(transparent)]
30pub struct Document(serde_json::Value);
31
32impl Document {
33    /// The document as JSON, for the one endpoint that serves values.
34    #[must_use]
35    pub fn as_json(&self) -> &serde_json::Value {
36        &self.0
37    }
38
39    /// The dotted path of every leaf, in order.
40    ///
41    /// The same walk [`Snapshot::leaf_paths`](dynamic_config::Snapshot::leaf_paths)
42    /// performs, and for the same purpose: a caller that wants to know
43    /// *which keys exist* without being handed what is in them. An array is
44    /// a leaf — its elements are values, not configuration keys — and so is
45    /// an empty table, which would otherwise vanish from the listing
46    /// entirely.
47    #[must_use]
48    pub fn leaf_paths(&self) -> Vec<String> {
49        let mut paths = Vec::new();
50
51        // The root is walked here rather than in `collect` so that the
52        // recursion never has to ask "am I at the top?" — which is what a
53        // configuration key that is the empty string would make it get
54        // wrong, and TOML permits one.
55        if let serde_json::Value::Object(table) = &self.0 {
56            let mut prefix = String::new();
57
58            for (key, value) in table {
59                prefix.clear();
60                prefix.push_str(key);
61
62                collect(value, &mut prefix, &mut paths);
63            }
64        }
65
66        paths
67    }
68}
69
70fn collect(value: &serde_json::Value, prefix: &mut String, paths: &mut Vec<String>) {
71    match value {
72        serde_json::Value::Object(table) if !table.is_empty() => {
73            for (key, nested) in table {
74                let restore = prefix.len();
75
76                prefix.push('.');
77                prefix.push_str(key);
78
79                collect(nested, prefix, paths);
80
81                prefix.truncate(restore);
82            }
83        }
84        _ => paths.push(prefix.clone()),
85    }
86}
87
88impl fmt::Debug for Document {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        f.debug_struct("Document")
91            .field("leaves", &self.leaf_paths().len())
92            .finish_non_exhaustive()
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    fn document(json: serde_json::Value) -> Document {
101        serde_json::from_value(json).expect("any JSON is a document")
102    }
103
104    #[test]
105    fn leaf_paths_reach_into_nested_tables_and_stop_at_arrays() {
106        let document = document(serde_json::json!({
107            "host": "db",
108            "pool": { "max": 8, "min": 1 },
109            "tags": ["a", "b"],
110            "empty": {},
111        }));
112
113        assert_eq!(
114            document.leaf_paths(),
115            ["empty", "host", "pool.max", "pool.min", "tags"]
116        );
117    }
118
119    /// TOML and JSON both permit `"" = 1`, and the walk must not lose it or
120    /// render it as the path of its parent.
121    #[test]
122    fn a_key_that_is_the_empty_string_still_has_a_path() {
123        let document = document(serde_json::json!({ "": 1, "pool": { "": 2 } }));
124
125        assert_eq!(document.leaf_paths(), ["", "pool."]);
126    }
127
128    #[test]
129    fn a_document_that_is_not_a_table_has_no_paths() {
130        assert!(document(serde_json::json!(7)).leaf_paths().is_empty());
131        assert!(document(serde_json::json!({})).leaf_paths().is_empty());
132    }
133
134    /// The rule the rest of the workspace keeps, kept here: a `{:?}` of a
135    /// resolved configuration must not be the leak everything else prevents.
136    #[test]
137    fn debug_prints_shape_and_never_a_value() {
138        let document = document(serde_json::json!({ "password": "hunter2" }));
139
140        let rendered = format!("{document:?}");
141
142        assert!(!rendered.contains("hunter2"), "{rendered}");
143        assert!(!rendered.contains("password"), "{rendered}");
144    }
145}