Skip to main content

dynamic_config/
schema.rs

1//! A JSON Schema for the files this program reads.
2//!
3//! `schemars` describes a *struct*. What an editor needs is a description of the
4//! *file*, and the two are not the same thing here: a config file is a map of
5//! sections, and a struct is one section. This module is the difference —
6//! wrapping a struct's schema under its section key, marking the fields
7//! `#[config(secret)]` covers, and merging several structs into the one file
8//! they share.
9//!
10//! Opt in with the `schema` argument, which is what emits `schema()`. Like
11//! `save`, it is a separate argument rather than something every type gets,
12//! because the method needs a trait — `JsonSchema` — that the user has to
13//! derive.
14//!
15//! ```
16//! # #[cfg(feature = "schema")] {
17//! use schemars::JsonSchema;
18//! use serde::Deserialize;
19//!
20//! #[dynamic_config::dynamic_config(files = ["config.json"], key = "db", schema)]
21//! #[derive(Deserialize, JsonSchema)]
22//! struct DbConfig {
23//!     host: String,
24//!     #[config(secret)]
25//!     password: String,
26//! }
27//!
28//! let schema = DbConfig::schema();
29//!
30//! // A file-level schema: `{"db": {...}}`, not `{...}`.
31//! assert!(schema["properties"]["db"].is_object());
32//! // And the secret is marked as one.
33//! assert_eq!(
34//!     schema["properties"]["db"]["properties"]["password"]["writeOnly"],
35//!     true
36//! );
37//! # }
38//! ```
39//!
40//! # Wiring it up
41//!
42//! | Format | How the editor finds it |
43//! |---|---|
44//! | JSON | `"$schema": "./config.schema.json"` as a top-level key in the file |
45//! | YAML | `# yaml-language-server: $schema=./config.schema.json` as the first line |
46//! | TOML | `#:schema ./config.schema.json` as the first line |
47//!
48//! The JSON case is the reason `$schema` is the one top-level key this crate
49//! does not treat as a section. The other two are comments, and were never a
50//! problem.
51//!
52//! # What is deliberately *not* in the schema
53//!
54//! **Nothing is `required`, at any depth.** schemars marks every field that is
55//! neither `Option` nor `#[serde(default)]` as required, which is right for a
56//! struct and wrong for a config file: the environment, a flag, an override or a
57//! computed default can all supply a value, and none of them are visible to an
58//! editor. Left in place it would light up every 12-factor deployment's config
59//! file in red for values that are perfectly well supplied. `check()` answers
60//! the question a schema cannot — *does this actually resolve* — with every
61//! layer in view.
62//!
63//! **The top level accepts unknown keys.** Other sections belong to other
64//! structs, and one struct's schema has no business calling them a mistake. Use
65//! [`merge`] to describe the whole file, and unknown-key detection —
66//! `check()` — for the part a schema cannot see.
67
68use serde_json::{Map, Value};
69
70/// The draft every emitted schema declares.
71const DRAFT: &str = "https://json-schema.org/draft/2020-12/schema";
72
73/// Wraps one struct's schema as the section it occupies in a file.
74///
75/// `secrets` are the field names to mark `writeOnly`, which is JSON Schema's
76/// way of saying *this value is not for reading back* — editors stop echoing
77/// them in completions and hovers.
78///
79/// Called by the generated `schema()`; public because the macro-free API is
80/// public too.
81#[must_use]
82pub fn section(key: &str, schema: Value, secrets: &[&str]) -> Value {
83    let mut schema = schema;
84
85    drop_required(&mut schema);
86    mark_secrets(&mut schema, secrets);
87
88    let mut properties = Map::new();
89    properties.insert(key.to_owned(), schema);
90
91    let mut file = Map::new();
92    file.insert("$schema".to_owned(), Value::from(DRAFT));
93    file.insert("type".to_owned(), Value::from("object"));
94    file.insert("properties".to_owned(), Value::Object(properties));
95
96    Value::Object(file)
97}
98
99/// Combines several section schemas into one schema for the file they share.
100///
101/// The usual shape once a program has more than one config type over one file:
102///
103/// ```
104/// # #[cfg(feature = "schema")] {
105/// # use serde::Deserialize;
106/// # use schemars::JsonSchema;
107/// # #[dynamic_config::dynamic_config(files = ["app.json"], key = "db", schema)]
108/// # #[derive(Deserialize, JsonSchema)] struct DbConfig { host: String }
109/// # #[dynamic_config::dynamic_config(files = ["app.json"], key = "server", schema)]
110/// # #[derive(Deserialize, JsonSchema)] struct ServerConfig { port: u16 }
111/// let whole_file = dynamic_config::schema::merge([
112///     DbConfig::schema(),
113///     ServerConfig::schema(),
114/// ]);
115///
116/// assert!(whole_file["properties"]["db"].is_object());
117/// assert!(whole_file["properties"]["server"].is_object());
118/// # }
119/// ```
120///
121/// Later sections win over earlier ones on a key collision, which cannot happen
122/// unless two config types claim the same section — and if they do, the schema
123/// is not where that will hurt.
124#[must_use]
125pub fn merge(schemas: impl IntoIterator<Item = Value>) -> Value {
126    let mut properties = Map::new();
127
128    for schema in schemas {
129        let Value::Object(mut schema) = schema else {
130            continue;
131        };
132
133        if let Some(Value::Object(section)) = schema.remove("properties") {
134            properties.extend(section);
135        }
136    }
137
138    let mut file = Map::new();
139    file.insert("$schema".to_owned(), Value::from(DRAFT));
140    file.insert("type".to_owned(), Value::from("object"));
141    file.insert("properties".to_owned(), Value::Object(properties));
142
143    Value::Object(file)
144}
145
146/// Removes every `required` list, however deep.
147///
148/// Depth matters: a nested table the file leaves out entirely can still be
149/// supplied by `APP_DB_POOL__MAX`, so the argument that makes `required` wrong
150/// at the top makes it wrong all the way down.
151fn drop_required(schema: &mut Value) {
152    match schema {
153        Value::Object(fields) => {
154            fields.remove("required");
155
156            for value in fields.values_mut() {
157                drop_required(value);
158            }
159        }
160        // `anyOf`, `oneOf`, `prefixItems`: schemars uses arrays of schemas for
161        // enums and tuples, and a required list inside one is just as wrong.
162        Value::Array(items) => {
163            for item in items {
164                drop_required(item);
165            }
166        }
167        _ => {}
168    }
169}
170
171/// Sets `writeOnly` on the named properties.
172///
173/// Only the top level of the section: `#[config(secret)]` marks a field, and a
174/// field is a top-level property of its own struct. A secret nested inside
175/// another struct is that struct's business, and marking it here would mean
176/// guessing which of several nested types the name belonged to.
177fn mark_secrets(schema: &mut Value, secrets: &[&str]) {
178    if secrets.is_empty() {
179        return;
180    }
181
182    let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) else {
183        return;
184    };
185
186    for secret in secrets {
187        // A rename makes the schema's property name differ from the field name,
188        // and the attribute only ever saw the field name. Silently doing
189        // nothing is the honest outcome: the alternative is marking whichever
190        // property happens to sort nearby.
191        if let Some(Value::Object(property)) = properties.get_mut(*secret) {
192            property.insert("writeOnly".to_owned(), Value::Bool(true));
193        }
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    fn struct_schema() -> Value {
202        serde_json::json!({
203            "type": "object",
204            "properties": {
205                "host": { "type": "string" },
206                "password": { "type": "string" },
207            }
208        })
209    }
210
211    #[test]
212    fn a_struct_becomes_a_section_of_a_file() {
213        let schema = section("db", struct_schema(), &[]);
214
215        assert_eq!(schema["$schema"], DRAFT);
216        assert_eq!(schema["type"], "object");
217        assert_eq!(
218            schema["properties"]["db"]["properties"]["host"]["type"],
219            "string"
220        );
221    }
222
223    #[test]
224    fn secrets_are_marked_write_only() {
225        let schema = section("db", struct_schema(), &["password"]);
226        let section = &schema["properties"]["db"];
227
228        assert_eq!(section["properties"]["password"]["writeOnly"], true);
229        assert!(
230            section["properties"]["host"].get("writeOnly").is_none(),
231            "only the marked fields"
232        );
233    }
234
235    #[test]
236    fn a_secret_that_was_renamed_is_left_alone_rather_than_guessed_at() {
237        let schema = section("db", struct_schema(), &["not_a_property"]);
238
239        assert_eq!(
240            schema["properties"]["db"]["properties"],
241            struct_schema()["properties"],
242            "a name the schema does not have must change nothing"
243        );
244    }
245
246    #[test]
247    fn nothing_is_required_at_any_depth() {
248        let with_required = serde_json::json!({
249            "type": "object",
250            "required": ["host"],
251            "properties": {
252                "host": { "type": "string" },
253                "pool": {
254                    "type": "object",
255                    "required": ["max_size"],
256                    "properties": { "max_size": { "type": "integer" } }
257                }
258            }
259        });
260
261        let schema = section("db", with_required, &[]);
262        let section = &schema["properties"]["db"];
263
264        assert!(
265            section.get("required").is_none(),
266            "the environment can supply anything the file does not"
267        );
268        assert!(
269            section["properties"]["pool"].get("required").is_none(),
270            "and it can supply a nested table the file leaves out entirely"
271        );
272    }
273
274    #[test]
275    fn a_required_list_inside_a_variant_goes_too() {
276        let tagged = serde_json::json!({
277            "anyOf": [
278                { "type": "object", "required": ["kind"], "properties": {} },
279                { "type": "null" }
280            ]
281        });
282
283        let schema = section("db", tagged, &[]);
284
285        assert!(
286            schema["properties"]["db"]["anyOf"][0]
287                .get("required")
288                .is_none(),
289            "schemars uses arrays of schemas for enums, and the rule is the same there"
290        );
291    }
292
293    #[test]
294    fn several_sections_become_one_file() {
295        let merged = merge([
296            section("db", struct_schema(), &["password"]),
297            section("server", struct_schema(), &[]),
298        ]);
299
300        assert_eq!(merged["$schema"], DRAFT);
301        assert!(merged["properties"]["db"].is_object());
302        assert!(merged["properties"]["server"].is_object());
303        assert_eq!(
304            merged["properties"]["db"]["properties"]["password"]["writeOnly"], true,
305            "merging must not undo the marking"
306        );
307    }
308
309    #[test]
310    fn merging_nothing_is_an_empty_file_schema() {
311        let merged = merge([]);
312
313        assert_eq!(merged["type"], "object");
314        assert_eq!(merged["properties"], serde_json::json!({}));
315    }
316}