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};
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/// Map a list of items to Drasi graph elements using the configured mappings.
51pub fn map_items_to_elements(
52    items: &[JsonValue],
53    mappings: &[ElementMappingConfig],
54    source_id: &str,
55    engine: &TemplateEngine,
56) -> Vec<Result<Element>> {
57    let mut elements = Vec::new();
58
59    for (index, item) in items.iter().enumerate() {
60        let context = TemplateContext {
61            item: item.clone(),
62            index,
63            source_id: source_id.to_string(),
64        };
65
66        for mapping in mappings {
67            let result = map_single_item(&context, mapping, source_id, engine);
68            elements.push(result);
69        }
70    }
71
72    elements
73}
74
75/// Map a single item to a Drasi Element.
76fn map_single_item(
77    context: &TemplateContext,
78    mapping: &ElementMappingConfig,
79    source_id: &str,
80    engine: &TemplateEngine,
81) -> Result<Element> {
82    let template = &mapping.template;
83
84    // Render ID
85    let id = engine
86        .render_string(&template.id, context)
87        .context("Failed to render element ID template")?;
88
89    if id.is_empty() {
90        return Err(anyhow!("Element ID rendered to empty string"));
91    }
92
93    // Render labels
94    let mut labels = Vec::new();
95    for label_template in &template.labels {
96        let label = engine
97            .render_string(label_template, context)
98            .context("Failed to render label template")?;
99        if !label.is_empty() {
100            labels.push(Arc::from(label.as_str()));
101        }
102    }
103
104    // Render properties
105    let properties = if let Some(ref props) = template.properties {
106        let rendered = engine
107            .render_properties(props, context)
108            .context("Failed to render properties")?;
109        json_map_to_element_properties(&rendered)
110    } else {
111        ElementPropertyMap::new()
112    };
113
114    // Create element based on type
115    match mapping.element_type {
116        ElementType::Node => {
117            let metadata = ElementMetadata {
118                reference: ElementReference::new(source_id, &id),
119                labels: labels.into(),
120                effective_from: 0,
121            };
122            Ok(Element::Node {
123                metadata,
124                properties,
125            })
126        }
127        ElementType::Relation => {
128            let from_id = template
129                .from
130                .as_ref()
131                .ok_or_else(|| anyhow!("Relation mapping requires 'from' template"))?;
132            let to_id = template
133                .to
134                .as_ref()
135                .ok_or_else(|| anyhow!("Relation mapping requires 'to' template"))?;
136
137            let from_rendered = engine
138                .render_string(from_id, context)
139                .context("Failed to render 'from' template")?;
140            let to_rendered = engine
141                .render_string(to_id, context)
142                .context("Failed to render 'to' template")?;
143
144            let metadata = ElementMetadata {
145                reference: ElementReference::new(source_id, &id),
146                labels: labels.into(),
147                effective_from: 0,
148            };
149
150            Ok(Element::Relation {
151                metadata,
152                properties,
153                in_node: ElementReference::new(source_id, &from_rendered),
154                out_node: ElementReference::new(source_id, &to_rendered),
155            })
156        }
157    }
158}
159
160/// Convert a HashMap<String, JsonValue> to ElementPropertyMap.
161fn json_map_to_element_properties(map: &HashMap<String, JsonValue>) -> ElementPropertyMap {
162    let mut props = ElementPropertyMap::new();
163    for (key, value) in map {
164        if let Some(elem_value) = json_value_to_element_value(value) {
165            props.insert(key.as_str(), elem_value);
166        }
167    }
168    props
169}
170
171/// Convert a serde_json::Value to an ElementValue.
172fn json_value_to_element_value(value: &JsonValue) -> Option<ElementValue> {
173    match value {
174        JsonValue::Null => Some(ElementValue::Null),
175        JsonValue::Bool(b) => Some(ElementValue::Bool(*b)),
176        JsonValue::Number(n) => {
177            if let Some(i) = n.as_i64() {
178                Some(ElementValue::Integer(i))
179            } else {
180                n.as_f64().map(|f| ElementValue::Float(OrderedFloat(f)))
181            }
182        }
183        JsonValue::String(s) => Some(ElementValue::String(s.clone().into())),
184        JsonValue::Array(arr) => {
185            let elements: Vec<ElementValue> =
186                arr.iter().filter_map(json_value_to_element_value).collect();
187            Some(ElementValue::List(elements))
188        }
189        JsonValue::Object(map) => {
190            let mut props = ElementPropertyMap::new();
191            for (key, v) in map {
192                if let Some(ev) = json_value_to_element_value(v) {
193                    props.insert(key.as_str(), ev);
194                }
195            }
196            Some(ElementValue::Object(props))
197        }
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use serde_json::json;
205
206    #[test]
207    fn test_extract_items_top_level_array() {
208        let body = json!([{"id": "1"}, {"id": "2"}]);
209        let items = extract_items(&body, "$").unwrap();
210        assert_eq!(items.len(), 2);
211    }
212
213    #[test]
214    fn test_extract_items_nested_path() {
215        let body = json!({"data": [{"id": "1"}, {"id": "2"}]});
216        let items = extract_items(&body, "$.data").unwrap();
217        assert_eq!(items.len(), 2);
218    }
219
220    #[test]
221    fn test_extract_items_deep_nested() {
222        let body = json!({"response": {"results": [{"id": "1"}]}});
223        let items = extract_items(&body, "$.response.results").unwrap();
224        assert_eq!(items.len(), 1);
225    }
226
227    #[test]
228    fn test_map_items_to_nodes() {
229        let items = vec![
230            json!({"id": "1", "name": "Alice"}),
231            json!({"id": "2", "name": "Bob"}),
232        ];
233
234        let mappings = vec![ElementMappingConfig {
235            element_type: ElementType::Node,
236            template: crate::config::ElementTemplate {
237                id: "{{item.id}}".to_string(),
238                labels: vec!["User".to_string()],
239                properties: Some(json!({"name": "{{item.name}}"})),
240                from: None,
241                to: None,
242            },
243        }];
244
245        let engine = TemplateEngine::new();
246        let results = map_items_to_elements(&items, &mappings, "test-source", &engine);
247        assert_eq!(results.len(), 2);
248
249        let elem = results[0].as_ref().unwrap();
250        match elem {
251            Element::Node { metadata, .. } => {
252                assert_eq!(&*metadata.reference.element_id, "1");
253                assert_eq!(metadata.labels.len(), 1);
254                assert_eq!(&*metadata.labels[0], "User");
255            }
256            _ => panic!("Expected Node"),
257        }
258    }
259
260    #[test]
261    fn test_map_items_to_relations() {
262        let items = vec![json!({"id": "r1", "from": "n1", "to": "n2", "type": "KNOWS"})];
263
264        let mappings = vec![ElementMappingConfig {
265            element_type: ElementType::Relation,
266            template: crate::config::ElementTemplate {
267                id: "{{item.id}}".to_string(),
268                labels: vec!["{{item.type}}".to_string()],
269                properties: None,
270                from: Some("{{item.from}}".to_string()),
271                to: Some("{{item.to}}".to_string()),
272            },
273        }];
274
275        let engine = TemplateEngine::new();
276        let results = map_items_to_elements(&items, &mappings, "test-source", &engine);
277        assert_eq!(results.len(), 1);
278
279        let elem = results[0].as_ref().unwrap();
280        match elem {
281            Element::Relation {
282                metadata,
283                in_node,
284                out_node,
285                ..
286            } => {
287                assert_eq!(&*metadata.reference.element_id, "r1");
288                assert_eq!(&*in_node.element_id, "n1");
289                assert_eq!(&*out_node.element_id, "n2");
290            }
291            _ => panic!("Expected Relation"),
292        }
293    }
294}