use schemars::{json_schema, Schema};
use serde_json::{json, Map, Value};
pub fn augment_with_from_env(schema: &mut Schema) {
if let Some(map) = schema.as_object_mut() {
augment_field_map(map);
}
}
fn from_env_alternative(default_schema: Value) -> Value {
json_schema!({
"type": "object",
"properties": {
"from_env": {
"type": "string",
"description": "Reads the value from an environment variable. In case of a missing value, a warning is logged and the field default/validation rules are applied as usual.",
},
"default": default_schema,
},
"required": ["from_env"],
"additionalProperties": false
})
.to_value()
}
fn augment_field_value(value: &mut Value) {
if let Value::Object(map) = value {
augment_field_map(map);
}
}
fn augment_field_map(map: &mut Map<String, Value>) {
recurse_into_properties_and_items(map);
recurse_into_union_branches(map);
if !is_object_or_array_only(map) {
add_from_env_alternative(map);
}
}
fn recurse_into_properties_and_items(map: &mut Map<String, Value>) {
if let Some(Value::Object(properties)) = map.get_mut("properties") {
for value in properties.values_mut() {
augment_field_value(value);
}
}
if let Some(items) = map.get_mut("items") {
augment_field_value(items);
}
}
fn recurse_into_union_branches(map: &mut Map<String, Value>) {
for key in ["oneOf", "anyOf"] {
if let Some(Value::Array(variants)) = map.get_mut(key) {
for variant in variants.iter_mut() {
if let Value::Object(variant_map) = variant {
recurse_into_properties_and_items(variant_map);
recurse_into_union_branches(variant_map);
}
}
}
}
}
const METADATA_KEYS: [&str; 5] = ["description", "title", "default", "deprecated", "examples"];
fn add_from_env_alternative(map: &mut Map<String, Value>) {
if let Some(Value::Array(variants)) = map.get_mut("oneOf") {
let default_schema = json!({ "oneOf": Value::Array(variants.clone()) });
variants.push(from_env_alternative(default_schema));
return;
}
if let Some(Value::Array(variants)) = map.get_mut("anyOf") {
let default_schema = json!({ "anyOf": Value::Array(variants.clone()) });
variants.push(from_env_alternative(default_schema));
return;
}
let mut original = std::mem::take(map);
for key in METADATA_KEYS {
if let Some(value) = original.remove(key) {
map.insert(key.to_string(), value);
}
}
if let Some(summary) = summarize_types(original.get("type")) {
map.insert("type".to_string(), summary);
}
let default_schema = Value::Object(original.clone());
map.insert(
"oneOf".to_string(),
Value::Array(vec![
Value::Object(original),
from_env_alternative(default_schema),
]),
);
}
fn summarize_types(original_type: Option<&Value>) -> Option<Value> {
let mut types = match original_type {
Some(Value::String(t)) => vec![t.clone()],
Some(Value::Array(types)) => types
.iter()
.filter_map(|t| t.as_str().map(str::to_string))
.collect(),
_ => return None,
};
if !types.iter().any(|t| t == "object") {
types.push("object".to_string());
}
Some(Value::Array(types.into_iter().map(Value::String).collect()))
}
fn is_object_or_array_only(map: &Map<String, Value>) -> bool {
if map.contains_key("properties") || map.contains_key("additionalProperties") {
return true;
}
if map.contains_key("items") || map.contains_key("prefixItems") {
return true;
}
match map.get("type") {
Some(Value::String(t)) => t == "object" || t == "array",
Some(Value::Array(types)) => types
.iter()
.all(|t| matches!(t.as_str(), Some("object") | Some("array"))),
_ => ["oneOf", "anyOf"]
.into_iter()
.find_map(|key| match map.get(key) {
Some(Value::Array(variants)) => Some(variants),
_ => None,
})
.is_some_and(|variants| {
!variants
.iter()
.any(|v| matches!(v, Value::Object(m) if !is_object_or_array_only(m)))
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn augmented(schema: Value) -> Value {
let Value::Object(mut map) = schema else {
panic!("expected an object schema")
};
augment_field_map(&mut map);
Value::Object(map)
}
#[test]
fn wraps_a_plain_scalar() {
let result = augmented(json!({ "type": "string" }));
assert_eq!(
result,
json!({
"type": ["string", "object"],
"oneOf": [
{ "type": "string" },
{
"type": "object",
"properties": {
"from_env": {
"type": "string",
"description": "Reads the value from an environment variable. In case of a missing value, a warning is logged and the field default/validation rules are applied as usual.",
},
"default": { "type": "string" },
},
"required": ["from_env"],
"additionalProperties": false
}
]
})
);
}
#[test]
fn the_default_property_is_validated_against_the_fields_original_type() {
let result = augmented(json!({
"type": "integer",
"minimum": 0,
"maximum": 65535
}));
let from_env_branch = &result["oneOf"][1];
assert_eq!(
from_env_branch["properties"]["default"],
json!({ "type": "integer", "minimum": 0, "maximum": 65535 })
);
assert_eq!(from_env_branch["required"], json!(["from_env"]));
}
#[test]
fn the_default_property_is_validated_against_the_original_union_for_wrapper_types() {
let result = augmented(json!({
"oneOf": [
{ "type": "boolean" },
{ "type": "string" }
]
}));
let from_env_branch = &result["oneOf"][2];
assert_eq!(
from_env_branch["properties"]["default"],
json!({ "oneOf": [{ "type": "boolean" }, { "type": "string" }] })
);
}
#[test]
fn hoists_description_and_default_to_the_wrapper_top_level() {
let result = augmented(json!({
"description": "The port to bind to.",
"type": "integer",
"default": 4000,
"minimum": 0
}));
assert_eq!(result["description"], json!("The port to bind to."));
assert_eq!(result["default"], json!(4000));
let variants = result["oneOf"].as_array().unwrap();
assert_eq!(
variants[0],
json!({ "type": "integer", "minimum": 0 }),
"the original branch keeps its type-shape keys but not the hoisted metadata"
);
assert_eq!(variants[1]["required"], json!(["from_env"]));
}
#[test]
fn summarizes_a_nullable_types_array_instead_of_dropping_it() {
let result = augmented(json!({ "type": ["string", "null"] }));
assert_eq!(result["type"], json!(["string", "null", "object"]));
}
#[test]
fn does_not_duplicate_object_in_the_type_summary() {
let value = json!("object");
assert_eq!(summarize_types(Some(&value)), Some(json!(["object"])));
}
#[test]
fn omits_the_type_summary_when_there_is_nothing_to_summarize() {
assert_eq!(summarize_types(None), None);
}
#[test]
fn leaves_a_plain_object_alone_but_recurses_into_its_properties() {
let result = augmented(json!({
"type": "object",
"additionalProperties": false,
"properties": {
"name": { "type": "string" }
}
}));
assert_eq!(result["type"], json!("object"));
assert!(result.get("oneOf").is_none());
assert!(result["properties"]["name"]["oneOf"].is_array());
}
#[test]
fn leaves_a_hashmap_alone() {
let result = augmented(json!({
"type": "object",
"additionalProperties": { "type": "string" }
}));
assert!(result.get("oneOf").is_none());
}
#[test]
fn appends_a_single_from_env_alternative_to_an_existing_union_without_duplicating() {
let result = augmented(json!({
"oneOf": [
{ "type": "boolean" },
{ "type": "string" }
]
}));
let variants = result["oneOf"].as_array().unwrap();
assert_eq!(variants.len(), 3);
assert_eq!(variants[0], json!({ "type": "boolean" }));
assert_eq!(variants[1], json!({ "type": "string" }));
assert_eq!(variants[2]["required"], json!(["from_env"]));
}
#[test]
fn does_not_add_an_alternative_to_an_all_object_union() {
let result = augmented(json!({
"oneOf": [
{ "type": "object", "properties": { "type": { "const": "a" } } },
{ "type": "object", "properties": { "type": { "const": "b" } } }
]
}));
let variants = result["oneOf"].as_array().unwrap();
assert_eq!(variants.len(), 2);
}
#[test]
fn wraps_array_items_individually() {
let result = augmented(json!({
"type": "array",
"items": { "type": "string" }
}));
assert!(result.get("oneOf").is_none());
assert!(result["items"]["oneOf"].is_array());
}
}