Skip to main content

nika_engine/binding/
jsonpath.rs

1//! JSONPath evaluation — RFC 9535 via `serde_json_path`
2//!
3//! Replaces the minimal custom `util/jsonpath.rs` with RFC 9535 compliance.
4//!
5//! Supports:
6//! - Dot notation: `$.a.b.c`
7//! - Array index: `$.a[0].b`
8//! - Wildcards: `$[*]`, `$.items[*].name`
9//! - Filters: `$.items[?@.price > 100]`
10//! - Recursive descent: `$..email`
11//! - Slices: `$.items[0:5]`
12//!
13//! # Usage
14//!
15//! ```rust,ignore
16//! use nika::binding::jsonpath;
17//!
18//! let value = json!({"items": [{"name": "a"}, {"name": "b"}]});
19//!
20//! // Simple path (fast, no RFC parse overhead)
21//! let result = jsonpath::resolve(&value, "items[0].name")?;
22//! assert_eq!(result, Some(json!("a")));
23//!
24//! // Rich JSONPath query (RFC 9535)
25//! let result = jsonpath::query(&value, "$.items[*].name")?;
26//! assert_eq!(result, json!(["a", "b"]));
27//! ```
28
29use serde_json::Value;
30use serde_json_path::JsonPath;
31
32use crate::error::NikaError;
33
34/// Try to parse a JSON string Value into a structured Value.
35///
36/// If `value` is a `Value::String` containing valid JSON (object or array),
37/// returns the parsed Value. Otherwise returns `None`.
38///
39/// This is the single source of truth for the "auto-parse JSON strings"
40/// pattern used throughout the binding system (jsonpath, resolve, runner).
41pub fn try_parse_json_str(value: &Value) -> Option<Value> {
42    if let Value::String(s) = value {
43        let trimmed = s.trim();
44        if (trimmed.starts_with('{') && trimmed.ends_with('}'))
45            || (trimmed.starts_with('[') && trimmed.ends_with(']'))
46        {
47            serde_json::from_str::<Value>(trimmed).ok()
48        } else {
49            None
50        }
51    } else {
52        None
53    }
54}
55
56/// Evaluate a RFC 9535 JSONPath expression against a JSON value.
57///
58/// Returns:
59/// - `Null` when no nodes match
60/// - The single value when exactly one node matches
61/// - An array of values when multiple nodes match
62///
63/// Use this for rich expressions with wildcards, filters, slices, or
64/// recursive descent. For simple `$.a.b[0]` paths, prefer `resolve()`
65/// which avoids the RFC parser overhead.
66pub fn query(value: &Value, path: &str) -> Result<Value, NikaError> {
67    let jp = JsonPath::parse(path).map_err(|e| NikaError::JsonPathUnsupported {
68        path: format!("{}: {}", path, e),
69    })?;
70    let results = jp.query(value);
71    let nodes: Vec<&Value> = results.all();
72    match nodes.len() {
73        0 => Ok(Value::Null),
74        1 => Ok(nodes[0].clone()),
75        _ => Ok(Value::Array(nodes.into_iter().cloned().collect())),
76    }
77}
78
79/// Check if a path uses rich JSONPath features (wildcards, filters, slices, recursive descent).
80///
81/// Returns `true` for expressions that need `query()` (RFC 9535 parser),
82/// `false` for simple dot/index paths that `resolve()` handles directly.
83pub fn is_jsonpath(path: &str) -> bool {
84    // Check for rich JSONPath operators inside brackets
85    if let Some(start) = path.find('[') {
86        let bracket_content = &path[start..];
87        if bracket_content.contains('*')
88            || bracket_content.contains('?')
89            || bracket_content.contains(':')
90        {
91            return true;
92        }
93    }
94    // Recursive descent operator
95    path.contains("..")
96}
97
98// ═══════════════════════════════════════════════════════════════
99// Simple path resolution
100// ═══════════════════════════════════════════════════════════════
101
102/// A parsed path segment (field or array index)
103#[derive(Debug, Clone, PartialEq)]
104pub enum Segment {
105    /// Object field access: .field
106    Field(String),
107    /// Array index access: [0]
108    Index(usize),
109}
110
111/// Parse a simple JSONPath string into segments.
112///
113/// Supports:
114/// - `$.a.b.c` (dot notation with `$` prefix)
115/// - `a.b.c` (dot notation without prefix)
116/// - `items[0].name` (array index in bracket notation)
117/// - `items.0.name` (array index as numeric segment)
118///
119/// Does NOT support wildcards, filters, slices, or recursive descent.
120/// Use `query()` for those.
121pub fn parse(path: &str) -> Result<Vec<Segment>, NikaError> {
122    // Remove $. prefix if present
123    let path = if let Some(stripped) = path.strip_prefix("$.") {
124        stripped
125    } else if path == "$" {
126        return Ok(vec![]);
127    } else {
128        path
129    };
130
131    if path.is_empty() {
132        return Ok(vec![]);
133    }
134
135    let mut segments = Vec::new();
136
137    for part in path.split('.') {
138        if part.is_empty() {
139            return Err(NikaError::JsonPathUnsupported {
140                path: path.to_string(),
141            });
142        }
143
144        // Check for array index: field[0] or just [0]
145        if let Some(bracket_pos) = part.find('[') {
146            let field = &part[..bracket_pos];
147            if !field.is_empty() {
148                segments.push(Segment::Field(field.to_string()));
149            }
150
151            if !part.ends_with(']') {
152                return Err(NikaError::JsonPathUnsupported {
153                    path: path.to_string(),
154                });
155            }
156
157            let index_str = &part[bracket_pos + 1..part.len() - 1];
158            let index: usize = index_str
159                .parse()
160                .map_err(|_| NikaError::JsonPathUnsupported {
161                    path: path.to_string(),
162                })?;
163
164            segments.push(Segment::Index(index));
165        } else if let Ok(index) = part.parse::<usize>() {
166            segments.push(Segment::Index(index));
167        } else {
168            segments.push(Segment::Field(part.to_string()));
169        }
170    }
171
172    Ok(segments)
173}
174
175/// Apply parsed segments to a JSON value.
176///
177/// Uses references internally, clones once at the end.
178pub fn apply(value: &Value, segments: &[Segment]) -> Option<Value> {
179    // Auto-parse JSON strings before traversal so that exec: output
180    // like '{"name":"Nika"}' can be accessed via $task.name
181    let parsed;
182    let mut current = if let Some(v) = try_parse_json_str(value) {
183        parsed = v;
184        &parsed
185    } else {
186        value
187    };
188
189    for segment in segments {
190        current = match segment {
191            Segment::Field(name) => current.get(name)?,
192            Segment::Index(idx) => current.get(*idx)?,
193        };
194    }
195
196    Some(current.clone())
197}
198
199/// Parse and apply a simple path in one step.
200///
201/// For simple dot/index paths like `data.items[0].name`.
202/// Returns `Ok(None)` if the path doesn't match.
203///
204/// For rich JSONPath expressions (wildcards, filters), use `query()`.
205pub fn resolve(value: &Value, path: &str) -> Result<Option<Value>, NikaError> {
206    let segments = parse(path)?;
207    Ok(apply(value, &segments))
208}
209
210/// Validate simple JSONPath syntax without evaluating.
211pub fn validate(path: &str) -> Result<(), NikaError> {
212    parse(path)?;
213    Ok(())
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use serde_json::json;
220
221    // ═══════════════════════════════════════════════════════════════
222    // Simple path resolution
223    // ═══════════════════════════════════════════════════════════════
224
225    #[test]
226    fn parse_simple_path() {
227        let segments = parse("$.a.b.c").unwrap();
228        assert_eq!(
229            segments,
230            vec![
231                Segment::Field("a".to_string()),
232                Segment::Field("b".to_string()),
233                Segment::Field("c".to_string()),
234            ]
235        );
236    }
237
238    #[test]
239    fn parse_without_dollar() {
240        let segments = parse("a.b").unwrap();
241        assert_eq!(
242            segments,
243            vec![
244                Segment::Field("a".to_string()),
245                Segment::Field("b".to_string()),
246            ]
247        );
248    }
249
250    #[test]
251    fn parse_with_array_index() {
252        let segments = parse("$.items[0].name").unwrap();
253        assert_eq!(
254            segments,
255            vec![
256                Segment::Field("items".to_string()),
257                Segment::Index(0),
258                Segment::Field("name".to_string()),
259            ]
260        );
261    }
262
263    #[test]
264    fn parse_just_root() {
265        let segments = parse("$").unwrap();
266        assert!(segments.is_empty());
267    }
268
269    #[test]
270    fn apply_simple() {
271        let value = json!({"a": {"b": "value"}});
272        let segments = parse("$.a.b").unwrap();
273        let result = apply(&value, &segments);
274        assert_eq!(result, Some(json!("value")));
275    }
276
277    #[test]
278    fn apply_array_index() {
279        let value = json!({"items": ["first", "second", "third"]});
280        let segments = parse("$.items[1]").unwrap();
281        let result = apply(&value, &segments);
282        assert_eq!(result, Some(json!("second")));
283    }
284
285    #[test]
286    fn apply_nested_array() {
287        let value = json!({
288            "users": [
289                {"name": "Alice"},
290                {"name": "Bob"}
291            ]
292        });
293        let segments = parse("$.users[0].name").unwrap();
294        let result = apply(&value, &segments);
295        assert_eq!(result, Some(json!("Alice")));
296    }
297
298    #[test]
299    fn apply_missing_field() {
300        let value = json!({"a": 1});
301        let segments = parse("$.b").unwrap();
302        let result = apply(&value, &segments);
303        assert_eq!(result, None);
304    }
305
306    #[test]
307    fn resolve_shorthand() {
308        let value = json!({"price": {"currency": "EUR", "amount": 100}});
309        let result = resolve(&value, "$.price.currency").unwrap();
310        assert_eq!(result, Some(json!("EUR")));
311    }
312
313    #[test]
314    fn parse_numeric_index_as_dot() {
315        let segments = parse("items.0").unwrap();
316        assert_eq!(
317            segments,
318            vec![Segment::Field("items".to_string()), Segment::Index(0)]
319        );
320    }
321
322    #[test]
323    fn apply_numeric_index_as_dot() {
324        let value = json!({"items": ["first", "second"]});
325        let result = resolve(&value, "items.1").unwrap();
326        assert_eq!(result, Some(json!("second")));
327    }
328
329    // ═══════════════════════════════════════════════════════════════
330    // Rich JSONPath via serde_json_path (RFC 9535)
331    // ═══════════════════════════════════════════════════════════════
332
333    #[test]
334    fn query_root() {
335        let value = json!({"name": "test"});
336        let result = query(&value, "$").unwrap();
337        assert_eq!(result, json!({"name": "test"}));
338    }
339
340    #[test]
341    fn query_single_field() {
342        let value = json!({"name": "test", "age": 30});
343        let result = query(&value, "$.name").unwrap();
344        assert_eq!(result, json!("test"));
345    }
346
347    #[test]
348    fn query_nested_field() {
349        let value = json!({"data": {"items": [1, 2, 3]}});
350        let result = query(&value, "$.data.items").unwrap();
351        assert_eq!(result, json!([1, 2, 3]));
352    }
353
354    #[test]
355    fn query_wildcard() {
356        let value = json!({
357            "items": [
358                {"name": "Alice"},
359                {"name": "Bob"},
360                {"name": "Charlie"}
361            ]
362        });
363        let result = query(&value, "$.items[*].name").unwrap();
364        assert_eq!(result, json!(["Alice", "Bob", "Charlie"]));
365    }
366
367    #[test]
368    fn query_array_index() {
369        let value = json!({"items": ["a", "b", "c"]});
370        let result = query(&value, "$.items[1]").unwrap();
371        assert_eq!(result, json!("b"));
372    }
373
374    #[test]
375    fn query_recursive_descent() {
376        let value = json!({
377            "a": {"email": "a@test.com"},
378            "b": {"nested": {"email": "b@test.com"}}
379        });
380        let result = query(&value, "$..email").unwrap();
381        // Recursive descent returns all matching nodes
382        assert!(result.is_array());
383        let arr = result.as_array().unwrap();
384        assert_eq!(arr.len(), 2);
385        assert!(arr.contains(&json!("a@test.com")));
386        assert!(arr.contains(&json!("b@test.com")));
387    }
388
389    #[test]
390    fn query_filter() {
391        let value = json!({
392            "items": [
393                {"name": "cheap", "price": 5},
394                {"name": "expensive", "price": 150},
395                {"name": "mid", "price": 50}
396            ]
397        });
398        let result = query(&value, "$.items[?@.price > 100]").unwrap();
399        assert_eq!(result, json!({"name": "expensive", "price": 150}));
400    }
401
402    #[test]
403    fn query_filter_multiple_results() {
404        let value = json!({
405            "items": [
406                {"name": "a", "price": 5},
407                {"name": "b", "price": 150},
408                {"name": "c", "price": 200}
409            ]
410        });
411        let result = query(&value, "$.items[?@.price > 100]").unwrap();
412        assert_eq!(
413            result,
414            json!([
415                {"name": "b", "price": 150},
416                {"name": "c", "price": 200}
417            ])
418        );
419    }
420
421    #[test]
422    fn query_no_match() {
423        let value = json!({"a": 1});
424        let result = query(&value, "$.missing").unwrap();
425        assert_eq!(result, Value::Null);
426    }
427
428    #[test]
429    fn query_slice() {
430        let value = json!({"items": [0, 1, 2, 3, 4, 5]});
431        let result = query(&value, "$.items[1:4]").unwrap();
432        assert_eq!(result, json!([1, 2, 3]));
433    }
434
435    #[test]
436    fn query_invalid_syntax() {
437        let result = query(&json!({}), "$.items[[invalid");
438        assert!(result.is_err());
439    }
440
441    // ═══════════════════════════════════════════════════════════════
442    // is_jsonpath detection
443    // ═══════════════════════════════════════════════════════════════
444
445    #[test]
446    fn is_jsonpath_simple_paths() {
447        assert!(!is_jsonpath("$.a.b.c"));
448        assert!(!is_jsonpath("items[0].name"));
449        assert!(!is_jsonpath("a.b"));
450    }
451
452    #[test]
453    fn is_jsonpath_rich_expressions() {
454        assert!(is_jsonpath("$.items[*].name"));
455        assert!(is_jsonpath("$.items[?@.price > 100]"));
456        assert!(is_jsonpath("$..email"));
457        assert!(is_jsonpath("$.items[0:5]"));
458        assert!(is_jsonpath("$.items[:3]"));
459    }
460
461    // ═══════════════════════════════════════════════════════════════
462    // Validate
463    // ═══════════════════════════════════════════════════════════════
464
465    #[test]
466    fn validate_valid_paths() {
467        assert!(validate("$.a.b").is_ok());
468        assert!(validate("items[0].name").is_ok());
469        assert!(validate("$").is_ok());
470    }
471
472    #[test]
473    fn validate_empty_segment() {
474        assert!(validate("a..b").is_err());
475    }
476}