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}