Skip to main content

camel_dsl/
json.rs

1//! JSON route definition parser.
2//!
3//! Reuses the same AST types as the YAML parser ([`crate::yaml::YamlRoutes`],
4//! [`crate::yaml::YamlRoute`], [`crate::yaml::YamlStep`])
5//! since JSON is a subset of YAML's data model. The only difference is the deserializer.
6//!
7//! ## Stability note
8//!
9//! The type aliases [`JsonRoutes`], [`JsonRoute`], [`JsonStep`] are convenience wrappers
10//! around the YAML AST types. They are **not a stable SDK contract**. SDKs and external
11//! consumers should target [`CanonicalRouteSpec`] for forward compatibility.
12
13use std::path::Path;
14
15use camel_api::{CamelError, CanonicalRouteSpec};
16use camel_core::route::RouteDefinition;
17
18use crate::compile::{
19    compile_declarative_route, compile_declarative_route_to_canonical,
20    compile_declarative_route_with_stream_cache_threshold,
21};
22use crate::yaml::{YamlRoutes, yaml_route_to_declarative_route};
23
24/// Convenience alias — not a stable SDK contract. Target [`CanonicalRouteSpec`] instead.
25pub type JsonRoutes = crate::yaml::YamlRoutes;
26
27/// Convenience alias — not a stable SDK contract. Target [`CanonicalRouteSpec`] instead.
28pub type JsonRoute = crate::yaml::YamlRoute;
29
30/// Convenience alias — not a stable SDK contract. Target [`CanonicalRouteSpec`] instead.
31pub type JsonStep = crate::yaml::YamlStep;
32
33/// Parse a JSON string into declarative route models.
34pub fn parse_json_to_declarative(
35    json: &str,
36) -> Result<Vec<crate::model::DeclarativeRoute>, CamelError> {
37    let routes: YamlRoutes = serde_json::from_str(json)
38        .map_err(|e| CamelError::RouteError(format!("JSON parse error: {e}")))?;
39
40    routes
41        .routes
42        .into_iter()
43        .map(yaml_route_to_declarative_route)
44        .collect()
45}
46
47/// Parse a JSON string into compiled [`RouteDefinition`]s.
48pub fn parse_json(json: &str) -> Result<Vec<RouteDefinition>, CamelError> {
49    parse_json_to_declarative(json)?
50        .into_iter()
51        .map(compile_declarative_route)
52        .collect()
53}
54
55/// Parse a JSON string with a custom stream-cache threshold.
56pub fn parse_json_with_threshold(
57    json: &str,
58    stream_cache_threshold: usize,
59) -> Result<Vec<RouteDefinition>, CamelError> {
60    parse_json_to_declarative(json)?
61        .into_iter()
62        .map(|route| {
63            compile_declarative_route_with_stream_cache_threshold(route, stream_cache_threshold)
64        })
65        .collect()
66}
67
68/// Parse a JSON string into canonical route specs.
69pub fn parse_json_to_canonical(json: &str) -> Result<Vec<CanonicalRouteSpec>, CamelError> {
70    parse_json_to_declarative(json)?
71        .into_iter()
72        .map(compile_declarative_route_to_canonical)
73        .collect()
74}
75
76/// Load a JSON route definition from a file.
77///
78/// Reads the file contents and parses them as JSON. No environment variable interpolation is performed.
79pub fn load_json_from_file(path: &Path) -> Result<Vec<RouteDefinition>, CamelError> {
80    let content = std::fs::read_to_string(path)
81        .map_err(|e| CamelError::Io(format!("Failed to read {}: {e}", path.display())))?;
82    parse_json(&content)
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::model::DeclarativeStep;
89
90    /// Basic route: parse JSON to declarative model.
91    #[test]
92    fn test_basic_route_to_declarative() {
93        let json = r#"
94        {
95            "routes": [
96                {
97                    "id": "test-route",
98                    "from": "timer:tick?period=1000",
99                    "steps": [
100                        {"set_header": {"key": "source", "value": "timer"}},
101                        {"to": "log:info"}
102                    ]
103                }
104            ]
105        }"#;
106        let routes = parse_json_to_declarative(json).unwrap();
107        assert_eq!(routes.len(), 1);
108        assert_eq!(routes[0].route_id, "test-route");
109        assert_eq!(routes[0].from, "timer:tick?period=1000");
110        assert_eq!(routes[0].steps.len(), 2);
111    }
112
113    /// Route metadata: auto_startup and startup_order round-trip correctly.
114    #[test]
115    fn test_route_metadata_auto_startup_and_startup_order() {
116        let json = r#"
117        {
118            "routes": [
119                {
120                    "id": "meta-route",
121                    "from": "direct:start",
122                    "auto_startup": false,
123                    "startup_order": 42
124                }
125            ]
126        }"#;
127        let routes = parse_json_to_declarative(json).unwrap();
128        assert_eq!(routes.len(), 1);
129        assert!(!routes[0].auto_startup);
130        assert_eq!(routes[0].startup_order, 42);
131    }
132
133    /// Error handler with on_exceptions including handled_by.
134    #[test]
135    fn test_error_handler_with_handled_by() {
136        let json = r#"
137        {
138            "routes": [
139                {
140                    "id": "eh-route",
141                    "from": "direct:start",
142                    "error_handler": {
143                        "dead_letter_channel": "log:dlc",
144                        "on_exceptions": [
145                            {
146                                "kind": "Io",
147                                "retry": {
148                                    "max_attempts": 3,
149                                    "handled_by": "log:io"
150                                }
151                            }
152                        ]
153                    }
154                }
155            ]
156        }"#;
157        let routes = parse_json_to_declarative(json).unwrap();
158        let eh = routes[0]
159            .error_handler
160            .as_ref()
161            .expect("error handler should be present");
162        let clauses = eh
163            .on_exceptions
164            .as_ref()
165            .expect("on_exceptions should be present");
166        assert_eq!(clauses.len(), 1);
167        assert_eq!(clauses[0].kind.as_deref(), Some("Io"));
168        let retry = clauses[0].retry.as_ref().expect("retry should be present");
169        assert_eq!(retry.max_attempts, 3);
170        assert_eq!(retry.handled_by.as_deref(), Some("log:io"));
171    }
172
173    /// Empty route id must be rejected.
174    #[test]
175    fn test_empty_id_fails() {
176        let json = r#"
177        {
178            "routes": [
179                {
180                    "id": "",
181                    "from": "timer:tick"
182                }
183            ]
184        }"#;
185        let result = parse_json_to_declarative(json);
186        assert!(result.is_err());
187        let err = result.unwrap_err().to_string();
188        assert!(
189            err.contains("route 'id' must not be empty"),
190            "unexpected error: {err}"
191        );
192    }
193
194    /// Invalid JSON must produce an error containing "JSON".
195    #[test]
196    fn test_invalid_json_error_says_json() {
197        let json = "{ not valid json }}}";
198        let result = parse_json_to_declarative(json);
199        assert!(result.is_err());
200        let err = result.unwrap_err().to_string();
201        assert!(
202            err.contains("JSON parse error:"),
203            "expected 'JSON parse error:' in error, got: {err}"
204        );
205    }
206
207    /// Compiled route via parse_json produces a RouteDefinition.
208    #[test]
209    fn test_compiled_route_via_parse_json() {
210        let json = r#"
211        {
212            "routes": [
213                {
214                    "id": "compiled-route",
215                    "from": "timer:tick",
216                    "steps": [
217                        {"to": "log:info"}
218                    ]
219                }
220            ]
221        }"#;
222        let defs = parse_json(json).unwrap();
223        assert_eq!(defs.len(), 1);
224        assert_eq!(defs[0].route_id(), "compiled-route");
225        assert_eq!(defs[0].from_uri(), "timer:tick");
226    }
227
228    /// parse_json_with_threshold compiles routes with custom stream-cache threshold.
229    #[test]
230    fn test_threshold_parse() {
231        let json = r#"
232        {
233            "routes": [
234                {
235                    "id": "threshold-route",
236                    "from": "timer:tick",
237                    "steps": [
238                        {"stream_cache": true},
239                        {"to": "log:info"}
240                    ]
241                }
242            ]
243        }"#;
244        let defs = parse_json_with_threshold(json, 8192).unwrap();
245        assert_eq!(defs.len(), 1);
246        assert_eq!(defs[0].route_id(), "threshold-route");
247    }
248
249    /// Canonical conversion via parse_json_to_canonical.
250    #[test]
251    fn test_canonical_conversion() {
252        let json = r#"
253        {
254            "routes": [
255                {
256                    "id": "canonical-v1",
257                    "from": "direct:start",
258                    "steps": [
259                        {"to": "mock:out"},
260                        {"log": {"message": "hello"}},
261                        {"stop": true}
262                    ]
263                }
264            ]
265        }"#;
266        let routes = parse_json_to_canonical(json).unwrap();
267        assert_eq!(routes.len(), 1);
268        assert_eq!(routes[0].route_id, "canonical-v1");
269        assert_eq!(routes[0].from, "direct:start");
270        assert_eq!(routes[0].version, 1);
271        assert_eq!(routes[0].steps.len(), 3);
272    }
273
274    /// The "loop" step JSON key works.
275    #[test]
276    fn test_loop_step_json_key() {
277        let json = r#"
278        {
279            "routes": [
280                {
281                    "id": "loop-route",
282                    "from": "direct:start",
283                    "steps": [
284                        {"loop": 3}
285                    ]
286                }
287            ]
288        }"#;
289        let routes = parse_json_to_declarative(json).unwrap();
290        assert_eq!(routes.len(), 1);
291        match &routes[0].steps[0] {
292            DeclarativeStep::Loop(def) => {
293                assert_eq!(def.count, Some(3));
294            }
295            other => panic!("expected Loop step, got {:?}", other),
296        }
297    }
298
299    /// Multiple routes in a single JSON document.
300    #[test]
301    fn test_multiple_routes() {
302        let json = r#"
303        {
304            "routes": [
305                {
306                    "id": "route-a",
307                    "from": "timer:tick",
308                    "steps": [{"to": "log:info"}]
309                },
310                {
311                    "id": "route-b",
312                    "from": "timer:tock",
313                    "auto_startup": false,
314                    "startup_order": 10
315                }
316            ]
317        }"#;
318        let defs = parse_json(json).unwrap();
319        assert_eq!(defs.len(), 2);
320        assert_eq!(defs[0].route_id(), "route-a");
321        assert_eq!(defs[1].route_id(), "route-b");
322    }
323
324    /// Defaults: auto_startup=true, startup_order=1000 when omitted.
325    #[test]
326    fn test_defaults() {
327        let json = r#"
328        {
329            "routes": [
330                {
331                    "id": "default-route",
332                    "from": "timer:tick"
333                }
334            ]
335        }"#;
336        let defs = parse_json(json).unwrap();
337        assert!(defs[0].auto_startup());
338        assert_eq!(defs[0].startup_order(), 1000);
339    }
340
341    /// File loading via load_json_from_file.
342    #[test]
343    fn test_file_loading() {
344        use std::io::Write;
345        let mut file = tempfile::NamedTempFile::new().unwrap();
346
347        let json_content = r#"
348        {
349            "routes": [
350                {
351                    "id": "file-route",
352                    "from": "timer:tick",
353                    "steps": [{"to": "log:info"}]
354                }
355            ]
356        }"#;
357
358        file.write_all(json_content.as_bytes()).unwrap();
359
360        let defs = load_json_from_file(file.path()).unwrap();
361        assert_eq!(defs.len(), 1);
362        assert_eq!(defs[0].route_id(), "file-route");
363    }
364
365    /// Missing file must return an error.
366    #[test]
367    fn test_missing_file_error() {
368        let result = load_json_from_file(Path::new("/nonexistent/path/routes.json"));
369        assert!(result.is_err());
370        let err = result.err().unwrap().to_string();
371        assert!(
372            err.contains("Failed to read"),
373            "expected file read error, got: {err}"
374        );
375    }
376
377    #[test]
378    fn test_function_step_json_parses_and_compiles() {
379        let json = r#"
380        {
381            "routes": [
382                {
383                    "id": "fn-json-route",
384                    "from": "direct:start",
385                    "steps": [
386                        {
387                            "function": {
388                                "runtime": "deno",
389                                "source": "return { body: 'ok' };",
390                                "timeout_ms": 3000
391                            }
392                        }
393                    ]
394                }
395            ]
396        }"#;
397        let defs = parse_json(json).unwrap();
398        assert_eq!(defs.len(), 1);
399        assert_eq!(defs[0].route_id(), "fn-json-route");
400    }
401
402    #[test]
403    fn test_function_step_json_compiles_to_declarative_function() {
404        let json = r#"
405        {
406            "routes": [
407                {
408                    "id": "fn-decl",
409                    "from": "direct:start",
410                    "steps": [
411                        {
412                            "function": {
413                                "runtime": "deno",
414                                "source": "return { body: 1 };"
415                            }
416                        }
417                    ]
418                }
419            ]
420        }"#;
421        let routes = parse_json_to_declarative(json).unwrap();
422        match &routes[0].steps[0] {
423            DeclarativeStep::Function(def) => {
424                assert_eq!(def.runtime, "deno");
425                assert_eq!(def.source, "return { body: 1 };");
426                assert_eq!(def.timeout_ms, None);
427            }
428            other => panic!("expected Function, got {other:?}"),
429        }
430    }
431
432    #[test]
433    fn test_function_step_rejected_by_canonical_json() {
434        let json = r#"
435        {
436            "routes": [
437                {
438                    "id": "fn-canonical-reject",
439                    "from": "direct:start",
440                    "steps": [
441                        {
442                            "function": {
443                                "runtime": "deno",
444                                "source": "return {};",
445                                "timeout_ms": 1000
446                            }
447                        }
448                    ]
449                }
450            ]
451        }"#;
452        let err = parse_json_to_canonical(json).unwrap_err().to_string();
453        assert!(
454            err.contains("canonical v1 does not support step `function`"),
455            "unexpected error: {err}"
456        );
457    }
458}