use serde_json::{Map, Value};
const DRAFT: &str = "https://json-schema.org/draft/2020-12/schema";
#[must_use]
pub fn section(key: &str, schema: Value, secrets: &[&str]) -> Value {
let mut schema = schema;
drop_required(&mut schema);
mark_secrets(&mut schema, secrets);
let mut properties = Map::new();
properties.insert(key.to_owned(), schema);
let mut file = Map::new();
file.insert("$schema".to_owned(), Value::from(DRAFT));
file.insert("type".to_owned(), Value::from("object"));
file.insert("properties".to_owned(), Value::Object(properties));
Value::Object(file)
}
#[must_use]
pub fn merge(schemas: impl IntoIterator<Item = Value>) -> Value {
let mut properties = Map::new();
for schema in schemas {
let Value::Object(mut schema) = schema else {
continue;
};
if let Some(Value::Object(section)) = schema.remove("properties") {
properties.extend(section);
}
}
let mut file = Map::new();
file.insert("$schema".to_owned(), Value::from(DRAFT));
file.insert("type".to_owned(), Value::from("object"));
file.insert("properties".to_owned(), Value::Object(properties));
Value::Object(file)
}
fn drop_required(schema: &mut Value) {
match schema {
Value::Object(fields) => {
fields.remove("required");
for value in fields.values_mut() {
drop_required(value);
}
}
Value::Array(items) => {
for item in items {
drop_required(item);
}
}
_ => {}
}
}
fn mark_secrets(schema: &mut Value, secrets: &[&str]) {
if secrets.is_empty() {
return;
}
let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) else {
return;
};
for secret in secrets {
if let Some(Value::Object(property)) = properties.get_mut(*secret) {
property.insert("writeOnly".to_owned(), Value::Bool(true));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn struct_schema() -> Value {
serde_json::json!({
"type": "object",
"properties": {
"host": { "type": "string" },
"password": { "type": "string" },
}
})
}
#[test]
fn a_struct_becomes_a_section_of_a_file() {
let schema = section("db", struct_schema(), &[]);
assert_eq!(schema["$schema"], DRAFT);
assert_eq!(schema["type"], "object");
assert_eq!(
schema["properties"]["db"]["properties"]["host"]["type"],
"string"
);
}
#[test]
fn secrets_are_marked_write_only() {
let schema = section("db", struct_schema(), &["password"]);
let section = &schema["properties"]["db"];
assert_eq!(section["properties"]["password"]["writeOnly"], true);
assert!(
section["properties"]["host"].get("writeOnly").is_none(),
"only the marked fields"
);
}
#[test]
fn a_secret_that_was_renamed_is_left_alone_rather_than_guessed_at() {
let schema = section("db", struct_schema(), &["not_a_property"]);
assert_eq!(
schema["properties"]["db"]["properties"],
struct_schema()["properties"],
"a name the schema does not have must change nothing"
);
}
#[test]
fn nothing_is_required_at_any_depth() {
let with_required = serde_json::json!({
"type": "object",
"required": ["host"],
"properties": {
"host": { "type": "string" },
"pool": {
"type": "object",
"required": ["max_size"],
"properties": { "max_size": { "type": "integer" } }
}
}
});
let schema = section("db", with_required, &[]);
let section = &schema["properties"]["db"];
assert!(
section.get("required").is_none(),
"the environment can supply anything the file does not"
);
assert!(
section["properties"]["pool"].get("required").is_none(),
"and it can supply a nested table the file leaves out entirely"
);
}
#[test]
fn a_required_list_inside_a_variant_goes_too() {
let tagged = serde_json::json!({
"anyOf": [
{ "type": "object", "required": ["kind"], "properties": {} },
{ "type": "null" }
]
});
let schema = section("db", tagged, &[]);
assert!(
schema["properties"]["db"]["anyOf"][0]
.get("required")
.is_none(),
"schemars uses arrays of schemas for enums, and the rule is the same there"
);
}
#[test]
fn several_sections_become_one_file() {
let merged = merge([
section("db", struct_schema(), &["password"]),
section("server", struct_schema(), &[]),
]);
assert_eq!(merged["$schema"], DRAFT);
assert!(merged["properties"]["db"].is_object());
assert!(merged["properties"]["server"].is_object());
assert_eq!(
merged["properties"]["db"]["properties"]["password"]["writeOnly"], true,
"merging must not undo the marking"
);
}
#[test]
fn merging_nothing_is_an_empty_file_schema() {
let merged = merge([]);
assert_eq!(merged["type"], "object");
assert_eq!(merged["properties"], serde_json::json!({}));
}
}