Skip to main content

camel_dsl/template/
materializer.rs

1//! Template materializer — resolves parameters, substitutes placeholders in JSON,
2//! and compiles the resulting declarative routes.
3//!
4//! This module bridges the template system (Phase 1) with the DSL compiler,
5//! enabling runtime instantiation of parameterized route templates.
6
7use std::collections::BTreeMap;
8use std::collections::hash_map::DefaultHasher;
9use std::hash::{Hash, Hasher};
10
11use camel_api::CamelError;
12use camel_api::template::{RouteTemplateSpec, TemplateError, TemplatedRouteSpec};
13use camel_core::route::RouteDefinition;
14
15use crate::compile::compile_declarative_route;
16use crate::json::parse_json_to_declarative;
17use crate::model::DeclarativeRoute;
18use crate::template::placeholder::substitute_placeholders;
19
20/// Result of compiling a materialized template instance.
21pub struct CompiledMaterializationResult {
22    /// The compiled route definition.
23    pub route_def: RouteDefinition,
24    /// Optional hash of the source template body for hot-reload detection.
25    pub source_hash: Option<u64>,
26}
27
28/// Resolve template parameters by merging declared defaults with provided values.
29///
30/// - Parameters supplied in `provided` override defaults.
31/// - Parameters not supplied but with a `default_value` use the default.
32/// - Parameters not supplied and without a default produce [`TemplateError::MissingParameter`].
33/// - Parameters supplied but not declared produce [`TemplateError::UnknownParameter`].
34pub fn resolve_params(
35    template: &RouteTemplateSpec,
36    provided: &BTreeMap<String, String>,
37) -> Result<BTreeMap<String, String>, TemplateError> {
38    let declared_names: Vec<String> = template.parameters.iter().map(|p| p.name.clone()).collect();
39    let declared_set: std::collections::HashSet<&str> =
40        declared_names.iter().map(|s| s.as_str()).collect();
41
42    // Validate that all supplied values correspond to declared parameters.
43    for key in provided.keys() {
44        if !declared_set.contains(key.as_str()) {
45            return Err(TemplateError::UnknownParameter(key.clone()));
46        }
47    }
48
49    // Build resolved map: provided value > default value > error if required and missing.
50    let mut resolved = BTreeMap::new();
51    for param in &template.parameters {
52        if let Some(value) = provided.get(&param.name) {
53            resolved.insert(param.name.clone(), value.clone());
54        } else if let Some(ref default) = param.default_value {
55            resolved.insert(param.name.clone(), default.clone());
56        } else {
57            return Err(TemplateError::MissingParameter(param.name.clone()));
58        }
59    }
60
61    Ok(resolved)
62}
63
64/// Recursively walk a [`serde_json::Value`] and substitute `{{name}}` placeholders
65/// in every string using the resolved parameter values.
66pub fn substitute_strings_in_json(
67    value: serde_json::Value,
68    resolved: &BTreeMap<String, String>,
69    declared_params: &[String],
70) -> Result<serde_json::Value, TemplateError> {
71    substitute_json_value(value, resolved, declared_params)
72}
73
74fn substitute_json_value(
75    value: serde_json::Value,
76    resolved: &BTreeMap<String, String>,
77    declared_params: &[String],
78) -> Result<serde_json::Value, TemplateError> {
79    match value {
80        serde_json::Value::String(s) => {
81            let substituted = substitute_placeholders(&s, resolved, declared_params)?;
82            Ok(serde_json::Value::String(substituted))
83        }
84        serde_json::Value::Object(map) => {
85            let new_map: serde_json::Map<String, serde_json::Value> = map
86                .into_iter()
87                .map(|(k, v)| {
88                    substitute_json_value(v, resolved, declared_params).map(|new_v| (k, new_v))
89                })
90                .collect::<Result<Vec<_>, _>>()?
91                .into_iter()
92                .collect();
93            Ok(serde_json::Value::Object(new_map))
94        }
95        serde_json::Value::Array(arr) => {
96            let new_arr: Vec<serde_json::Value> = arr
97                .into_iter()
98                .map(|v| substitute_json_value(v, resolved, declared_params))
99                .collect::<Result<Vec<_>, _>>()?;
100            Ok(serde_json::Value::Array(new_arr))
101        }
102        // Numbers, booleans, null — no substitution needed.
103        other => Ok(other),
104    }
105}
106
107/// Compute a hash of the template's route body for source-change detection.
108fn compute_source_hash(routes: &[serde_json::Value]) -> u64 {
109    let json_str = serde_json::to_string(routes).unwrap_or_default();
110    let mut hasher = DefaultHasher::new();
111    json_str.hash(&mut hasher);
112    hasher.finish()
113}
114
115/// Main entry point: instantiate a template with concrete parameters and
116/// return compiled declarative routes.
117///
118/// Steps:
119/// 1. Resolve parameters (defaults + provided values).
120/// 2. Substitute placeholders in the template's JSON body.
121/// 3. Wrap the substituted body in `{ "routes": [substituted] }`.
122/// 4. Parse via [`parse_json_to_declarative`].
123/// 5. Return the resulting [`Vec<DeclarativeRoute>`].
124pub fn materialize_template(
125    template: &RouteTemplateSpec,
126    templated: &TemplatedRouteSpec,
127) -> Result<Vec<DeclarativeRoute>, CamelError> {
128    if template.routes.is_empty() {
129        return Err(CamelError::Config(
130            TemplateError::InvalidBody("template has empty routes array".to_string()).to_string(),
131        ));
132    }
133
134    if templated.route_id.is_some() && template.routes.len() > 1 {
135        return Err(CamelError::Config(
136            TemplateError::InvalidBody(
137                "route_id override not allowed for multi-route template".to_string(),
138            )
139            .to_string(),
140        ));
141    }
142
143    // Step 1: resolve parameters.
144    let resolved = resolve_params(template, &templated.parameters)
145        .map_err(|e| CamelError::Config(e.to_string()))?;
146
147    let declared_names: Vec<String> = template.parameters.iter().map(|p| p.name.clone()).collect();
148
149    // Step 2: substitute placeholders in each route body.
150    let substituted_routes: Vec<serde_json::Value> = template
151        .routes
152        .iter()
153        .map(|r| {
154            substitute_strings_in_json(r.clone(), &resolved, &declared_names)
155                .map_err(|e| CamelError::Config(e.to_string()))
156        })
157        .collect::<Result<Vec<_>, _>>()?;
158
159    // Step 3: wrap in `{ "routes": [substituted...] }`.
160    let wrapped = serde_json::json!({
161        "routes": substituted_routes
162    });
163
164    // Step 4: serialize to string.
165    let json_str = serde_json::to_string(&wrapped).map_err(|e| {
166        CamelError::RouteError(format!("failed to serialize materialized template: {e}"))
167    })?;
168
169    // Step 5: parse to declarative routes.
170    let mut routes = parse_json_to_declarative(&json_str)?;
171
172    // Step 6: apply explicit route_id override from the instantiation.
173    if let Some(override_id) = &templated.route_id {
174        for route in &mut routes {
175            route.route_id = override_id.clone();
176        }
177    }
178
179    Ok(routes)
180}
181
182/// Helper used by discovery: materialize a template and compile each resulting
183/// declarative route into a [`RouteDefinition`].
184///
185/// Returns a vector of [`CompiledMaterializationResult`], one per route produced
186/// by the template, each carrying the compiled definition and the source hash.
187pub fn materialize_and_compile(
188    template: &RouteTemplateSpec,
189    templated: &TemplatedRouteSpec,
190) -> Result<Vec<CompiledMaterializationResult>, CamelError> {
191    let source_hash = compute_source_hash(&template.routes);
192    let declarative_routes = materialize_template(template, templated)?;
193
194    declarative_routes
195        .into_iter()
196        .map(|route| {
197            let route_def = compile_declarative_route(route)?;
198            Ok(CompiledMaterializationResult {
199                route_def,
200                source_hash: Some(source_hash),
201            })
202        })
203        .collect()
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use camel_api::template::TemplateParameterSpec;
210
211    // --- resolve_params tests ---
212
213    fn make_template(
214        id: &str,
215        params: Vec<TemplateParameterSpec>,
216        routes: Vec<serde_json::Value>,
217    ) -> RouteTemplateSpec {
218        RouteTemplateSpec {
219            id: id.into(),
220            parameters: params,
221            routes,
222        }
223    }
224
225    fn make_templated(template_ref: &str, params: BTreeMap<String, String>) -> TemplatedRouteSpec {
226        TemplatedRouteSpec {
227            route_template_ref: template_ref.into(),
228            route_id: None,
229            parameters: params,
230        }
231    }
232
233    #[test]
234    fn resolve_params_with_all_provided() {
235        let template = make_template(
236            "test",
237            vec![
238                TemplateParameterSpec {
239                    name: "host".into(),
240                    default_value: None,
241                    description: None,
242                },
243                TemplateParameterSpec {
244                    name: "port".into(),
245                    default_value: None,
246                    description: None,
247                },
248            ],
249            vec![serde_json::json!({})],
250        );
251        let provided = [
252            ("host".into(), "localhost".into()),
253            ("port".into(), "8080".into()),
254        ]
255        .into_iter()
256        .collect();
257        let resolved = resolve_params(&template, &provided).unwrap();
258        assert_eq!(resolved["host"], "localhost");
259        assert_eq!(resolved["port"], "8080");
260    }
261
262    #[test]
263    fn resolve_params_uses_defaults() {
264        let template = make_template(
265            "test",
266            vec![
267                TemplateParameterSpec {
268                    name: "host".into(),
269                    default_value: Some("localhost".into()),
270                    description: None,
271                },
272                TemplateParameterSpec {
273                    name: "port".into(),
274                    default_value: Some("8080".into()),
275                    description: None,
276                },
277            ],
278            vec![serde_json::json!({})],
279        );
280        let provided = BTreeMap::new();
281        let resolved = resolve_params(&template, &provided).unwrap();
282        assert_eq!(resolved["host"], "localhost");
283        assert_eq!(resolved["port"], "8080");
284    }
285
286    #[test]
287    fn resolve_params_provided_overrides_default() {
288        let template = make_template(
289            "test",
290            vec![TemplateParameterSpec {
291                name: "host".into(),
292                default_value: Some("localhost".into()),
293                description: None,
294            }],
295            vec![serde_json::json!({})],
296        );
297        let provided = [("host".into(), "example.com".into())]
298            .into_iter()
299            .collect();
300        let resolved = resolve_params(&template, &provided).unwrap();
301        assert_eq!(resolved["host"], "example.com");
302    }
303
304    #[test]
305    fn resolve_params_missing_required() {
306        let template = make_template(
307            "test",
308            vec![TemplateParameterSpec {
309                name: "host".into(),
310                default_value: None,
311                description: None,
312            }],
313            vec![serde_json::json!({})],
314        );
315        let provided = BTreeMap::new();
316        let err = resolve_params(&template, &provided).unwrap_err();
317        assert!(matches!(err, TemplateError::MissingParameter(ref n) if n == "host"));
318    }
319
320    #[test]
321    fn resolve_params_unknown_parameter() {
322        let template = make_template(
323            "test",
324            vec![TemplateParameterSpec {
325                name: "host".into(),
326                default_value: None,
327                description: None,
328            }],
329            vec![serde_json::json!({})],
330        );
331        let mut provided = BTreeMap::new();
332        provided.insert("host".into(), "localhost".into());
333        provided.insert("unknown".into(), "val".into());
334        let err = resolve_params(&template, &provided).unwrap_err();
335        assert!(matches!(err, TemplateError::UnknownParameter(ref n) if n == "unknown"));
336    }
337
338    #[test]
339    fn resolve_params_mixed_provided_and_default() {
340        let template = make_template(
341            "test",
342            vec![
343                TemplateParameterSpec {
344                    name: "host".into(),
345                    default_value: Some("localhost".into()),
346                    description: None,
347                },
348                TemplateParameterSpec {
349                    name: "port".into(),
350                    default_value: None,
351                    description: None,
352                },
353                TemplateParameterSpec {
354                    name: "protocol".into(),
355                    default_value: Some("http".into()),
356                    description: None,
357                },
358            ],
359            vec![serde_json::json!({})],
360        );
361        let provided = [("port".into(), "9090".into())].into_iter().collect();
362        let resolved = resolve_params(&template, &provided).unwrap();
363        assert_eq!(resolved["host"], "localhost");
364        assert_eq!(resolved["port"], "9090");
365        assert_eq!(resolved["protocol"], "http");
366    }
367
368    // --- substitute_strings_in_json tests ---
369
370    fn declared(names: &[&str]) -> Vec<String> {
371        names.iter().map(|s| s.to_string()).collect()
372    }
373
374    fn resolved(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
375        pairs
376            .iter()
377            .map(|(k, v)| (k.to_string(), v.to_string()))
378            .collect()
379    }
380
381    #[test]
382    fn substitute_in_string_value() {
383        let value = serde_json::Value::String("http://{{host}}:{{port}}".into());
384        let result = substitute_strings_in_json(
385            value,
386            &resolved(&[("host", "localhost"), ("port", "8080")]),
387            &declared(&["host", "port"]),
388        )
389        .unwrap();
390        assert_eq!(
391            result,
392            serde_json::Value::String("http://localhost:8080".into())
393        );
394    }
395
396    #[test]
397    fn substitute_in_object_values() {
398        let value = serde_json::json!({
399            "from": "timer:{{period}}",
400            "steps": [{"to": "log:{{level}}"}]
401        });
402        let result = substitute_strings_in_json(
403            value,
404            &resolved(&[("period", "5s"), ("level", "info")]),
405            &declared(&["period", "level"]),
406        )
407        .unwrap();
408        assert_eq!(result["from"], "timer:5s");
409        assert_eq!(result["steps"][0]["to"], "log:info");
410    }
411
412    #[test]
413    fn substitute_in_array_elements() {
414        let value = serde_json::json!(["http://{{host}}/a", "http://{{host}}/b"]);
415        let result = substitute_strings_in_json(
416            value,
417            &resolved(&[("host", "example.com")]),
418            &declared(&["host"]),
419        )
420        .unwrap();
421        assert_eq!(result[0], "http://example.com/a");
422        assert_eq!(result[1], "http://example.com/b");
423    }
424
425    #[test]
426    fn non_string_values_unchanged() {
427        let value = serde_json::json!({
428            "count": 42,
429            "enabled": true,
430            "nothing": null
431        });
432        let result = substitute_strings_in_json(value, &BTreeMap::new(), &declared(&[])).unwrap();
433        assert_eq!(result["count"], 42);
434        assert_eq!(result["enabled"], true);
435        assert_eq!(result["nothing"], serde_json::Value::Null);
436    }
437
438    #[test]
439    fn deeply_nested_substitution() {
440        let value = serde_json::json!({
441            "outer": {
442                "inner": {
443                    "uri": "{{endpoint}}"
444                }
445            }
446        });
447        let result = substitute_strings_in_json(
448            value,
449            &resolved(&[("endpoint", "direct:target")]),
450            &declared(&["endpoint"]),
451        )
452        .unwrap();
453        assert_eq!(result["outer"]["inner"]["uri"], "direct:target");
454    }
455
456    // --- materialize_template tests ---
457
458    #[test]
459    fn materialize_simple_template() {
460        let template = make_template(
461            "http-route",
462            vec![TemplateParameterSpec {
463                name: "path".into(),
464                default_value: None,
465                description: None,
466            }],
467            vec![serde_json::json!({
468                "id": "my-http-route",
469                "from": "rest:{{path}}",
470                "steps": [{"to": "log:info"}]
471            })],
472        );
473        let templated = make_templated(
474            "http-route",
475            [("path".into(), "/api/users".into())].into_iter().collect(),
476        );
477        let routes = materialize_template(&template, &templated).unwrap();
478        assert_eq!(routes.len(), 1);
479        assert_eq!(routes[0].route_id, "my-http-route");
480        assert_eq!(routes[0].from, "rest:/api/users");
481    }
482
483    #[test]
484    fn materialize_with_default_params() {
485        let template = make_template(
486            "timer-route",
487            vec![TemplateParameterSpec {
488                name: "period".into(),
489                default_value: Some("1000".into()),
490                description: None,
491            }],
492            vec![serde_json::json!({
493                "id": "timer-route",
494                "from": "timer:tick?period={{period}}",
495                "steps": []
496            })],
497        );
498        let templated = make_templated("timer-route", BTreeMap::new());
499        let routes = materialize_template(&template, &templated).unwrap();
500        assert_eq!(routes.len(), 1);
501        assert_eq!(routes[0].from, "timer:tick?period=1000");
502    }
503
504    #[test]
505    fn materialize_missing_required_param() {
506        let template = make_template(
507            "test",
508            vec![TemplateParameterSpec {
509                name: "host".into(),
510                default_value: None,
511                description: None,
512            }],
513            vec![serde_json::json!({})],
514        );
515        let templated = make_templated("test", BTreeMap::new());
516        let err = materialize_template(&template, &templated).unwrap_err();
517        assert!(err.to_string().contains("missing required parameter"));
518    }
519
520    #[test]
521    fn materialize_unknown_param() {
522        let template = make_template(
523            "test",
524            vec![TemplateParameterSpec {
525                name: "host".into(),
526                default_value: None,
527                description: None,
528            }],
529            vec![serde_json::json!({})],
530        );
531        let mut params = BTreeMap::new();
532        params.insert("bogus".into(), "val".into());
533        let templated = make_templated("test", params);
534        let err = materialize_template(&template, &templated).unwrap_err();
535        assert!(err.to_string().contains("unknown parameter"));
536    }
537
538    // --- materialize_and_compile tests ---
539
540    #[test]
541    fn compile_materialized_route() {
542        let template = make_template(
543            "compile-test",
544            vec![TemplateParameterSpec {
545                name: "uri".into(),
546                default_value: None,
547                description: None,
548            }],
549            vec![serde_json::json!({
550                "id": "compiled-route",
551                "from": "{{uri}}",
552                "steps": [{"to": "log:info"}]
553            })],
554        );
555        let templated = make_templated(
556            "compile-test",
557            [("uri".into(), "timer:tick?period=500".into())]
558                .into_iter()
559                .collect(),
560        );
561        let results = materialize_and_compile(&template, &templated).unwrap();
562        assert_eq!(results.len(), 1);
563        assert_eq!(results[0].route_def.route_id(), "compiled-route");
564        assert_eq!(results[0].route_def.from_uri(), "timer:tick?period=500");
565        assert!(results[0].source_hash.is_some());
566    }
567
568    #[test]
569    fn source_hash_is_deterministic() {
570        let template = make_template(
571            "hash-test",
572            vec![],
573            vec![serde_json::json!({
574                "id": "hash-route",
575                "from": "timer:tick",
576                "steps": []
577            })],
578        );
579        let templated = make_templated("hash-test", BTreeMap::new());
580        let results1 = materialize_and_compile(&template, &templated).unwrap();
581        let results2 = materialize_and_compile(&template, &templated).unwrap();
582        assert_eq!(results1[0].source_hash, results2[0].source_hash);
583    }
584
585    #[test]
586    fn source_hash_differs_for_different_templates() {
587        let template_a = make_template(
588            "a",
589            vec![],
590            vec![serde_json::json!({
591                "id": "route-a",
592                "from": "timer:tick",
593                "steps": []
594            })],
595        );
596        let template_b = make_template(
597            "b",
598            vec![],
599            vec![serde_json::json!({
600                "id": "route-b",
601                "from": "timer:tock",
602                "steps": []
603            })],
604        );
605        let templated = make_templated("a", BTreeMap::new());
606        let results_a = materialize_and_compile(&template_a, &templated).unwrap();
607        let results_b = materialize_and_compile(&template_b, &templated).unwrap();
608        assert_ne!(results_a[0].source_hash, results_b[0].source_hash);
609    }
610
611    #[test]
612    fn materialize_multi_route_template() {
613        let template = make_template(
614            "chain",
615            vec![TemplateParameterSpec {
616                name: "PROV".into(),
617                default_value: None,
618                description: None,
619            }],
620            vec![
621                serde_json::json!({
622                    "id": "step1-{{PROV}}",
623                    "from": "direct:start",
624                    "steps": [{"to": "controlbus:route?routeId=step2-{{PROV}}&action=start"}]
625                }),
626                serde_json::json!({
627                    "id": "step2-{{PROV}}",
628                    "from": "direct:step2",
629                    "steps": [{"to": "log:done"}]
630                }),
631            ],
632        );
633        let templated = make_templated(
634            "chain",
635            [("PROV".into(), "granada".into())].into_iter().collect(),
636        );
637        let routes = materialize_template(&template, &templated).unwrap();
638        assert_eq!(routes.len(), 2);
639        assert_eq!(routes[0].route_id, "step1-granada");
640        assert_eq!(routes[1].route_id, "step2-granada");
641    }
642
643    #[test]
644    fn materialize_multi_route_rejects_route_id_override() {
645        let template = make_template(
646            "chain",
647            vec![],
648            vec![
649                serde_json::json!({"id": "r1", "from": "direct:a", "steps": []}),
650                serde_json::json!({"id": "r2", "from": "direct:b", "steps": []}),
651            ],
652        );
653        let templated = TemplatedRouteSpec {
654            route_template_ref: "chain".into(),
655            route_id: Some("override-id".into()),
656            parameters: BTreeMap::new(),
657        };
658        let err = materialize_template(&template, &templated).unwrap_err();
659        assert!(err.to_string().contains("route_id override not allowed"));
660    }
661
662    #[test]
663    fn materialize_empty_routes_returns_error() {
664        let template = make_template("empty", vec![], vec![]);
665        let templated = make_templated("empty", BTreeMap::new());
666        let err = materialize_template(&template, &templated).unwrap_err();
667        assert!(err.to_string().contains("empty routes array"));
668    }
669
670    #[test]
671    fn source_hash_covers_multi_route_array() {
672        let template = make_template(
673            "multi-hash",
674            vec![],
675            vec![
676                serde_json::json!({"id": "r1", "from": "direct:a", "steps": []}),
677                serde_json::json!({"id": "r2", "from": "direct:b", "steps": []}),
678            ],
679        );
680        let templated = make_templated("multi-hash", BTreeMap::new());
681        let results = materialize_and_compile(&template, &templated).unwrap();
682        assert_eq!(results.len(), 2);
683        let expected_hash = compute_source_hash(&template.routes);
684        assert_eq!(results[0].source_hash, Some(expected_hash));
685        assert_eq!(results[1].source_hash, Some(expected_hash));
686    }
687}