1use 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
20pub struct CompiledMaterializationResult {
22 pub route_def: RouteDefinition,
24 pub source_hash: Option<u64>,
26}
27
28pub 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 for key in provided.keys() {
44 if !declared_set.contains(key.as_str()) {
45 return Err(TemplateError::UnknownParameter(key.clone()));
46 }
47 }
48
49 let mut resolved = BTreeMap::new();
51 for param in &template.parameters {
52 if let Some(value) = provided.get(¶m.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
64pub 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 other => Ok(other),
104 }
105}
106
107fn 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
115pub 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 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 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 let wrapped = serde_json::json!({
161 "routes": substituted_routes
162 });
163
164 let json_str = serde_json::to_string(&wrapped).map_err(|e| {
166 CamelError::RouteError(format!("failed to serialize materialized template: {e}"))
167 })?;
168
169 let mut routes = parse_json_to_declarative(&json_str)?;
171
172 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
182pub 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 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 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 #[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 #[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}