Skip to main content

camel_api/
exchange_lookup.rs

1//! Exchange-scoped lookup path grammar shared by SQL `:#` placeholders and
2//! Simple `${...}` interpolation. See ADR-0016 (strict rejection) and the
3//! rc-o6o Phase 2 spec §3.2.
4//!
5//! Grammar (e_gpt decision Q2):
6//! - `body.a.b.c`        → `Body([Key("a"), Key("b"), Key("c")])` — walks JSON.
7//! - `body.items.0`      → `Body([Key("items"), Index(0)])`        — array index.
8//! - `header.some.name`  → `Header("some.name")`                   — flat key.
9//! - `property.x`        → `Property("x")`                         — flat key.
10//! - `exchangeProperty.x`→ `Property("x")`                         — alias.
11//! - `foo`               → `Unscoped("foo")`                       — try body JSON key, then header, then property.
12
13use crate::{Body, Exchange, Value};
14
15/// A single segment in a body JSON path.
16///
17/// Lives in `camel-api` so both SQL (`ExchangeLookupPath`) and Simple
18/// (`Expr::BodyField`) share the same segment type without cyclic deps.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum PathSegment {
21    /// Named object field: `"name"`, `"user"`.
22    Key(String),
23    /// Array index: `0`, `1`. Leading-zero strings (`"01"`) are NOT indexes —
24    /// they are `Key("01")` — matching the existing Simple language rule.
25    Index(usize),
26}
27
28/// A parsed Exchange lookup path. See module docs for the grammar.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum ExchangeLookupPath {
31    /// `body.a.b.c` — walk JSON tree via path segments. Empty vec means
32    /// "whole body" (corresponds to Simple `${body}`).
33    Body(Vec<PathSegment>),
34    /// `header.some.name` — flat key `"some.name"` (headers are flat maps).
35    Header(String),
36    /// `property.some.name` / `exchangeProperty.some.name` — flat key.
37    Property(String),
38    /// `foo` — unscoped: try body JSON key (flat), then header (flat), then
39    /// property (flat). The full token is the key in each scope.
40    Unscoped(String),
41}
42
43/// Error raised by [`ExchangeLookupPath::parse`]. Aligns with ADR-0016 strict
44/// rejection: ambiguous / malformed paths are reported, never silently coerced.
45#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
46pub enum LookupPathError {
47    /// The whole input was empty.
48    #[error("empty lookup path")]
49    Empty,
50    /// A path segment between dots was empty (e.g. `body..name`).
51    #[error("empty path segment in {input:?}")]
52    EmptySegment { input: String },
53    /// The path ended with a trailing dot (e.g. `body.`).
54    #[error("trailing dot in {input:?}")]
55    TrailingDot { input: String },
56    /// The scope prefix was given but no key followed (e.g. `header.`).
57    #[error("scope prefix {scope:?} requires a non-empty key")]
58    EmptyScopedKey { scope: String, input: String },
59}
60
61impl ExchangeLookupPath {
62    pub fn parse(s: &str) -> Result<Self, LookupPathError> {
63        if s.is_empty() {
64            return Err(LookupPathError::Empty);
65        }
66
67        // Scope detection: split on the FIRST dot only. The remainder stays
68        // verbatim (header keys and property keys may contain dots themselves;
69        // only `body.` walks segments).
70        let (head, rest_opt) = match s.split_once('.') {
71            Some((h, r)) => (h, Some(r)),
72            None => (s, None),
73        };
74
75        match head {
76            "body" => {
77                let Some(rest) = rest_opt else {
78                    // `body` alone → whole body.
79                    return Ok(ExchangeLookupPath::Body(Vec::new()));
80                };
81                if rest.is_empty() {
82                    return Err(LookupPathError::TrailingDot { input: s.into() });
83                }
84                let segments = parse_body_segments(rest, s)?;
85                Ok(ExchangeLookupPath::Body(segments))
86            }
87            "header" => {
88                let Some(rest) = rest_opt else {
89                    // `header` with no key is invalid (a header lookup needs a key).
90                    return Err(LookupPathError::EmptyScopedKey {
91                        scope: "header".into(),
92                        input: s.into(),
93                    });
94                };
95                if rest.is_empty() {
96                    return Err(LookupPathError::EmptyScopedKey {
97                        scope: "header".into(),
98                        input: s.into(),
99                    });
100                }
101                Ok(ExchangeLookupPath::Header(rest.into()))
102            }
103            "property" | "exchangeProperty" => {
104                let scope = head;
105                let Some(rest) = rest_opt else {
106                    return Err(LookupPathError::EmptyScopedKey {
107                        scope: scope.into(),
108                        input: s.into(),
109                    });
110                };
111                if rest.is_empty() {
112                    return Err(LookupPathError::EmptyScopedKey {
113                        scope: scope.into(),
114                        input: s.into(),
115                    });
116                }
117                Ok(ExchangeLookupPath::Property(rest.into()))
118            }
119            _ => {
120                // No reserved prefix → unscoped. The full token is the flat key
121                // tried against body / header / property in that order.
122                Ok(ExchangeLookupPath::Unscoped(s.into()))
123            }
124        }
125    }
126
127    /// Resolve this path against an Exchange. Returns `None` when the path
128    /// does not match (caller decides whether that is an error).
129    pub fn lookup(&self, exchange: &Exchange) -> Option<Value> {
130        match self {
131            ExchangeLookupPath::Body(segments) => lookup_body(exchange, segments),
132            ExchangeLookupPath::Header(key) => exchange.input.header(key).cloned(),
133            ExchangeLookupPath::Property(key) => exchange.property(key).cloned(),
134            ExchangeLookupPath::Unscoped(token) => {
135                // 1. Body JSON object flat key.
136                if let Some(value) = body_json_object(exchange).and_then(|obj| obj.get(token)) {
137                    return Some(value.clone());
138                }
139                // 2. Header flat key.
140                if let Some(value) = exchange.input.header(token) {
141                    return Some(value.clone());
142                }
143                // 3. Property flat key.
144                exchange.property(token).cloned()
145            }
146        }
147    }
148}
149
150fn body_json_object(exchange: &Exchange) -> Option<&serde_json::Map<String, Value>> {
151    match &exchange.input.body {
152        Body::Json(value) => value.as_object(),
153        _ => None,
154    }
155}
156
157fn lookup_body(exchange: &Exchange, segments: &[PathSegment]) -> Option<Value> {
158    let Body::Json(value) = &exchange.input.body else {
159        return None;
160    };
161    if segments.is_empty() {
162        // `${body}` — whole body value.
163        return Some(value.clone());
164    }
165    let mut current = value;
166    for seg in segments {
167        current = match seg {
168            PathSegment::Key(k) => current.as_object().and_then(|obj| obj.get(k))?,
169            PathSegment::Index(i) => current.as_array().and_then(|arr| arr.get(*i))?,
170        };
171    }
172    Some(current.clone())
173}
174
175/// Parse the dotted segment list AFTER `body.`. Each segment is either a
176/// `Key(string)` or, when it parses as `usize` with no leading zero (except
177/// "0" itself), an `Index(n)`. Mirrors the existing Simple language rule so
178/// Simple's `parse_body_path` regression tests pass unchanged.
179fn parse_body_segments(path: &str, full_input: &str) -> Result<Vec<PathSegment>, LookupPathError> {
180    let mut segments = Vec::new();
181    for seg in path.split('.') {
182        if seg.is_empty() {
183            return Err(LookupPathError::EmptySegment {
184                input: full_input.into(),
185            });
186        }
187        let parsed = seg
188            .parse::<usize>()
189            .ok()
190            .filter(|_| seg == "0" || !seg.starts_with('0'));
191        match parsed {
192            Some(i) => segments.push(PathSegment::Index(i)),
193            None => segments.push(PathSegment::Key(seg.into())),
194        }
195    }
196    Ok(segments)
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn parse_unscoped_token() {
205        assert_eq!(
206            ExchangeLookupPath::parse("my-param"),
207            Ok(ExchangeLookupPath::Unscoped("my-param".into()))
208        );
209        assert_eq!(
210            ExchangeLookupPath::parse("foo.bar"),
211            Ok(ExchangeLookupPath::Unscoped("foo.bar".into()))
212        );
213    }
214
215    #[test]
216    fn parse_body_scope_walks_segments() {
217        assert_eq!(
218            ExchangeLookupPath::parse("body.user.address.city"),
219            Ok(ExchangeLookupPath::Body(vec![
220                PathSegment::Key("user".into()),
221                PathSegment::Key("address".into()),
222                PathSegment::Key("city".into()),
223            ]))
224        );
225    }
226
227    #[test]
228    fn parse_body_scope_with_numeric_index() {
229        assert_eq!(
230            ExchangeLookupPath::parse("body.items.0"),
231            Ok(ExchangeLookupPath::Body(vec![
232                PathSegment::Key("items".into()),
233                PathSegment::Index(0),
234            ]))
235        );
236    }
237
238    #[test]
239    fn parse_body_scope_leading_zero_is_key_not_index() {
240        // Matches existing Simple language rule: "01" parses as Key("01"), not Index(1).
241        assert_eq!(
242            ExchangeLookupPath::parse("body.01"),
243            Ok(ExchangeLookupPath::Body(vec![PathSegment::Key(
244                "01".into()
245            )]))
246        );
247    }
248
249    #[test]
250    fn parse_body_scope_bare_is_empty_segments() {
251        // `${body}` in Simple means "whole body". Represent as Body(vec![]).
252        assert_eq!(
253            ExchangeLookupPath::parse("body"),
254            Ok(ExchangeLookupPath::Body(vec![]))
255        );
256    }
257
258    #[test]
259    fn parse_header_scope_flat_key() {
260        assert_eq!(
261            ExchangeLookupPath::parse("header.some.name"),
262            Ok(ExchangeLookupPath::Header("some.name".into()))
263        );
264    }
265
266    #[test]
267    fn parse_property_scope_flat_key() {
268        assert_eq!(
269            ExchangeLookupPath::parse("property.some.name"),
270            Ok(ExchangeLookupPath::Property("some.name".into()))
271        );
272    }
273
274    #[test]
275    fn parse_exchange_property_alias() {
276        assert_eq!(
277            ExchangeLookupPath::parse("exchangeProperty.myKey"),
278            Ok(ExchangeLookupPath::Property("myKey".into()))
279        );
280    }
281
282    #[test]
283    fn parse_rejects_empty_input() {
284        assert_eq!(ExchangeLookupPath::parse(""), Err(LookupPathError::Empty));
285    }
286
287    #[test]
288    fn parse_rejects_trailing_dot() {
289        let err = ExchangeLookupPath::parse("body.").unwrap_err();
290        assert!(
291            matches!(err, LookupPathError::TrailingDot { .. }),
292            "{err:?}"
293        );
294    }
295
296    #[test]
297    fn parse_rejects_empty_segment_in_body_path() {
298        let err = ExchangeLookupPath::parse("body..name").unwrap_err();
299        assert!(
300            matches!(err, LookupPathError::EmptySegment { .. }),
301            "{err:?}"
302        );
303    }
304
305    #[test]
306    fn parse_rejects_empty_scoped_key_for_header() {
307        // `header.` with nothing after is a trailing dot but reported as
308        // EmptyScopedKey because the scope prefix was explicit.
309        let err = ExchangeLookupPath::parse("header.").unwrap_err();
310        assert!(
311            matches!(err, LookupPathError::EmptyScopedKey { .. }),
312            "{err:?}"
313        );
314    }
315
316    #[test]
317    fn lookup_walks_nested_body_json() {
318        use crate::{Body, Exchange, Message};
319        let msg = Message::new(Body::Json(serde_json::json!({
320            "user": { "address": { "city": "Berlin" } }
321        })));
322        let ex = Exchange::new(msg);
323
324        let path = ExchangeLookupPath::parse("body.user.address.city").unwrap();
325        assert_eq!(path.lookup(&ex), Some(serde_json::json!("Berlin")));
326    }
327
328    #[test]
329    fn lookup_walks_body_array_index() {
330        use crate::{Body, Exchange, Message};
331        let msg = Message::new(Body::Json(serde_json::json!({
332            "items": [10, 20, 30]
333        })));
334        let ex = Exchange::new(msg);
335
336        let path = ExchangeLookupPath::parse("body.items.1").unwrap();
337        assert_eq!(path.lookup(&ex), Some(serde_json::json!(20)));
338    }
339
340    #[test]
341    fn lookup_body_whole_returns_full_body_value() {
342        use crate::{Body, Exchange, Message};
343        let msg = Message::new(Body::Json(serde_json::json!({"a": 1})));
344        let ex = Exchange::new(msg);
345
346        let path = ExchangeLookupPath::parse("body").unwrap();
347        assert_eq!(path.lookup(&ex), Some(serde_json::json!({"a": 1})));
348    }
349
350    #[test]
351    fn lookup_body_returns_none_when_not_json() {
352        use crate::{Body, Exchange, Message};
353        let msg = Message::new(Body::Text("hello".into()));
354        let ex = Exchange::new(msg);
355
356        let path = ExchangeLookupPath::parse("body.user").unwrap();
357        assert_eq!(path.lookup(&ex), None);
358    }
359
360    #[test]
361    fn lookup_body_returns_none_when_path_misses() {
362        use crate::{Body, Exchange, Message};
363        let msg = Message::new(Body::Json(serde_json::json!({"a": 1})));
364        let ex = Exchange::new(msg);
365
366        let path = ExchangeLookupPath::parse("body.b.c").unwrap();
367        assert_eq!(path.lookup(&ex), None);
368    }
369
370    #[test]
371    fn lookup_header_flat_dotted_key() {
372        use crate::{Exchange, Message};
373        let mut msg = Message::default();
374        msg.set_header("some.name", serde_json::json!(42));
375        let ex = Exchange::new(msg);
376
377        let path = ExchangeLookupPath::parse("header.some.name").unwrap();
378        assert_eq!(path.lookup(&ex), Some(serde_json::json!(42)));
379    }
380
381    #[test]
382    fn lookup_property_flat_dotted_key() {
383        use crate::{Exchange, Message};
384        let mut ex = Exchange::new(Message::default());
385        ex.set_property("config.key", serde_json::json!("v"));
386
387        let path = ExchangeLookupPath::parse("property.config.key").unwrap();
388        assert_eq!(path.lookup(&ex), Some(serde_json::json!("v")));
389    }
390
391    #[test]
392    fn lookup_unscoped_fallback_body_then_header_then_property() {
393        use crate::{Body, Exchange, Message};
394        // Body wins over header.
395        let mut msg = Message::new(Body::Json(serde_json::json!({"id": 1})));
396        msg.set_header("id", serde_json::json!(2));
397        let ex = Exchange::new(msg);
398        let path = ExchangeLookupPath::parse("id").unwrap();
399        assert_eq!(path.lookup(&ex), Some(serde_json::json!(1)));
400    }
401
402    #[test]
403    fn lookup_unscoped_falls_through_to_header() {
404        use crate::{Exchange, Message};
405        let mut msg = Message::default();
406        msg.set_header("token", serde_json::json!("abc"));
407        let ex = Exchange::new(msg);
408        let path = ExchangeLookupPath::parse("token").unwrap();
409        assert_eq!(path.lookup(&ex), Some(serde_json::json!("abc")));
410    }
411
412    #[test]
413    fn lookup_unscoped_falls_through_to_property() {
414        use crate::{Exchange, Message};
415        let mut ex = Exchange::new(Message::default());
416        ex.set_property("tenant", serde_json::json!("acme"));
417        let path = ExchangeLookupPath::parse("tenant").unwrap();
418        assert_eq!(path.lookup(&ex), Some(serde_json::json!("acme")));
419    }
420
421    #[test]
422    fn lookup_unscoped_returns_none_when_missing_everywhere() {
423        use crate::{Exchange, Message};
424        let ex = Exchange::new(Message::default());
425        let path = ExchangeLookupPath::parse("nope").unwrap();
426        assert_eq!(path.lookup(&ex), None);
427    }
428}