drasi_lib/reactions/common/templates.rs
1// Copyright 2025 The Drasi Authors.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Common template configuration types for reactions.
16//!
17//! This module provides shared template structures that can be used across
18//! different reaction implementations (SSE, Logger, StoredProc, etc.) to support
19//! Handlebars template syntax for formatting outputs based on operation types.
20
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23
24/// Specification for template-based output.
25///
26/// This type is used to configure templates for different operation types (added, updated, deleted).
27/// Template fields support Handlebars template syntax for dynamic content generation.
28///
29/// # Type Parameter
30///
31/// - `T` - Optional extension type for reaction-specific fields. Use `()` (default) if no extensions needed.
32///
33/// # Template Variables
34///
35/// Templates have access to the following variables:
36/// - `after` - The data after the change (available for ADD and UPDATE)
37/// - `before` - The data before the change (available for UPDATE and DELETE)
38/// - `data` - The raw data field (available for UPDATE)
39/// - `query_name` - The name of the query that produced the result
40/// - `operation` - The operation type ("ADD", "UPDATE", or "DELETE")
41/// - `timestamp` - The timestamp of the event (if available)
42///
43/// # Example (Basic)
44///
45/// ```rust
46/// use drasi_lib::reactions::common::TemplateSpec;
47///
48/// let spec: TemplateSpec = TemplateSpec {
49/// template: "[NEW] ID: {{after.id}}, Name: {{after.name}}".to_string(),
50/// extension: (),
51/// };
52/// ```
53///
54/// # Example (With Extensions)
55///
56/// ```rust
57/// use drasi_lib::reactions::common::TemplateSpec;
58/// use serde::{Deserialize, Serialize};
59///
60/// #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
61/// struct MyExtension {
62/// custom_field: String,
63/// }
64///
65/// let spec: TemplateSpec<MyExtension> = TemplateSpec {
66/// template: "[NEW] {{after.id}}".to_string(),
67/// extension: MyExtension {
68/// custom_field: "value".to_string(),
69/// },
70/// };
71/// ```
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73#[serde(bound(deserialize = "T: Deserialize<'de> + Default"))]
74pub struct TemplateSpec<T = ()>
75where
76 T: Default,
77{
78 /// Output template as a Handlebars template.
79 /// If empty, the reaction may use a default format (typically raw JSON).
80 #[serde(default)]
81 pub template: String,
82
83 /// Extension data for reaction-specific fields.
84 /// This is flattened in the JSON representation, so extension fields appear at the same level as `template`.
85 #[serde(flatten, default)]
86 pub extension: T,
87}
88
89impl TemplateSpec {
90 /// Create a new TemplateSpec with no extensions (for reactions that don't need custom fields).
91 ///
92 /// # Example
93 ///
94 /// ```rust
95 /// use drasi_lib::reactions::common::TemplateSpec;
96 ///
97 /// let spec = TemplateSpec::new("{{after.id}}");
98 /// ```
99 pub fn new(template: impl Into<String>) -> Self {
100 Self {
101 template: template.into(),
102 extension: (),
103 }
104 }
105}
106
107impl<T: Default> TemplateSpec<T> {
108 /// Create a new TemplateSpec with a custom extension.
109 ///
110 /// # Example
111 ///
112 /// ```rust
113 /// use drasi_lib::reactions::common::TemplateSpec;
114 /// use serde::{Deserialize, Serialize};
115 ///
116 /// #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
117 /// struct MyExtension {
118 /// custom_field: String,
119 /// }
120 ///
121 /// let spec = TemplateSpec::with_extension(
122 /// "{{after.id}}",
123 /// MyExtension { custom_field: "value".to_string() }
124 /// );
125 /// ```
126 pub fn with_extension(template: impl Into<String>, extension: T) -> Self {
127 Self {
128 template: template.into(),
129 extension,
130 }
131 }
132}
133
134impl<T: Default> Default for TemplateSpec<T> {
135 /// Create a default TemplateSpec with empty template and default extension.
136 ///
137 /// This allows using the `..Default::default()` syntax:
138 ///
139 /// # Example
140 ///
141 /// ```rust
142 /// use drasi_lib::reactions::common::TemplateSpec;
143 ///
144 /// let spec: TemplateSpec<()> = TemplateSpec {
145 /// template: "{{after.id}}".to_string(),
146 /// ..Default::default()
147 /// };
148 /// ```
149 fn default() -> Self {
150 Self {
151 template: String::new(),
152 extension: T::default(),
153 }
154 }
155}
156
157/// Configuration for query-specific template-based output.
158///
159/// Defines different template specifications for each operation type (added, updated, deleted).
160/// Each operation type can have its own formatting template.
161///
162/// # Type Parameter
163///
164/// - `T` - Optional extension type for reaction-specific fields. Use `()` (default) if no extensions needed.
165///
166/// # Example
167///
168/// ```rust
169/// use drasi_lib::reactions::common::{QueryConfig, TemplateSpec};
170///
171/// let config: QueryConfig = QueryConfig {
172/// added: Some(TemplateSpec {
173/// template: "[ADD] {{after.id}}".to_string(),
174/// extension: (),
175/// }),
176/// updated: Some(TemplateSpec {
177/// template: "[UPD] {{after.id}}".to_string(),
178/// extension: (),
179/// }),
180/// deleted: Some(TemplateSpec {
181/// template: "[DEL] {{before.id}}".to_string(),
182/// extension: (),
183/// }),
184/// };
185/// ```
186///
187/// # Example (Omitting fields with Default)
188///
189/// ```rust
190/// use drasi_lib::reactions::common::{QueryConfig, TemplateSpec};
191///
192/// // Only specify the added operation, others will be None
193/// let config: QueryConfig = QueryConfig {
194/// added: Some(TemplateSpec::new("[ADD] {{after.id}}")),
195/// ..Default::default()
196/// };
197/// ```
198#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
199#[serde(bound(deserialize = "T: Deserialize<'de> + Default"))]
200pub struct QueryConfig<T = ()>
201where
202 T: Default,
203{
204 /// Template specification for ADD operations (new rows in query results).
205 #[serde(skip_serializing_if = "Option::is_none")]
206 pub added: Option<TemplateSpec<T>>,
207
208 /// Template specification for UPDATE operations (modified rows in query results).
209 #[serde(skip_serializing_if = "Option::is_none")]
210 pub updated: Option<TemplateSpec<T>>,
211
212 /// Template specification for DELETE operations (removed rows from query results).
213 #[serde(skip_serializing_if = "Option::is_none")]
214 pub deleted: Option<TemplateSpec<T>>,
215}
216
217impl<T: Default> Default for QueryConfig<T> {
218 fn default() -> Self {
219 Self {
220 added: None,
221 updated: None,
222 deleted: None,
223 }
224 }
225}
226
227/// Template routing configuration for reactions.
228///
229/// Provides a way to configure templates at two levels:
230/// 1. **Default template**: Applied to all queries unless overridden
231/// 2. **Per-query templates**: Override default for specific queries
232///
233/// This trait can be used by any reaction that needs template-based routing.
234///
235/// # Type Parameter
236///
237/// - `T` - Optional extension type for reaction-specific fields. Use `()` (default) if no extensions needed.
238///
239/// # Example
240///
241/// ```rust
242/// use std::collections::HashMap;
243/// use drasi_lib::reactions::common::{TemplateRouting, QueryConfig, TemplateSpec};
244///
245/// #[derive(Debug, Clone)]
246/// struct MyReactionConfig {
247/// routes: HashMap<String, QueryConfig>,
248/// default_template: Option<QueryConfig>,
249/// }
250///
251/// impl TemplateRouting for MyReactionConfig {
252/// fn routes(&self) -> &HashMap<String, QueryConfig> {
253/// &self.routes
254/// }
255///
256/// fn default_template(&self) -> Option<&QueryConfig> {
257/// self.default_template.as_ref()
258/// }
259/// }
260/// ```
261pub trait TemplateRouting<T = ()>
262where
263 T: Default,
264{
265 /// Get the query-specific template routes
266 fn routes(&self) -> &HashMap<String, QueryConfig<T>>;
267
268 /// Get the default template configuration
269 fn default_template(&self) -> Option<&QueryConfig<T>>;
270
271 /// Get the template spec for a specific query and operation type
272 fn get_template_spec(
273 &self,
274 query_id: &str,
275 operation: OperationType,
276 ) -> Option<&TemplateSpec<T>> {
277 // First check query-specific routes
278 if let Some(query_config) = self.routes().get(query_id) {
279 if let Some(spec) = Self::get_spec_from_config(query_config, operation) {
280 return Some(spec);
281 }
282 }
283
284 // Fall back to default template
285 if let Some(default_config) = self.default_template() {
286 return Self::get_spec_from_config(default_config, operation);
287 }
288
289 None
290 }
291
292 /// Helper to get spec from a QueryConfig based on operation type
293 fn get_spec_from_config(
294 config: &QueryConfig<T>,
295 operation: OperationType,
296 ) -> Option<&TemplateSpec<T>> {
297 match operation {
298 OperationType::Add => config.added.as_ref(),
299 OperationType::Update => config.updated.as_ref(),
300 OperationType::Delete => config.deleted.as_ref(),
301 }
302 }
303}
304
305/// Operation type for query results
306#[derive(Debug, Clone, Copy, PartialEq, Eq)]
307pub enum OperationType {
308 /// Add operation (new row)
309 Add,
310 /// Update operation (modified row)
311 Update,
312 /// Delete operation (removed row)
313 Delete,
314}
315
316impl std::str::FromStr for OperationType {
317 type Err = String;
318
319 fn from_str(s: &str) -> Result<Self, Self::Err> {
320 match s.to_lowercase().as_str() {
321 "add" => Ok(Self::Add),
322 "update" => Ok(Self::Update),
323 "delete" => Ok(Self::Delete),
324 _ => Err(format!("Invalid operation type: {s}")),
325 }
326 }
327}
328
329impl OperationType {
330 /// Convert to string representation
331 pub fn as_str(&self) -> &'static str {
332 match self {
333 Self::Add => "add",
334 Self::Update => "update",
335 Self::Delete => "delete",
336 }
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 #[test]
345 fn test_operation_type_from_str() {
346 use std::str::FromStr;
347 assert_eq!(OperationType::from_str("add"), Ok(OperationType::Add));
348 assert_eq!(OperationType::from_str("ADD"), Ok(OperationType::Add));
349 assert_eq!(OperationType::from_str("update"), Ok(OperationType::Update));
350 assert_eq!(OperationType::from_str("UPDATE"), Ok(OperationType::Update));
351 assert_eq!(OperationType::from_str("delete"), Ok(OperationType::Delete));
352 assert_eq!(OperationType::from_str("DELETE"), Ok(OperationType::Delete));
353 assert!(OperationType::from_str("invalid").is_err());
354 }
355
356 #[test]
357 fn test_operation_type_as_str() {
358 assert_eq!(OperationType::Add.as_str(), "add");
359 assert_eq!(OperationType::Update.as_str(), "update");
360 assert_eq!(OperationType::Delete.as_str(), "delete");
361 }
362
363 #[test]
364 fn test_template_spec_serialization() {
365 let spec: TemplateSpec = TemplateSpec {
366 template: "{{after.id}}".to_string(),
367 extension: (),
368 };
369
370 let json = serde_json::to_string(&spec).unwrap();
371 let deserialized: TemplateSpec = serde_json::from_str(&json).unwrap();
372 assert_eq!(spec, deserialized);
373 }
374
375 #[test]
376 fn test_query_config_serialization() {
377 let config: QueryConfig = QueryConfig {
378 added: Some(TemplateSpec {
379 template: "[ADD] {{after.id}}".to_string(),
380 extension: (),
381 }),
382 updated: None,
383 deleted: Some(TemplateSpec {
384 template: "[DEL] {{before.id}}".to_string(),
385 extension: (),
386 }),
387 };
388
389 let json = serde_json::to_string(&config).unwrap();
390 let deserialized: QueryConfig = serde_json::from_str(&json).unwrap();
391 assert_eq!(config, deserialized);
392 }
393
394 #[derive(Debug, Clone)]
395 struct TestReactionConfig {
396 routes: HashMap<String, QueryConfig>,
397 default_template: Option<QueryConfig>,
398 }
399
400 impl TemplateRouting for TestReactionConfig {
401 fn routes(&self) -> &HashMap<String, QueryConfig> {
402 &self.routes
403 }
404
405 fn default_template(&self) -> Option<&QueryConfig> {
406 self.default_template.as_ref()
407 }
408 }
409
410 #[test]
411 fn test_template_routing_query_specific() {
412 let mut routes = HashMap::new();
413 routes.insert(
414 "query1".to_string(),
415 QueryConfig {
416 added: Some(TemplateSpec {
417 template: "query1 add".to_string(),
418 extension: (),
419 }),
420 updated: None,
421 deleted: None,
422 },
423 );
424
425 let config = TestReactionConfig {
426 routes,
427 default_template: None,
428 };
429
430 let spec = config.get_template_spec("query1", OperationType::Add);
431 assert!(spec.is_some());
432 assert_eq!(spec.unwrap().template, "query1 add");
433
434 let spec = config.get_template_spec("query1", OperationType::Update);
435 assert!(spec.is_none());
436 }
437
438 #[test]
439 fn test_template_routing_default_fallback() {
440 let config = TestReactionConfig {
441 routes: HashMap::new(),
442 default_template: Some(QueryConfig {
443 added: Some(TemplateSpec {
444 template: "default add".to_string(),
445 extension: (),
446 }),
447 updated: Some(TemplateSpec {
448 template: "default update".to_string(),
449 extension: (),
450 }),
451 deleted: Some(TemplateSpec {
452 template: "default delete".to_string(),
453 extension: (),
454 }),
455 }),
456 };
457
458 let spec = config.get_template_spec("any_query", OperationType::Add);
459 assert!(spec.is_some());
460 assert_eq!(spec.unwrap().template, "default add");
461
462 let spec = config.get_template_spec("any_query", OperationType::Update);
463 assert!(spec.is_some());
464 assert_eq!(spec.unwrap().template, "default update");
465 }
466
467 #[test]
468 fn test_template_routing_query_overrides_default() {
469 let mut routes = HashMap::new();
470 routes.insert(
471 "query1".to_string(),
472 QueryConfig {
473 added: Some(TemplateSpec {
474 template: "query1 add override".to_string(),
475 extension: (),
476 }),
477 updated: None,
478 deleted: None,
479 },
480 );
481
482 let config = TestReactionConfig {
483 routes,
484 default_template: Some(QueryConfig {
485 added: Some(TemplateSpec {
486 template: "default add".to_string(),
487 extension: (),
488 }),
489 updated: Some(TemplateSpec {
490 template: "default update".to_string(),
491 extension: (),
492 }),
493 deleted: None,
494 }),
495 };
496
497 // Query-specific should override default
498 let spec = config.get_template_spec("query1", OperationType::Add);
499 assert_eq!(spec.unwrap().template, "query1 add override");
500
501 // Missing query-specific should fall back to default
502 let spec = config.get_template_spec("query1", OperationType::Update);
503 assert_eq!(spec.unwrap().template, "default update");
504
505 // Unknown query should use default
506 let spec = config.get_template_spec("query2", OperationType::Add);
507 assert_eq!(spec.unwrap().template, "default add");
508 }
509}