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}