Skip to main content

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}