fresh_core/
plugin_schemas.rs1use serde_json::{Map, Value};
25
26pub fn validate_plugin_schema(value: &Value) -> Result<(), String> {
30 let obj = value
31 .as_object()
32 .ok_or_else(|| "schema root must be an object".to_string())?;
33
34 match obj.get("type") {
35 Some(Value::String(s)) if s == "object" => {}
36 _ => return Err("schema root must have \"type\": \"object\"".to_string()),
37 }
38
39 check_no_forbidden_keys(value)?;
40
41 let _ = obj
44 .get("properties")
45 .and_then(|p| p.as_object())
46 .ok_or_else(|| "schema must declare \"properties\"".to_string())?;
47 Ok(())
48}
49
50fn check_no_forbidden_keys(value: &Value) -> Result<(), String> {
51 match value {
52 Value::Object(m) => {
53 for (k, v) in m {
54 if k == "$ref" {
55 return Err("plugin schemas may not use $ref".to_string());
56 }
57 if k == "x-enum-from" {
58 return Err(
59 "plugin schemas may not use x-enum-from (host coupling)".to_string()
60 );
61 }
62 check_no_forbidden_keys(v)?;
63 }
64 }
65 Value::Array(a) => {
66 for v in a {
67 check_no_forbidden_keys(v)?;
68 }
69 }
70 _ => {}
71 }
72 Ok(())
73}
74
75pub fn deep_merge_under(target: &mut Value, defaults: &Value) {
80 if target.is_null() {
81 *target = defaults.clone();
82 return;
83 }
84 let (Value::Object(t_map), Value::Object(d_map)) = (target, defaults) else {
85 return;
86 };
87 for (k, v) in d_map {
88 match t_map.get_mut(k) {
89 Some(existing) => deep_merge_under(existing, v),
90 None => {
91 t_map.insert(k.clone(), v.clone());
92 }
93 }
94 }
95}
96
97pub fn defaults_from_schema(schema: &Value) -> Value {
101 let mut out = Map::new();
102 if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
103 for (k, prop) in props {
104 if let Some(d) = prop.get("default") {
105 out.insert(k.clone(), d.clone());
106 } else if let Some(t) = prop.get("type").and_then(|t| t.as_str()) {
107 if t == "object" {
108 let nested = defaults_from_schema(prop);
109 if !nested.as_object().map(|o| o.is_empty()).unwrap_or(true) {
110 out.insert(k.clone(), nested);
111 }
112 }
113 }
114 }
115 }
116 Value::Object(out)
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use serde_json::json;
123
124 #[test]
125 fn rejects_non_object_root() {
126 assert!(validate_plugin_schema(&json!("hello")).is_err());
127 assert!(validate_plugin_schema(&json!([])).is_err());
128 }
129
130 #[test]
131 fn rejects_missing_object_type() {
132 assert!(validate_plugin_schema(&json!({})).is_err());
133 assert!(validate_plugin_schema(&json!({"type": "string"})).is_err());
134 }
135
136 #[test]
137 fn rejects_refs() {
138 let schema = json!({
139 "type": "object",
140 "properties": {"foo": {"$ref": "#/$defs/Bar"}}
141 });
142 assert!(validate_plugin_schema(&schema).is_err());
143 }
144
145 #[test]
146 fn rejects_x_enum_from() {
147 let schema = json!({
148 "type": "object",
149 "properties": {"lang": {"type": "string", "x-enum-from": "/languages"}}
150 });
151 assert!(validate_plugin_schema(&schema).is_err());
152 }
153
154 #[test]
155 fn accepts_valid_schema() {
156 let schema = json!({
157 "type": "object",
158 "properties": {
159 "auto_enable": {"type": "boolean", "default": false},
160 "max_items": {"type": "integer", "minimum": 1, "default": 3}
161 }
162 });
163 assert!(validate_plugin_schema(&schema).is_ok());
164 }
165
166 #[test]
167 fn accepts_empty_properties() {
168 let schema = json!({"type": "object", "properties": {}});
169 assert!(validate_plugin_schema(&schema).is_ok());
170 }
171
172 #[test]
173 fn defaults_extraction() {
174 let schema = json!({
175 "type": "object",
176 "properties": {
177 "auto_enable": {"type": "boolean", "default": false},
178 "max_items": {"type": "integer", "minimum": 1, "default": 3},
179 "no_default": {"type": "string"}
180 }
181 });
182 let d = defaults_from_schema(&schema);
183 assert_eq!(d, json!({"auto_enable": false, "max_items": 3}));
184 }
185}