Skip to main content

ironflow_core/
schema_transform.rs

1//! JSON Schema transformations for Claude CLI compatibility.
2//!
3//! The Claude CLI `--json-schema` flag has known compatibility issues with
4//! certain JSON Schema features. This module normalizes schemas before
5//! sending them to the CLI to maximize structured output reliability.
6//!
7//! # Transformations applied
8//!
9//! 1. **`additionalProperties: false`** - added to every object that lacks it
10//!    (required by Anthropic's constrained decoding).
11//! 2. **`$ref` / `$defs` inlining** - JSON Schema references are resolved
12//!    and inlined, since the CLI may not support `$ref`.
13//! 3. **Meta-field removal** - `$schema`, `title`, and `default` are stripped
14//!    (not used by the CLI and may cause issues).
15//!
16//! # Examples
17//!
18//! ```
19//! use ironflow_core::schema_transform::transform_schema;
20//!
21//! let schema = r#"{"type":"object","properties":{"name":{"type":"string"}}}"#;
22//! let transformed = transform_schema(schema);
23//! assert!(transformed.contains("additionalProperties"));
24//! ```
25
26use serde_json::{Map, Value};
27use tracing::warn;
28
29/// Transform a JSON Schema string for Claude CLI compatibility.
30///
31/// Parses the schema, applies all transformations, and re-serializes.
32/// Returns the original string unchanged if parsing fails.
33///
34/// # Examples
35///
36/// ```
37/// use ironflow_core::schema_transform::transform_schema;
38///
39/// let schema = r#"{"type":"object","properties":{"x":{"type":"integer"}}}"#;
40/// let result = transform_schema(schema);
41/// assert!(result.contains(r#""additionalProperties":false"#));
42/// ```
43pub fn transform_schema(schema: &str) -> String {
44    let mut value: Value = match serde_json::from_str(schema) {
45        Ok(v) => v,
46        Err(e) => {
47            warn!(error = %e, "failed to parse JSON schema for transformation, using original");
48            return schema.to_string();
49        }
50    };
51
52    let defs = extract_defs(&value);
53    remove_meta_fields(&mut value);
54    inline_refs(&mut value, &defs);
55    add_additional_properties_false(&mut value);
56
57    serde_json::to_string(&value).unwrap_or_else(|_| schema.to_string())
58}
59
60/// Extract `$defs` (or `definitions`) from the root schema for ref inlining.
61fn extract_defs(schema: &Value) -> Map<String, Value> {
62    schema
63        .get("$defs")
64        .or_else(|| schema.get("definitions"))
65        .and_then(|v| v.as_object())
66        .cloned()
67        .unwrap_or_default()
68}
69
70/// Remove `$schema`, `title`, `default`, `$defs`, and `definitions` from all levels.
71fn remove_meta_fields(value: &mut Value) {
72    if let Some(obj) = value.as_object_mut() {
73        obj.remove("$schema");
74        obj.remove("title");
75        obj.remove("default");
76        obj.remove("$defs");
77        obj.remove("definitions");
78
79        for (_, v) in obj.iter_mut() {
80            remove_meta_fields(v);
81        }
82    } else if let Some(arr) = value.as_array_mut() {
83        for item in arr.iter_mut() {
84            remove_meta_fields(item);
85        }
86    }
87}
88
89/// Recursively replace `{"$ref": "#/$defs/Foo"}` with the inlined definition.
90fn inline_refs(value: &mut Value, defs: &Map<String, Value>) {
91    match value {
92        Value::Object(obj) => {
93            if let Some(ref_val) = obj.get("$ref").and_then(|v| v.as_str()).map(String::from)
94                && let Some(resolved) = resolve_ref(&ref_val, defs)
95            {
96                let mut resolved = resolved.clone();
97                inline_refs(&mut resolved, defs);
98                *value = resolved;
99                return;
100            }
101
102            for (_, v) in obj.iter_mut() {
103                inline_refs(v, defs);
104            }
105        }
106        Value::Array(arr) => {
107            for item in arr.iter_mut() {
108                inline_refs(item, defs);
109            }
110        }
111        _ => {}
112    }
113}
114
115/// Resolve a `$ref` path like `#/$defs/MyType` or `#/definitions/MyType`.
116fn resolve_ref<'a>(ref_path: &str, defs: &'a Map<String, Value>) -> Option<&'a Value> {
117    let name = ref_path
118        .strip_prefix("#/$defs/")
119        .or_else(|| ref_path.strip_prefix("#/definitions/"))?;
120    defs.get(name)
121}
122
123/// Add `"additionalProperties": false` to every object schema that lacks it.
124fn add_additional_properties_false(value: &mut Value) {
125    if let Some(obj) = value.as_object_mut() {
126        let is_object_schema = obj.get("type").and_then(|t| t.as_str()) == Some("object");
127
128        if is_object_schema && !obj.contains_key("additionalProperties") {
129            obj.insert("additionalProperties".to_string(), Value::Bool(false));
130        }
131
132        for (_, v) in obj.iter_mut() {
133            add_additional_properties_false(v);
134        }
135    } else if let Some(arr) = value.as_array_mut() {
136        for item in arr.iter_mut() {
137            add_additional_properties_false(item);
138        }
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use serde_json::json;
146
147    #[test]
148    fn adds_additional_properties_to_simple_object() {
149        let schema = r#"{"type":"object","properties":{"name":{"type":"string"}}}"#;
150        let result = transform_schema(schema);
151        let parsed: Value = serde_json::from_str(&result).unwrap();
152        assert_eq!(parsed["additionalProperties"], json!(false));
153    }
154
155    #[test]
156    fn adds_additional_properties_to_nested_objects() {
157        let schema = json!({
158            "type": "object",
159            "properties": {
160                "address": {
161                    "type": "object",
162                    "properties": {
163                        "city": {"type": "string"}
164                    }
165                }
166            }
167        });
168        let result = transform_schema(&schema.to_string());
169        let parsed: Value = serde_json::from_str(&result).unwrap();
170        assert_eq!(parsed["additionalProperties"], json!(false));
171        assert_eq!(
172            parsed["properties"]["address"]["additionalProperties"],
173            json!(false)
174        );
175    }
176
177    #[test]
178    fn preserves_existing_additional_properties() {
179        let schema = json!({
180            "type": "object",
181            "properties": {"x": {"type": "integer"}},
182            "additionalProperties": true
183        });
184        let result = transform_schema(&schema.to_string());
185        let parsed: Value = serde_json::from_str(&result).unwrap();
186        assert_eq!(parsed["additionalProperties"], json!(true));
187    }
188
189    #[test]
190    fn inlines_defs_refs() {
191        let schema = json!({
192            "type": "object",
193            "properties": {
194                "item": {"$ref": "#/$defs/Item"}
195            },
196            "$defs": {
197                "Item": {
198                    "type": "object",
199                    "properties": {
200                        "name": {"type": "string"}
201                    }
202                }
203            }
204        });
205        let result = transform_schema(&schema.to_string());
206        let parsed: Value = serde_json::from_str(&result).unwrap();
207
208        assert!(parsed.get("$defs").is_none());
209        assert_eq!(parsed["properties"]["item"]["type"], json!("object"));
210        assert_eq!(
211            parsed["properties"]["item"]["properties"]["name"]["type"],
212            json!("string")
213        );
214        assert_eq!(
215            parsed["properties"]["item"]["additionalProperties"],
216            json!(false)
217        );
218    }
219
220    #[test]
221    fn inlines_definitions_refs() {
222        let schema = json!({
223            "type": "object",
224            "properties": {
225                "item": {"$ref": "#/definitions/Item"}
226            },
227            "definitions": {
228                "Item": {
229                    "type": "object",
230                    "properties": {
231                        "id": {"type": "integer"}
232                    }
233                }
234            }
235        });
236        let result = transform_schema(&schema.to_string());
237        let parsed: Value = serde_json::from_str(&result).unwrap();
238
239        assert!(parsed.get("definitions").is_none());
240        assert_eq!(parsed["properties"]["item"]["type"], json!("object"));
241    }
242
243    #[test]
244    fn removes_meta_fields() {
245        let schema = json!({
246            "$schema": "http://json-schema.org/draft-07/schema#",
247            "title": "MySchema",
248            "type": "object",
249            "properties": {
250                "score": {
251                    "type": "integer",
252                    "title": "The Score",
253                    "default": 0
254                }
255            }
256        });
257        let result = transform_schema(&schema.to_string());
258        let parsed: Value = serde_json::from_str(&result).unwrap();
259
260        assert!(parsed.get("$schema").is_none());
261        assert!(parsed.get("title").is_none());
262        assert!(parsed["properties"]["score"].get("title").is_none());
263        assert!(parsed["properties"]["score"].get("default").is_none());
264    }
265
266    #[test]
267    fn idempotent_on_already_transformed_schema() {
268        let schema = json!({
269            "type": "object",
270            "properties": {
271                "x": {"type": "integer"}
272            },
273            "additionalProperties": false
274        });
275        let input = schema.to_string();
276        let first = transform_schema(&input);
277        let second = transform_schema(&first);
278        assert_eq!(first, second);
279    }
280
281    #[test]
282    fn handles_invalid_json_gracefully() {
283        let bad = "not valid json{";
284        let result = transform_schema(bad);
285        assert_eq!(result, bad);
286    }
287
288    #[test]
289    fn handles_non_object_schema() {
290        let schema = r#"{"type":"string"}"#;
291        let result = transform_schema(schema);
292        let parsed: Value = serde_json::from_str(&result).unwrap();
293        assert!(parsed.get("additionalProperties").is_none());
294    }
295
296    #[test]
297    fn inlines_nested_refs() {
298        let schema = json!({
299            "type": "object",
300            "properties": {
301                "items": {
302                    "type": "array",
303                    "items": {"$ref": "#/$defs/Item"}
304                }
305            },
306            "$defs": {
307                "Item": {
308                    "type": "object",
309                    "properties": {
310                        "nested": {"$ref": "#/$defs/Nested"}
311                    }
312                },
313                "Nested": {
314                    "type": "object",
315                    "properties": {
316                        "value": {"type": "string"}
317                    }
318                }
319            }
320        });
321        let result = transform_schema(&schema.to_string());
322        let parsed: Value = serde_json::from_str(&result).unwrap();
323
324        let item = &parsed["properties"]["items"]["items"];
325        assert_eq!(item["type"], json!("object"));
326        assert_eq!(item["additionalProperties"], json!(false));
327
328        let nested = &item["properties"]["nested"];
329        assert_eq!(nested["type"], json!("object"));
330        assert_eq!(nested["additionalProperties"], json!(false));
331        assert_eq!(nested["properties"]["value"]["type"], json!("string"));
332    }
333
334    #[test]
335    fn handles_schemars_generated_schema() {
336        let schema = json!({
337            "$schema": "http://json-schema.org/draft-07/schema#",
338            "title": "Review",
339            "type": "object",
340            "required": ["score", "summary"],
341            "properties": {
342                "score": {"type": "integer", "default": 5},
343                "summary": {"type": "string"}
344            }
345        });
346        let result = transform_schema(&schema.to_string());
347        let parsed: Value = serde_json::from_str(&result).unwrap();
348
349        assert!(parsed.get("$schema").is_none());
350        assert!(parsed.get("title").is_none());
351        assert!(parsed["properties"]["score"].get("default").is_none());
352        assert_eq!(parsed["additionalProperties"], json!(false));
353        assert_eq!(parsed["required"], json!(["score", "summary"]));
354    }
355}