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