Skip to main content

drasi_bootstrap_http/
response.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//! Response parsing and element extraction.
16//!
17//! Extracts items from HTTP responses and maps them to Drasi graph elements
18//! using the template engine.
19
20use anyhow::{anyhow, Context, Result};
21use drasi_core::models::{
22    Element, ElementMetadata, ElementPropertyMap, ElementReference, ElementValue,
23};
24use ordered_float::OrderedFloat;
25use serde_json::Value as JsonValue;
26use std::collections::HashMap;
27use std::sync::Arc;
28
29use crate::config::{ElementMappingConfig, ElementType, OperationType};
30use crate::pagination;
31use crate::template_engine::{TemplateContext, TemplateEngine};
32
33/// Extract items array from a response body using the configured items path.
34pub fn extract_items(body: &JsonValue, items_path: &str) -> Result<Vec<JsonValue>> {
35    // Use the shared path navigator that supports bracket notation and negative indexes
36    let items_value = pagination::navigate_path(body, items_path).ok_or_else(|| {
37        anyhow!("Items path '{items_path}' did not resolve to a value in response")
38    })?;
39
40    match items_value {
41        JsonValue::Array(arr) => Ok(arr.clone()),
42        // If it's a single object, wrap it in a vec
43        other if other.is_object() => Ok(vec![other.clone()]),
44        _ => Err(anyhow!(
45            "Items path '{items_path}' did not resolve to an array or object"
46        )),
47    }
48}
49
50/// A mapped change result representing the operation to emit for an item.
51pub enum MappedChange {
52    /// Insert or Update — carries the full element.
53    Upsert {
54        element: Element,
55        operation: OperationType,
56    },
57    /// Delete — only metadata (id + labels) is needed.
58    Delete { metadata: ElementMetadata },
59}
60
61/// Map a list of items to Drasi graph elements using the configured mappings.
62pub fn map_items_to_elements(
63    items: &[JsonValue],
64    mappings: &[ElementMappingConfig],
65    source_id: &str,
66    engine: &TemplateEngine,
67) -> Vec<Result<MappedChange>> {
68    let mut elements = Vec::new();
69
70    for (index, item) in items.iter().enumerate() {
71        let context = TemplateContext {
72            item: item.clone(),
73            index,
74            source_id: source_id.to_string(),
75        };
76
77        for mapping in mappings {
78            let result = map_single_item(&context, mapping, source_id, engine);
79            elements.push(result);
80        }
81    }
82
83    elements
84}
85
86/// Map a single item to a `MappedChange`.
87fn map_single_item(
88    context: &TemplateContext,
89    mapping: &ElementMappingConfig,
90    source_id: &str,
91    engine: &TemplateEngine,
92) -> Result<MappedChange> {
93    let template = &mapping.template;
94    let is_delete = mapping.operation == OperationType::Delete;
95
96    // Render ID
97    let id = engine
98        .render_string(&template.id, context)
99        .context("Failed to render element ID template")?;
100
101    if id.is_empty() {
102        return Err(anyhow!("Element ID rendered to empty string"));
103    }
104
105    // Render labels
106    let mut labels = Vec::new();
107    for label_template in &template.labels {
108        let label = engine
109            .render_string(label_template, context)
110            .context("Failed to render label template")?;
111        if !label.is_empty() {
112            labels.push(Arc::from(label.as_str()));
113        }
114    }
115
116    // For Delete operations, only metadata (id + labels) is needed — skip
117    // property and relation endpoint rendering.
118    if is_delete {
119        let metadata = ElementMetadata {
120            reference: ElementReference::new(source_id, &id),
121            labels: labels.into(),
122            effective_from: 0,
123        };
124        return Ok(MappedChange::Delete { metadata });
125    }
126
127    // Render properties
128    let properties = if let Some(ref props) = template.properties {
129        let rendered = engine
130            .render_properties(props, context)
131            .context("Failed to render properties")?;
132        json_map_to_element_properties(&rendered)
133    } else {
134        ElementPropertyMap::new()
135    };
136
137    // Create element based on type
138    let element = match mapping.element_type {
139        ElementType::Node => {
140            let metadata = ElementMetadata {
141                reference: ElementReference::new(source_id, &id),
142                labels: labels.into(),
143                effective_from: 0,
144            };
145            Element::Node {
146                metadata,
147                properties,
148            }
149        }
150        ElementType::Relation => {
151            let from_id = template
152                .from
153                .as_ref()
154                .ok_or_else(|| anyhow!("Relation mapping requires 'from' template"))?;
155            let to_id = template
156                .to
157                .as_ref()
158                .ok_or_else(|| anyhow!("Relation mapping requires 'to' template"))?;
159
160            let from_rendered = engine
161                .render_string(from_id, context)
162                .context("Failed to render 'from' template")?;
163            let to_rendered = engine
164                .render_string(to_id, context)
165                .context("Failed to render 'to' template")?;
166
167            let metadata = ElementMetadata {
168                reference: ElementReference::new(source_id, &id),
169                labels: labels.into(),
170                effective_from: 0,
171            };
172
173            Element::Relation {
174                metadata,
175                properties,
176                in_node: ElementReference::new(source_id, &from_rendered),
177                out_node: ElementReference::new(source_id, &to_rendered),
178            }
179        }
180    };
181
182    Ok(MappedChange::Upsert {
183        element,
184        operation: mapping.operation.clone(),
185    })
186}
187
188/// Convert a HashMap<String, JsonValue> to ElementPropertyMap.
189fn json_map_to_element_properties(map: &HashMap<String, JsonValue>) -> ElementPropertyMap {
190    let mut props = ElementPropertyMap::new();
191    for (key, value) in map {
192        if let Some(elem_value) = json_value_to_element_value(value) {
193            props.insert(key.as_str(), elem_value);
194        }
195    }
196    props
197}
198
199/// Convert a serde_json::Value to an ElementValue.
200fn json_value_to_element_value(value: &JsonValue) -> Option<ElementValue> {
201    match value {
202        JsonValue::Null => Some(ElementValue::Null),
203        JsonValue::Bool(b) => Some(ElementValue::Bool(*b)),
204        JsonValue::Number(n) => {
205            if let Some(i) = n.as_i64() {
206                Some(ElementValue::Integer(i))
207            } else {
208                n.as_f64().map(|f| ElementValue::Float(OrderedFloat(f)))
209            }
210        }
211        JsonValue::String(s) => Some(ElementValue::String(s.clone().into())),
212        JsonValue::Array(arr) => {
213            let elements: Vec<ElementValue> =
214                arr.iter().filter_map(json_value_to_element_value).collect();
215            Some(ElementValue::List(elements))
216        }
217        JsonValue::Object(map) => {
218            let mut props = ElementPropertyMap::new();
219            for (key, v) in map {
220                if let Some(ev) = json_value_to_element_value(v) {
221                    props.insert(key.as_str(), ev);
222                }
223            }
224            Some(ElementValue::Object(props))
225        }
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use serde_json::json;
233
234    #[test]
235    fn test_extract_items_top_level_array() {
236        let body = json!([{"id": "1"}, {"id": "2"}]);
237        let items = extract_items(&body, "$").unwrap();
238        assert_eq!(items.len(), 2);
239    }
240
241    #[test]
242    fn test_extract_items_nested_path() {
243        let body = json!({"data": [{"id": "1"}, {"id": "2"}]});
244        let items = extract_items(&body, "$.data").unwrap();
245        assert_eq!(items.len(), 2);
246    }
247
248    #[test]
249    fn test_extract_items_deep_nested() {
250        let body = json!({"response": {"results": [{"id": "1"}]}});
251        let items = extract_items(&body, "$.response.results").unwrap();
252        assert_eq!(items.len(), 1);
253    }
254
255    #[test]
256    fn test_map_items_to_nodes() {
257        let items = vec![
258            json!({"id": "1", "name": "Alice"}),
259            json!({"id": "2", "name": "Bob"}),
260        ];
261
262        let mappings = vec![ElementMappingConfig {
263            element_type: ElementType::Node,
264            operation: Default::default(),
265            template: crate::config::ElementTemplate {
266                id: "{{item.id}}".to_string(),
267                labels: vec!["User".to_string()],
268                properties: Some(json!({"name": "{{item.name}}"})),
269                from: None,
270                to: None,
271            },
272        }];
273
274        let engine = TemplateEngine::new();
275        let results = map_items_to_elements(&items, &mappings, "test-source", &engine);
276        assert_eq!(results.len(), 2);
277
278        let mapped = results[0].as_ref().unwrap();
279        match mapped {
280            MappedChange::Upsert { element, .. } => match element {
281                Element::Node { metadata, .. } => {
282                    assert_eq!(&*metadata.reference.element_id, "1");
283                    assert_eq!(metadata.labels.len(), 1);
284                    assert_eq!(&*metadata.labels[0], "User");
285                }
286                _ => panic!("Expected Node"),
287            },
288            _ => panic!("Expected Upsert"),
289        }
290    }
291
292    #[test]
293    fn test_map_items_to_relations() {
294        let items = vec![json!({"id": "r1", "from": "n1", "to": "n2", "type": "KNOWS"})];
295
296        let mappings = vec![ElementMappingConfig {
297            element_type: ElementType::Relation,
298            operation: Default::default(),
299            template: crate::config::ElementTemplate {
300                id: "{{item.id}}".to_string(),
301                labels: vec!["{{item.type}}".to_string()],
302                properties: None,
303                from: Some("{{item.from}}".to_string()),
304                to: Some("{{item.to}}".to_string()),
305            },
306        }];
307
308        let engine = TemplateEngine::new();
309        let results = map_items_to_elements(&items, &mappings, "test-source", &engine);
310        assert_eq!(results.len(), 1);
311
312        let mapped = results[0].as_ref().unwrap();
313        match mapped {
314            MappedChange::Upsert { element, .. } => match element {
315                Element::Relation {
316                    metadata,
317                    in_node,
318                    out_node,
319                    ..
320                } => {
321                    assert_eq!(&*metadata.reference.element_id, "r1");
322                    assert_eq!(&*in_node.element_id, "n1");
323                    assert_eq!(&*out_node.element_id, "n2");
324                }
325                _ => panic!("Expected Relation"),
326            },
327            _ => panic!("Expected Upsert"),
328        }
329    }
330}