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::RouteDslRoutes`],
4//! [`crate::yaml::RouteDslRoute`], [`crate::yaml::RouteDslStep`])
5//! since JSON is a subset of YAML's data model. The only difference is the deserializer.
6//!
7//! ## Stability note
8//!
9//! The parser uses [`RouteDslRoutes`] directly (no separate JSON-specific AST types).
10//! The `RouteDsl*` AST types 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::input_format::{InputFormat, annotate_format};
23use crate::model::SecurityCompileContext;
24use crate::yaml::{RouteDslRoutes, route_dsl_to_declarative_route};
25
26/// Parse a JSON string into declarative route models.
27pub fn parse_json_to_declarative(
28    json: &str,
29) -> Result<Vec<crate::model::DeclarativeRoute>, CamelError> {
30    annotate_format(InputFormat::Json, parse_json_to_declarative_inner(json))
31}
32
33fn parse_json_to_declarative_inner(
34    json: &str,
35) -> Result<Vec<crate::model::DeclarativeRoute>, CamelError> {
36    let mut dsl: RouteDslRoutes = serde_json::from_str(json)
37        .map_err(|e| CamelError::RouteError(format!("JSON parse error: {e}")))?;
38
39    // Expand REST blocks (review I2): the JSON authoring path previously
40    // dropped `rest:` silently. JSON is a full-DSL authoring format
41    // (ADR-0026), so it lowers `rest:` exactly like the YAML path — via the
42    // shared helper that also runs cross-block validation.
43    crate::rest::expand_rest_into(&mut dsl.routes, &dsl.rest)?;
44    crate::rest::check_duplicate_route_ids(&dsl.routes)?;
45
46    dsl.routes
47        .into_iter()
48        .map(route_dsl_to_declarative_route)
49        .collect()
50}
51
52/// Parse a JSON string into compiled [`RouteDefinition`]s.
53pub fn parse_json(json: &str) -> Result<Vec<RouteDefinition>, CamelError> {
54    annotate_format(InputFormat::Json, parse_json_inner(json))
55}
56
57fn parse_json_inner(json: &str) -> Result<Vec<RouteDefinition>, CamelError> {
58    parse_json_to_declarative_inner(json)?
59        .into_iter()
60        .map(compile_declarative_route)
61        .collect()
62}
63
64/// Parse a JSON string with a custom stream-cache threshold.
65pub fn parse_json_with_threshold(
66    json: &str,
67    stream_cache_threshold: usize,
68) -> Result<Vec<RouteDefinition>, CamelError> {
69    annotate_format(
70        InputFormat::Json,
71        parse_json_with_threshold_inner(json, stream_cache_threshold),
72    )
73}
74
75fn parse_json_with_threshold_inner(
76    json: &str,
77    stream_cache_threshold: usize,
78) -> Result<Vec<RouteDefinition>, CamelError> {
79    parse_json_with_threshold_and_security_inner(
80        json,
81        stream_cache_threshold,
82        SecurityCompileContext::default(),
83    )
84}
85
86pub fn parse_json_with_threshold_and_security(
87    json: &str,
88    stream_cache_threshold: usize,
89    security_ctx: SecurityCompileContext,
90) -> Result<Vec<RouteDefinition>, CamelError> {
91    annotate_format(
92        InputFormat::Json,
93        parse_json_with_threshold_and_security_inner(json, stream_cache_threshold, security_ctx),
94    )
95}
96
97fn parse_json_with_threshold_and_security_inner(
98    json: &str,
99    stream_cache_threshold: usize,
100    security_ctx: SecurityCompileContext,
101) -> Result<Vec<RouteDefinition>, CamelError> {
102    parse_json_to_declarative_inner(json)?
103        .into_iter()
104        .map(|route| {
105            compile_declarative_route_with_stream_cache_threshold(
106                route,
107                stream_cache_threshold,
108                security_ctx.clone(),
109            )
110        })
111        .collect()
112}
113
114/// Parse a JSON string into canonical route specs.
115pub fn parse_json_to_canonical(json: &str) -> Result<Vec<CanonicalRouteSpec>, CamelError> {
116    annotate_format(InputFormat::Json, parse_json_to_canonical_inner(json))
117}
118
119fn parse_json_to_canonical_inner(json: &str) -> Result<Vec<CanonicalRouteSpec>, CamelError> {
120    let routes = parse_json_to_declarative_inner(json)?;
121    for route in &routes {
122        if route.security_policy.is_some() {
123            return Err(CamelError::RouteError(
124                "routes with security_policy cannot use the canonical/hot-reload path (not yet supported)".into(),
125            ));
126        }
127    }
128    routes
129        .into_iter()
130        .map(|r| compile_declarative_route_to_canonical(r, false).map(|(spec, _)| spec))
131        .collect()
132}
133
134use crate::util::read_route_file_capped;
135
136/// Load a JSON route definition from a file.
137///
138/// Reads the file contents and parses them as JSON. No environment variable interpolation is performed.
139pub fn load_json_from_file(path: &Path) -> Result<Vec<RouteDefinition>, CamelError> {
140    let content = read_route_file_capped(path)?;
141    let annotated = annotate_format(InputFormat::Json, parse_json_inner(&content));
142    annotated.map_err(|e| match e {
143        CamelError::RouteError(msg) => {
144            CamelError::RouteError(format!("{msg} (in {})", path.display()))
145        }
146        other => other,
147    })
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::model::DeclarativeStep;
154    use std::fs::File;
155
156    /// Basic route: parse JSON to declarative model.
157    #[test]
158    fn test_basic_route_to_declarative() {
159        let json = r#"
160        {
161            "routes": [
162                {
163                    "id": "test-route",
164                    "from": "timer:tick?period=1000",
165                    "steps": [
166                        {"set_header": {"key": "source", "value": "timer"}},
167                        {"to": "log:info"}
168                    ]
169                }
170            ]
171        }"#;
172        let routes = parse_json_to_declarative(json).unwrap();
173        assert_eq!(routes.len(), 1);
174        assert_eq!(routes[0].route_id, "test-route");
175        assert_eq!(routes[0].from, "timer:tick?period=1000");
176        assert_eq!(routes[0].steps.len(), 2);
177    }
178
179    /// Route metadata: auto_startup and startup_order round-trip correctly.
180    #[test]
181    fn test_route_metadata_auto_startup_and_startup_order() {
182        let json = r#"
183        {
184            "routes": [
185                {
186                    "id": "meta-route",
187                    "from": "direct:start",
188                    "auto_startup": false,
189                    "startup_order": 42
190                }
191            ]
192        }"#;
193        let routes = parse_json_to_declarative(json).unwrap();
194        assert_eq!(routes.len(), 1);
195        assert!(!routes[0].auto_startup);
196        assert_eq!(routes[0].startup_order, 42);
197    }
198
199    /// Error handler with on_exceptions including handled_by.
200    #[test]
201    fn test_error_handler_with_handled_by() {
202        let json = r#"
203        {
204            "routes": [
205                {
206                    "id": "eh-route",
207                    "from": "direct:start",
208                    "error_handler": {
209                        "dead_letter_channel": "log:dlc",
210                        "on_exceptions": [
211                            {
212                                "kind": "Io",
213                                "retry": {
214                                    "max_attempts": 3,
215                                    "handled_by": "log:io"
216                                }
217                            }
218                        ]
219                    }
220                }
221            ]
222        }"#;
223        let routes = parse_json_to_declarative(json).unwrap();
224        let eh = routes[0]
225            .error_handler
226            .as_ref()
227            .expect("error handler should be present");
228        let clauses = eh
229            .on_exceptions
230            .as_ref()
231            .expect("on_exceptions should be present");
232        assert_eq!(clauses.len(), 1);
233        assert_eq!(clauses[0].kind.as_deref(), Some("Io"));
234        let retry = clauses[0].retry.as_ref().expect("retry should be present");
235        assert_eq!(retry.max_attempts, 3);
236        assert_eq!(retry.handled_by.as_deref(), Some("log:io"));
237    }
238
239    /// Empty route id must be rejected.
240    #[test]
241    fn test_empty_id_fails() {
242        let json = r#"
243        {
244            "routes": [
245                {
246                    "id": "",
247                    "from": "timer:tick"
248                }
249            ]
250        }"#;
251        let result = parse_json_to_declarative(json);
252        assert!(result.is_err());
253        let err = result.unwrap_err().to_string();
254        assert!(
255            err.contains("route 'id' must not be empty"),
256            "unexpected error: {err}"
257        );
258    }
259
260    /// Invalid JSON must produce an error containing "JSON".
261    #[test]
262    fn test_invalid_json_error_says_json() {
263        let json = "{ not valid json }}}";
264        let result = parse_json_to_declarative(json);
265        assert!(result.is_err());
266        let err = result.unwrap_err().to_string();
267        assert!(
268            err.contains("JSON DSL error: JSON parse error:"),
269            "expected 'JSON DSL error: JSON parse error:' in error, got: {err}"
270        );
271    }
272
273    /// Compiled route via parse_json produces a RouteDefinition.
274    #[test]
275    fn test_compiled_route_via_parse_json() {
276        let json = r#"
277        {
278            "routes": [
279                {
280                    "id": "compiled-route",
281                    "from": "timer:tick",
282                    "steps": [
283                        {"to": "log:info"}
284                    ]
285                }
286            ]
287        }"#;
288        let defs = parse_json(json).unwrap();
289        assert_eq!(defs.len(), 1);
290        assert_eq!(defs[0].route_id(), "compiled-route");
291        assert_eq!(defs[0].from_uri(), "timer:tick");
292    }
293
294    /// parse_json_with_threshold compiles routes with custom stream-cache threshold.
295    #[test]
296    fn test_threshold_parse() {
297        let json = r#"
298        {
299            "routes": [
300                {
301                    "id": "threshold-route",
302                    "from": "timer:tick",
303                    "steps": [
304                        {"stream_cache": true},
305                        {"to": "log:info"}
306                    ]
307                }
308            ]
309        }"#;
310        let defs = parse_json_with_threshold(json, 8192).unwrap();
311        assert_eq!(defs.len(), 1);
312        assert_eq!(defs[0].route_id(), "threshold-route");
313    }
314
315    /// Canonical conversion via parse_json_to_canonical.
316    #[test]
317    fn test_canonical_conversion() {
318        let json = r#"
319        {
320            "routes": [
321                {
322                    "id": "canonical-v1",
323                    "from": "direct:start",
324                    "steps": [
325                        {"to": "mock:out"},
326                        {"log": {"message": "hello"}},
327                        {"stop": true}
328                    ]
329                }
330            ]
331        }"#;
332        let routes = parse_json_to_canonical(json).unwrap();
333        assert_eq!(routes.len(), 1);
334        assert_eq!(routes[0].route_id, "canonical-v1");
335        assert_eq!(routes[0].from, "direct:start");
336        assert_eq!(routes[0].version, 2);
337        assert_eq!(routes[0].steps.len(), 3);
338    }
339
340    /// The "loop" step JSON key works.
341    #[test]
342    fn test_loop_step_json_key() {
343        let json = r#"
344        {
345            "routes": [
346                {
347                    "id": "loop-route",
348                    "from": "direct:start",
349                    "steps": [
350                        {"loop": 3}
351                    ]
352                }
353            ]
354        }"#;
355        let routes = parse_json_to_declarative(json).unwrap();
356        assert_eq!(routes.len(), 1);
357        match &routes[0].steps[0] {
358            DeclarativeStep::Loop(def) => {
359                assert_eq!(def.count, Some(3));
360            }
361            other => panic!("expected Loop step, got {:?}", other),
362        }
363    }
364
365    /// Multiple routes in a single JSON document.
366    #[test]
367    fn test_multiple_routes() {
368        let json = r#"
369        {
370            "routes": [
371                {
372                    "id": "route-a",
373                    "from": "timer:tick",
374                    "steps": [{"to": "log:info"}]
375                },
376                {
377                    "id": "route-b",
378                    "from": "timer:tock",
379                    "auto_startup": false,
380                    "startup_order": 10
381                }
382            ]
383        }"#;
384        let defs = parse_json(json).unwrap();
385        assert_eq!(defs.len(), 2);
386        assert_eq!(defs[0].route_id(), "route-a");
387        assert_eq!(defs[1].route_id(), "route-b");
388    }
389
390    /// Defaults: auto_startup=true, startup_order=1000 when omitted.
391    #[test]
392    fn test_defaults() {
393        let json = r#"
394        {
395            "routes": [
396                {
397                    "id": "default-route",
398                    "from": "timer:tick"
399                }
400            ]
401        }"#;
402        let defs = parse_json(json).unwrap();
403        assert!(defs[0].auto_startup());
404        assert_eq!(defs[0].startup_order(), 1000);
405    }
406
407    /// File loading via load_json_from_file.
408    #[test]
409    fn test_file_loading() {
410        use std::io::Write;
411        let mut file = tempfile::NamedTempFile::new().unwrap();
412
413        let json_content = r#"
414        {
415            "routes": [
416                {
417                    "id": "file-route",
418                    "from": "timer:tick",
419                    "steps": [{"to": "log:info"}]
420                }
421            ]
422        }"#;
423
424        file.write_all(json_content.as_bytes()).unwrap();
425
426        let defs = load_json_from_file(file.path()).unwrap();
427        assert_eq!(defs.len(), 1);
428        assert_eq!(defs[0].route_id(), "file-route");
429    }
430
431    /// Missing file must return an error.
432    #[test]
433    fn test_missing_file_error() {
434        let result = load_json_from_file(Path::new("/nonexistent/path/routes.json"));
435        assert!(result.is_err());
436        let err = result.err().unwrap().to_string();
437        assert!(
438            err.contains("Failed to read"),
439            "expected file read error, got: {err}"
440        );
441    }
442
443    #[test]
444    fn test_function_step_json_parses_and_compiles() {
445        let json = r#"
446        {
447            "routes": [
448                {
449                    "id": "fn-json-route",
450                    "from": "direct:start",
451                    "steps": [
452                        {
453                            "function": {
454                                "runtime": "deno",
455                                "source": "return { body: 'ok' };",
456                                "timeout_ms": 3000
457                            }
458                        }
459                    ]
460                }
461            ]
462        }"#;
463        let defs = parse_json(json).unwrap();
464        assert_eq!(defs.len(), 1);
465        assert_eq!(defs[0].route_id(), "fn-json-route");
466    }
467
468    #[test]
469    fn test_function_step_json_compiles_to_declarative_function() {
470        let json = r#"
471        {
472            "routes": [
473                {
474                    "id": "fn-decl",
475                    "from": "direct:start",
476                    "steps": [
477                        {
478                            "function": {
479                                "runtime": "deno",
480                                "source": "return { body: 1 };"
481                            }
482                        }
483                    ]
484                }
485            ]
486        }"#;
487        let routes = parse_json_to_declarative(json).unwrap();
488        match &routes[0].steps[0] {
489            DeclarativeStep::Function(def) => {
490                assert_eq!(def.runtime, "deno");
491                assert_eq!(def.source, "return { body: 1 };");
492                assert_eq!(def.timeout_ms, None);
493            }
494            other => panic!("expected Function, got {other:?}"),
495        }
496    }
497
498    #[test]
499    fn test_function_step_rejected_by_canonical_json() {
500        let json = r#"
501        {
502            "routes": [
503                {
504                    "id": "fn-canonical-reject",
505                    "from": "direct:start",
506                    "steps": [
507                        {
508                            "function": {
509                                "runtime": "deno",
510                                "source": "return {};",
511                                "timeout_ms": 1000
512                            }
513                        }
514                    ]
515                }
516            ]
517        }"#;
518        let err = parse_json_to_canonical(json).unwrap_err().to_string();
519        assert!(
520            err.contains("canonical v1 does not support step `function`"),
521            "unexpected error: {err}"
522        );
523    }
524
525    #[test]
526    fn test_set_property_step_json_parses_to_declarative() {
527        let json = r#"
528        {
529            "routes": [
530                {
531                    "id": "property-json-route",
532                    "from": "direct:start",
533                    "steps": [
534                        {"set_property": {"name": "traceId", "value": "abc-123"}}
535                    ]
536                }
537            ]
538        }"#;
539        let routes = parse_json_to_declarative(json).unwrap();
540        match &routes[0].steps[0] {
541            DeclarativeStep::SetProperty(def) => {
542                assert_eq!(def.key, "traceId");
543                assert_eq!(
544                    def.value,
545                    crate::model::ValueSourceDef::Literal(serde_json::Value::String(
546                        "abc-123".into()
547                    ))
548                );
549            }
550            other => panic!("expected SetProperty, got {other:?}"),
551        }
552    }
553
554    #[test]
555    fn dollar_schema_key_is_accepted() {
556        let json = r#"{
557            "$schema": "https://example.com/route-schema.json",
558            "routes": [{"id": "r1", "from": "direct:start", "steps": [{"to": "direct:end"}]}]
559        }"#;
560        let parsed = parse_json_to_declarative(json).expect("$schema key must be accepted");
561        assert_eq!(parsed.len(), 1);
562    }
563
564    #[test]
565    fn parse_json_to_canonical_rejects_security_policy() {
566        let json = r#"{
567            "routes": [{
568                "id": "r-sec",
569                "from": "direct:start",
570                "security_policy": { "roles": ["admin"] },
571                "steps": [{ "to": "log:info" }]
572            }]
573        }"#;
574        let result = parse_json_to_canonical(json);
575        assert!(result.is_err());
576    }
577
578    #[test]
579    fn semantic_error_carries_json_format_prefix() {
580        let json = r#"{"routes": [{"id": "", "from": "timer:tick"}]}"#;
581        let result = parse_json_to_declarative(json);
582        assert!(result.is_err());
583        let err = result.unwrap_err().to_string();
584        assert!(
585            err.contains("JSON DSL error:"),
586            "expected 'JSON DSL error:' prefix, got: {err}"
587        );
588        assert!(
589            err.contains("route 'id' must not be empty"),
590            "expected underlying semantic message, got: {err}"
591        );
592    }
593
594    #[test]
595    fn json_parse_error_carries_format_prefix() {
596        let json = "{ not valid json }}}";
597        let result = parse_json_to_declarative(json);
598        assert!(result.is_err());
599        let err = result.unwrap_err().to_string();
600        assert!(
601            err.contains("JSON DSL error: JSON parse error:"),
602            "expected double prefix for parse errors, got: {err}"
603        );
604    }
605
606    /// Review I2: the JSON authoring path must lower `rest:` blocks, not
607    /// silently drop them. JSON is a full-DSL authoring format (ADR-0026),
608    /// so a `rest:` block expands exactly like the YAML path.
609    #[test]
610    fn json_rest_block_expands_into_routes() {
611        let json = r#"{
612            "rest": [
613                {
614                    "host": "0.0.0.0",
615                    "port": 8080,
616                    "path": "/users",
617                    "operations": [
618                        { "method": "GET", "path": "/{id}", "operation_id": "getUser", "to": "bean:svc" },
619                        { "method": "POST", "path": "/", "operation_id": "createUser", "to": "bean:create" }
620                    ]
621                }
622            ]
623        }"#;
624        let routes = parse_json_to_declarative(json).unwrap();
625        // Two operations → two lowered routes.
626        assert_eq!(routes.len(), 2);
627        let ids: Vec<&str> = routes.iter().map(|r| r.route_id.as_str()).collect();
628        assert!(ids.contains(&"getUser"));
629        assert!(ids.contains(&"createUser"));
630        // Each route's from-URI carries the templated path + httpMethod.
631        let get_route = routes.iter().find(|r| r.route_id == "getUser").unwrap();
632        assert!(get_route.from.contains("/users/{id}"));
633        assert!(get_route.from.contains("httpMethod=GET"));
634    }
635
636    /// Review I2 + C3: the JSON path runs the same cross-block validation
637    /// as YAML — duplicate (method, path) tuples must be rejected.
638    #[test]
639    fn json_rest_rejects_duplicate_method_path() {
640        let json = r#"{
641            "rest": [
642                {
643                    "path": "/users",
644                    "operations": [
645                        { "method": "GET", "path": "/{id}", "operation_id": "a", "to": "bean:x" }
646                    ]
647                },
648                {
649                    "path": "/users",
650                    "operations": [
651                        { "method": "GET", "path": "/{id}", "operation_id": "b", "to": "bean:y" }
652                    ]
653                }
654            ]
655        }"#;
656        let result = parse_json_to_declarative(json);
657        assert!(
658            result.is_err(),
659            "JSON rest must reject duplicate (method, path)"
660        );
661    }
662
663    #[test]
664    fn load_json_from_file_rejects_oversized_file() {
665        let dir = tempfile::tempdir().unwrap();
666        let path = dir.path().join("oversized.json");
667        std::fs::write(&path, "{}").unwrap();
668        // Extend the file past the 16 MiB cap (sparse file, no actual disk usage)
669        let file = File::create(&path).unwrap();
670        file.set_len(16 * 1024 * 1024 + 1).unwrap();
671        drop(file);
672
673        let err = match load_json_from_file(&path) {
674            Ok(_) => panic!("expected oversized file error"),
675            Err(e) => e,
676        };
677        assert!(
678            err.to_string().contains("exceeds max"),
679            "expected size cap error, got: {err}"
680        );
681    }
682}