use serde_json::{Map, Value};
pub fn template_from_schema(schema: &Value) -> Value {
let Some(props) = schema.get("properties").and_then(Value::as_object) else {
return Value::Object(Map::new());
};
let allowed_keys: Option<std::collections::BTreeSet<String>> = schema
.get("oneOf")
.and_then(Value::as_array)
.and_then(|alts| alts.first())
.and_then(|alt| alt.get("required"))
.and_then(Value::as_array)
.map(|req| {
req.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
});
let mut out = Map::new();
for (key, prop) in props {
if let Some(allowed) = &allowed_keys
&& !allowed.contains(key)
{
continue;
}
out.insert(key.clone(), placeholder(prop));
}
Value::Object(out)
}
fn placeholder(prop: &Value) -> Value {
if prop.get("type").and_then(Value::as_str) == Some("object") {
return template_from_schema(prop);
}
let desc = prop
.get("description")
.and_then(Value::as_str)
.unwrap_or("");
let kind = type_hint(prop);
let text = if desc.is_empty() {
format!("<{kind}>")
} else {
format!("<{kind}> {desc}")
};
Value::String(text)
}
fn type_hint(prop: &Value) -> String {
if let Some(values) = prop.get("enum").and_then(Value::as_array) {
let opts: Vec<String> = values.iter().map(render_scalar).collect();
let joined = opts.join("|");
return match prop.get("type").and_then(Value::as_str) {
Some("integer") => format!("integer, one of: {joined}"),
Some("number") => format!("number, one of: {joined}"),
_ => format!("one of: {joined}"),
};
}
match prop.get("type").and_then(Value::as_str) {
Some("boolean") => "boolean".to_string(),
Some("integer") => with_range("integer", prop),
Some("number") => with_range("number", prop),
Some("string") => "string".to_string(),
Some("array") => array_hint(prop),
Some(other) => other.to_string(),
None => "value".to_string(),
}
}
fn with_range(base: &str, prop: &Value) -> String {
let min = prop.get("minimum").and_then(Value::as_i64);
let max = prop.get("maximum").and_then(Value::as_i64);
match (min, max) {
(Some(lo), Some(hi)) => format!("{base} {lo}-{hi}"),
(Some(lo), None) => format!("{base} >= {lo}"),
(None, Some(hi)) => format!("{base} <= {hi}"),
(None, None) => base.to_string(),
}
}
fn array_hint(prop: &Value) -> String {
let item_kind = prop
.get("items")
.map(type_hint)
.unwrap_or_else(|| "value".to_string());
let min = prop.get("minItems").and_then(Value::as_u64);
let max = prop.get("maxItems").and_then(Value::as_u64);
match (min, max) {
(Some(n), Some(m)) if n == m => format!("array of {item_kind} ({n} items)"),
_ => format!("array of {item_kind}"),
}
}
fn render_scalar(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn booleans_become_typed_hints() {
let schema = json!({
"type": "object",
"properties": {
"fever": { "type": "boolean", "description": "Fever in the last 24 hours" }
}
});
let t = template_from_schema(&schema);
assert_eq!(t["fever"], json!("<boolean> Fever in the last 24 hours"));
}
#[test]
fn enums_list_their_options() {
let schema = json!({
"type": "object",
"properties": {
"smoking": { "type": "string", "enum": ["non", "ex", "current"], "description": "Smoking status" }
}
});
let t = template_from_schema(&schema);
assert_eq!(
t["smoking"],
json!("<one of: non|ex|current> Smoking status")
);
}
#[test]
fn arrays_report_item_type_and_count() {
let schema = json!({
"type": "object",
"properties": {
"responses": {
"type": "array",
"items": { "type": "integer", "minimum": 0, "maximum": 4 },
"minItems": 18, "maxItems": 18,
"description": "18 frequency responses"
}
}
});
let t = template_from_schema(&schema);
assert_eq!(
t["responses"],
json!("<array of integer 0-4 (18 items)> 18 frequency responses")
);
}
#[test]
fn no_properties_yields_empty_object() {
assert_eq!(template_from_schema(&json!({"type": "object"})), json!({}));
}
#[test]
fn integer_enums_lead_with_the_json_type() {
let schema = json!({
"type": "object",
"properties": {
"killip_class": {
"type": "integer",
"enum": [1, 2, 3, 4],
"description": "Killip class on admission (1-4)"
}
}
});
let t = template_from_schema(&schema);
assert_eq!(
t["killip_class"],
json!("<integer, one of: 1|2|3|4> Killip class on admission (1-4)")
);
}
#[test]
fn number_enums_lead_with_the_json_type() {
let schema = json!({
"type": "object",
"properties": {
"dose": { "type": "number", "enum": [0.5, 1.0, 2.0] }
}
});
let t = template_from_schema(&schema);
assert_eq!(t["dose"], json!("<number, one of: 0.5|1.0|2.0>"));
}
#[test]
fn one_of_alternatives_emit_only_the_first_alternative() {
let schema = json!({
"type": "object",
"properties": {
"acr": { "type": "number" },
"acr_unit": { "type": "string", "enum": ["mg/mmol", "mg/g"] },
"albumin": { "type": "number" },
"creatinine": { "type": "number" }
},
"oneOf": [
{ "required": ["acr", "acr_unit"] },
{ "required": ["albumin", "creatinine"] }
]
});
let t = template_from_schema(&schema);
let obj = t.as_object().unwrap();
assert!(obj.contains_key("acr"));
assert!(obj.contains_key("acr_unit"));
assert!(!obj.contains_key("albumin"));
assert!(!obj.contains_key("creatinine"));
}
}