Skip to main content

drasi_source_mapping/
dto.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//! User-facing DTO types for source mapping configuration.
16//!
17//! These types use `camelCase` serialization to match the user-facing YAML/JSON
18//! configuration format. They convert to the internal runtime types via `From` impls.
19
20use serde::{Deserialize, Serialize};
21use std::collections::HashMap;
22
23use crate::config::{
24    EffectiveFromConfig, ElementTemplate, ElementType, MappingCondition, OperationType,
25    SourceMapping, TimestampFormat,
26};
27
28/// DTO for source mapping configuration (user-facing, camelCase).
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
30#[serde(rename_all = "camelCase")]
31pub struct SourceMappingDto {
32    /// Optional condition for when this mapping applies
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub when: Option<MappingConditionDto>,
35
36    /// Static operation type
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub operation: Option<OperationTypeDto>,
39
40    /// Path to extract operation from context (e.g., "payload.action")
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub operation_from: Option<String>,
43
44    /// Mapping from extracted values to operation types
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub operation_map: Option<HashMap<String, OperationTypeDto>>,
47
48    /// Element type to create
49    pub element_type: ElementTypeDto,
50
51    /// Timestamp configuration for effective_from
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub effective_from: Option<EffectiveFromConfigDto>,
54
55    /// Template for element creation
56    pub template: ElementTemplateDto,
57}
58
59/// DTO for mapping condition (user-facing, camelCase).
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
61#[serde(rename_all = "camelCase")]
62pub struct MappingConditionDto {
63    /// Header to check (HTTP-specific, but included for compatibility)
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub header: Option<String>,
66
67    /// Payload field path to check (dot notation)
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub field: Option<String>,
70
71    /// Value must equal this
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub equals: Option<String>,
74
75    /// Value must contain this
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub contains: Option<String>,
78
79    /// Value must match this regex
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub regex: Option<String>,
82}
83
84/// DTO for operation type (user-facing).
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
86#[serde(rename_all = "lowercase")]
87pub enum OperationTypeDto {
88    Insert,
89    Update,
90    Delete,
91}
92
93/// DTO for element type (user-facing).
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95#[serde(rename_all = "lowercase")]
96pub enum ElementTypeDto {
97    Node,
98    Relation,
99}
100
101/// DTO for effective_from timestamp configuration (user-facing, camelCase).
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
103#[serde(untagged)]
104pub enum EffectiveFromConfigDto {
105    /// Simple template string (auto-detect format)
106    Simple(String),
107    /// Explicit configuration with format
108    #[serde(rename_all = "camelCase")]
109    Explicit {
110        /// Template for the timestamp value
111        value: String,
112        /// Format of the timestamp
113        format: TimestampFormatDto,
114    },
115}
116
117/// DTO for timestamp format (user-facing).
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
119#[serde(rename_all = "snake_case")]
120pub enum TimestampFormatDto {
121    Iso8601,
122    UnixSeconds,
123    UnixMillis,
124    UnixNanos,
125}
126
127/// DTO for element template (user-facing, camelCase).
128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
129#[serde(rename_all = "camelCase")]
130pub struct ElementTemplateDto {
131    /// Template for element ID
132    pub id: String,
133
134    /// Templates for element labels
135    pub labels: Vec<String>,
136
137    /// Templates for element properties
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub properties: Option<serde_json::Value>,
140
141    /// Template for relation source node ID (relations only)
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub from: Option<String>,
144
145    /// Template for relation target node ID (relations only)
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub to: Option<String>,
148}
149
150// --- From<Dto> -> Runtime conversions ---
151
152impl From<SourceMappingDto> for SourceMapping {
153    fn from(dto: SourceMappingDto) -> Self {
154        Self {
155            when: dto.when.map(Into::into),
156            operation: dto.operation.map(Into::into),
157            operation_from: dto.operation_from,
158            operation_map: dto
159                .operation_map
160                .map(|m| m.into_iter().map(|(k, v)| (k, v.into())).collect()),
161            element_type: dto.element_type.into(),
162            effective_from: dto.effective_from.map(Into::into),
163            template: dto.template.into(),
164        }
165    }
166}
167
168impl From<MappingConditionDto> for MappingCondition {
169    fn from(dto: MappingConditionDto) -> Self {
170        Self {
171            header: dto.header,
172            field: dto.field,
173            equals: dto.equals,
174            contains: dto.contains,
175            regex: dto.regex,
176        }
177    }
178}
179
180impl From<OperationTypeDto> for OperationType {
181    fn from(dto: OperationTypeDto) -> Self {
182        match dto {
183            OperationTypeDto::Insert => Self::Insert,
184            OperationTypeDto::Update => Self::Update,
185            OperationTypeDto::Delete => Self::Delete,
186        }
187    }
188}
189
190impl From<ElementTypeDto> for ElementType {
191    fn from(dto: ElementTypeDto) -> Self {
192        match dto {
193            ElementTypeDto::Node => Self::Node,
194            ElementTypeDto::Relation => Self::Relation,
195        }
196    }
197}
198
199impl From<EffectiveFromConfigDto> for EffectiveFromConfig {
200    fn from(dto: EffectiveFromConfigDto) -> Self {
201        match dto {
202            EffectiveFromConfigDto::Simple(s) => Self::Simple(s),
203            EffectiveFromConfigDto::Explicit { value, format } => Self::Explicit {
204                value,
205                format: format.into(),
206            },
207        }
208    }
209}
210
211impl From<TimestampFormatDto> for TimestampFormat {
212    fn from(dto: TimestampFormatDto) -> Self {
213        match dto {
214            TimestampFormatDto::Iso8601 => Self::Iso8601,
215            TimestampFormatDto::UnixSeconds => Self::UnixSeconds,
216            TimestampFormatDto::UnixMillis => Self::UnixMillis,
217            TimestampFormatDto::UnixNanos => Self::UnixNanos,
218        }
219    }
220}
221
222impl From<ElementTemplateDto> for ElementTemplate {
223    fn from(dto: ElementTemplateDto) -> Self {
224        Self {
225            id: dto.id,
226            labels: dto.labels,
227            properties: dto.properties,
228            from: dto.from,
229            to: dto.to,
230        }
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn test_dto_deserializes_camel_case() {
240        let json = r#"{
241            "elementType": "node",
242            "operation": "insert",
243            "operationFrom": "payload.action",
244            "operationMap": {
245                "created": "insert",
246                "deleted": "delete"
247            },
248            "effectiveFrom": {
249                "value": "{{payload.timestamp}}",
250                "format": "unix_millis"
251            },
252            "template": {
253                "id": "{{key}}",
254                "labels": ["Order"],
255                "properties": {"name": "{{payload.name}}"}
256            }
257        }"#;
258
259        let dto: SourceMappingDto = serde_json::from_str(json).unwrap();
260        assert_eq!(dto.element_type, ElementTypeDto::Node);
261        assert_eq!(dto.operation, Some(OperationTypeDto::Insert));
262        assert_eq!(dto.operation_from, Some("payload.action".to_string()));
263        assert!(dto.operation_map.is_some());
264
265        // Convert to runtime
266        let runtime: SourceMapping = dto.into();
267        assert_eq!(runtime.element_type, ElementType::Node);
268        assert_eq!(runtime.operation, Some(OperationType::Insert));
269    }
270
271    #[test]
272    fn test_dto_with_condition() {
273        let json = r#"{
274            "when": {
275                "field": "payload.type",
276                "equals": "order"
277            },
278            "elementType": "node",
279            "operation": "insert",
280            "template": {
281                "id": "{{key}}",
282                "labels": ["Order"]
283            }
284        }"#;
285
286        let dto: SourceMappingDto = serde_json::from_str(json).unwrap();
287        let condition = dto.when.as_ref().unwrap();
288        assert_eq!(condition.field, Some("payload.type".to_string()));
289        assert_eq!(condition.equals, Some("order".to_string()));
290
291        let runtime: SourceMapping = dto.into();
292        let cond = runtime.when.unwrap();
293        assert_eq!(cond.field, Some("payload.type".to_string()));
294    }
295
296    #[test]
297    fn test_dto_simple_effective_from() {
298        let json = r#"{
299            "elementType": "node",
300            "operation": "update",
301            "effectiveFrom": "{{payload.ts}}",
302            "template": {
303                "id": "{{key}}",
304                "labels": ["Item"]
305            }
306        }"#;
307
308        let dto: SourceMappingDto = serde_json::from_str(json).unwrap();
309        assert_eq!(
310            dto.effective_from,
311            Some(EffectiveFromConfigDto::Simple("{{payload.ts}}".to_string()))
312        );
313
314        let runtime: SourceMapping = dto.into();
315        assert_eq!(
316            runtime.effective_from,
317            Some(EffectiveFromConfig::Simple("{{payload.ts}}".to_string()))
318        );
319    }
320}