Skip to main content

camel_integration_test/document/
validate.rs

1//! The `validate` grammar: what a `validate` action asserts against
2//! (target), the paired expectation shapes, and the expectation
3//! parsers (bd rc-m6xr, split out of the parent module; mirrors the
4//! `document/error.rs` and `partner_script.rs` patterns).
5//!
6//! `ScenarioTarget`, `SqlTarget`, and `ValidateExpectation` are
7//! re-exported at `crate::document` and the crate root, so consumers
8//! keep the paths they had before the split. `PartnerExpectation`
9//! stays the `camel_matchers::RequestExpectation` alias.
10
11use std::collections::BTreeMap;
12
13use camel_api::Value;
14use camel_matchers::{CountBound, Expectation, PathFilter, RowsExpectation};
15
16use super::{DocError, EndpointRef, PartnerExpectation};
17
18/// What a `validate` action asserts against.
19#[derive(Debug, Clone, PartialEq)]
20#[non_exhaustive]
21pub enum ScenarioTarget {
22    /// The last message received on the endpoint.
23    LastReceived(EndpointRef),
24    /// A scenario variable set by an earlier `extract`. Variable
25    /// existence is validated at run time.
26    Variable(String),
27    /// A partner endpoint: the assertion reads the partner's recorded
28    /// request traffic. The URI must equal a harness endpoint
29    /// reference declared by the scenario's own `send`/`receive`
30    /// actions, or self-declare the reference: an object form with
31    /// `provisioning: harness` on an `http` URI that also has a `partners:`
32    /// entry naming it.
33    Partner(EndpointRef),
34    /// A named datasource: the assertion executes the doc-authored
35    /// read and validates the returned rows. Reads only — the `sql:`
36    /// prepare action owns mutations, and the two vocabularies never
37    /// mix (bd rc-25lup.2).
38    Sql(SqlTarget),
39}
40
41/// The sql `validate` target payload (bd rc-25lup.2): a read against a
42/// configured datasource. The datasource obeys the identifier law: it
43/// names an entry under `[datasources.*]` in `Camel.toml` and is never
44/// interpolated. The query is doc-authored read text; every statement
45/// that fails [`crate::sql_action::is_read_statement`] is rejected at
46/// load — the `sql:` prepare action owns mutations.
47#[derive(Debug, Clone, PartialEq)]
48pub struct SqlTarget {
49    /// The datasource name as declared under `[datasources.*]`.
50    pub datasource: String,
51    /// The read query executed against the datasource's pool.
52    pub query: String,
53}
54
55/// The expectation of a `validate` action, keyed by its target: the
56/// message matcher grammar for `lastReceived` and `variable` targets,
57/// the partner count grammar for `partner` targets, the sql-target
58/// row shape for `sql` targets.
59#[derive(Debug, Clone, PartialEq)]
60#[non_exhaustive]
61pub enum ValidateExpectation {
62    /// Message matcher expectation (`lastReceived` / `variable`).
63    Message(Expectation),
64    /// Partner request-count expectation (`partner`).
65    Partner(PartnerExpectation),
66    /// The sql-target row shape: concrete row patterns (`rows`) or a
67    /// row-count bound (`bound`), with an optional named projection
68    /// and order flag. `Message` and `Partner` unchanged.
69    Rows(RowsExpectation),
70}
71
72/// Recognized expectation matcher keys.
73fn is_matcher_key(key: &str) -> bool {
74    matches!(
75        key,
76        "equals"
77            | "regex"
78            | "contains"
79            | "startsWith"
80            | "endsWith"
81            | "exists"
82            | "ignore"
83            | "jsonSubset"
84    )
85}
86
87/// Applies the expectation dual grammar: a bare value is a literal
88/// `equals`; an object whose single key is a recognized matcher key is
89/// that matcher; any other object is a literal `equals`. Payload shapes
90/// mirror the mock-testkit matcher rules. The field name parameter
91/// (`expectation`, `expectReply`) keeps one verb parser behind both
92/// readers (rc-qvz6): the verbs never fork between `validate` and
93/// send-level reply assertions.
94pub(crate) fn expectation_from_value(
95    value: &Value,
96    index: usize,
97    field: &'static str,
98) -> Result<Expectation, DocError> {
99    let invalid = |message: String| DocError::Validation { index, message };
100    if let Value::Object(map) = value
101        && map.len() == 1
102        && let Some((key, payload)) = map.iter().next()
103        && is_matcher_key(key)
104    {
105        return match key.as_str() {
106            "equals" => Ok(Expectation::Equals(payload.clone())),
107            "regex" | "contains" | "startsWith" | "endsWith" => {
108                let Some(pattern) = payload.as_str() else {
109                    return Err(invalid(format!(
110                        "{field}: `{key}` requires a string payload"
111                    )));
112                };
113                if key.as_str() == "regex"
114                    && let Err(e) = regex::Regex::new(pattern)
115                {
116                    return Err(invalid(format!("{field}: invalid regex `{pattern}`: {e}")));
117                }
118                Ok(match key.as_str() {
119                    "regex" => Expectation::Regex(pattern.to_string()),
120                    "contains" => Expectation::Contains(pattern.to_string()),
121                    "startsWith" => Expectation::StartsWith(pattern.to_string()),
122                    _ => Expectation::EndsWith(pattern.to_string()),
123                })
124            }
125            "exists" => {
126                if payload.is_null() {
127                    Ok(Expectation::Exists)
128                } else {
129                    Err(invalid(format!("{field}: `exists` takes no argument")))
130                }
131            }
132            "ignore" => {
133                if payload.is_null() {
134                    Ok(Expectation::Any)
135                } else {
136                    Err(invalid(format!("{field}: `ignore` takes no argument")))
137                }
138            }
139            _ => {
140                if payload.is_object() {
141                    Ok(Expectation::JsonSubset(payload.clone()))
142                } else {
143                    Err(invalid(format!("{field}: `jsonSubset` must be an object")))
144                }
145            }
146        };
147    }
148    Ok(Expectation::Equals(value.clone()))
149}
150
151/// Applies the partner expectation grammar: a map with exactly one
152/// count bound (`count`; or `atLeast`, `atMost`, or their range), an
153/// optional `method` string, at most one path filter (`path`,
154/// `pathContains`, `pathMatches` — the regex compiled at load), and
155/// an optional `query` subset map of string keys to string values;
156/// unknown keys fail. Field-by-field extraction, like the
157/// endpoint-reference reader, so errors name the offending key.
158pub(super) fn partner_expectation_from_value(
159    value: &Value,
160    index: usize,
161) -> Result<PartnerExpectation, DocError> {
162    const FIELD: &str = "partner expectation";
163    const KEYS: &[&str] = &[
164        "count",
165        "atLeast",
166        "atMost",
167        "method",
168        "path",
169        "pathContains",
170        "pathMatches",
171        "query",
172    ];
173    let invalid = |message: String| DocError::Validation { index, message };
174    let Value::Object(map) = value else {
175        return Err(invalid(format!(
176            "{FIELD} must be a map with a count bound, got {value:?}"
177        )));
178    };
179    let mut count: Option<u64> = None;
180    let mut at_least: Option<u64> = None;
181    let mut at_most: Option<u64> = None;
182    let mut method: Option<String> = None;
183    let mut path: Option<PathFilter> = None;
184    let mut path_key: Option<&str> = None;
185    let mut query: Option<BTreeMap<String, String>> = None;
186    for (key, payload) in map {
187        match key.as_str() {
188            "count" | "atLeast" | "atMost" => {
189                let bound = payload.as_u64().ok_or_else(|| {
190                    invalid(format!(
191                        "{FIELD}: `{key}` must be a non-negative integer, got {payload}"
192                    ))
193                })?;
194                match key.as_str() {
195                    "count" => count = Some(bound),
196                    "atLeast" => at_least = Some(bound),
197                    _ => at_most = Some(bound),
198                }
199            }
200            "method" => {
201                let text = payload.as_str().ok_or_else(|| {
202                    invalid(format!("{FIELD}: `{key}` must be a string, got {payload}"))
203                })?;
204                method = Some(text.to_string());
205            }
206            "path" | "pathContains" | "pathMatches" => {
207                if let Some(first) = path_key {
208                    return Err(invalid(format!(
209                        "{FIELD}: `{first}` and `{key}` are exclusive: at most one path filter"
210                    )));
211                }
212                let text = payload.as_str().ok_or_else(|| {
213                    invalid(format!("{FIELD}: `{key}` must be a string, got {payload}"))
214                })?;
215                path = Some(match key.as_str() {
216                    "path" => PathFilter::Exact(text.to_string()),
217                    "pathContains" => PathFilter::Contains(text.to_string()),
218                    _ => {
219                        if let Err(e) = regex::Regex::new(text) {
220                            return Err(invalid(format!("{FIELD}: invalid regex `{text}`: {e}")));
221                        }
222                        PathFilter::Matches(text.to_string())
223                    }
224                });
225                path_key = Some(key.as_str());
226            }
227            "query" => {
228                let Value::Object(pairs) = payload else {
229                    return Err(invalid(format!(
230                        "{FIELD}: `query` must be a map of string keys to string values, got {payload}"
231                    )));
232                };
233                let mut subset = BTreeMap::new();
234                for (name, pair) in pairs {
235                    let Some(text) = pair.as_str() else {
236                        return Err(invalid(format!(
237                            "{FIELD}: `query` value for `{name}` must be a string, got {pair}"
238                        )));
239                    };
240                    subset.insert(name.clone(), text.to_string());
241                }
242                query = Some(subset);
243            }
244            other => {
245                return Err(invalid(format!(
246                    "{FIELD}: unknown field `{other}`; expected {}",
247                    backticked(KEYS)
248                )));
249            }
250        }
251    }
252    if count.is_some() && (at_least.is_some() || at_most.is_some()) {
253        let mut others: Vec<&str> = Vec::new();
254        if at_least.is_some() {
255            others.push("atLeast");
256        }
257        if at_most.is_some() {
258            others.push("atMost");
259        }
260        return Err(invalid(format!(
261            "{FIELD}: `count` and {} are exclusive: declare exactly one bound form",
262            backticked(&others)
263        )));
264    }
265    let bound = if let Some(exact) = count {
266        CountBound::Exact(exact)
267    } else if let (Some(min), Some(max)) = (at_least, at_most) {
268        if min > max {
269            return Err(invalid(format!(
270                "{FIELD}: `atLeast` ({min}) must not exceed `atMost` ({max})"
271            )));
272        }
273        CountBound::Range(min, max)
274    } else if let Some(n) = at_least {
275        CountBound::AtLeast(n)
276    } else if let Some(n) = at_most {
277        CountBound::AtMost(n)
278    } else {
279        return Err(invalid(format!(
280            "{FIELD}: requires a count bound: `count`, `atLeast`, or `atMost`"
281        )));
282    };
283    Ok(PartnerExpectation {
284        bound,
285        method,
286        path,
287        query,
288    })
289}
290
291/// Backticks and comma-joins field names for error messages.
292pub(super) fn backticked(fields: &[&str]) -> String {
293    fields
294        .iter()
295        .map(|field| format!("`{field}`"))
296        .collect::<Vec<_>>()
297        .join(", ")
298}
299
300/// Applies the sql-target expectation grammar (bd rc-25lup.2): either
301/// concrete row patterns (`rows`, an optional `columns` projection, an
302/// optional `unordered` flag) or a row-count bound (`count`, `atLeast`,
303/// `atMost` — the partner count-key semantics: exactly one bound form,
304/// `atLeast <= atMost` for a range) — never both. Unknown keys fail.
305/// Field-by-field extraction, like the partner reader, so errors name
306/// the offending key and, for row shapes, the offending row index.
307pub(crate) fn sql_expectation_from_value(
308    value: &Value,
309    index: usize,
310) -> Result<RowsExpectation, DocError> {
311    const FIELD: &str = "sql expectation";
312    const KEYS: &[&str] = &["rows", "columns", "unordered", "count", "atLeast", "atMost"];
313    let invalid = |message: String| DocError::Validation { index, message };
314    let Value::Object(map) = value else {
315        return Err(invalid(format!(
316            "{FIELD} must be a map with `rows` or a count bound, got {value:?}"
317        )));
318    };
319    let mut rows: Option<Vec<Vec<Expectation>>> = None;
320    let mut columns: Option<Vec<String>> = None;
321    let mut unordered = false;
322    let mut count: Option<u64> = None;
323    let mut at_least: Option<u64> = None;
324    let mut at_most: Option<u64> = None;
325    for (key, payload) in map {
326        match key.as_str() {
327            "rows" => {
328                let Value::Array(raw_rows) = payload else {
329                    return Err(invalid(format!(
330                        "{FIELD}: `rows` must be a sequence of rows, got {payload}"
331                    )));
332                };
333                if raw_rows.is_empty() {
334                    return Err(invalid(format!("{FIELD}: `rows` must not be empty")));
335                }
336                let mut parsed_rows = Vec::with_capacity(raw_rows.len());
337                for (row_index, raw_row) in raw_rows.iter().enumerate() {
338                    let Value::Array(cells) = raw_row else {
339                        return Err(invalid(format!(
340                            "{FIELD}: `rows` row {row_index} must be a sequence of cell \
341                             expectations, got {raw_row}"
342                        )));
343                    };
344                    let mut row = Vec::with_capacity(cells.len());
345                    for cell in cells {
346                        row.push(expectation_from_value(cell, index, "rows")?);
347                    }
348                    parsed_rows.push(row);
349                }
350                rows = Some(parsed_rows);
351            }
352            "columns" => {
353                let Value::Array(raw_names) = payload else {
354                    return Err(invalid(format!(
355                        "{FIELD}: `columns` must be a sequence of column names, got {payload}"
356                    )));
357                };
358                if raw_names.is_empty() {
359                    return Err(invalid(format!("{FIELD}: `columns` must not be empty")));
360                }
361                let mut names = Vec::with_capacity(raw_names.len());
362                for raw_name in raw_names {
363                    let Some(name) = raw_name.as_str() else {
364                        return Err(invalid(format!(
365                            "{FIELD}: `columns` entries must be strings, got {raw_name}"
366                        )));
367                    };
368                    if names.iter().any(|existing| existing == name) {
369                        return Err(invalid(format!("{FIELD}: duplicate column name `{name}`")));
370                    }
371                    names.push(name.to_string());
372                }
373                columns = Some(names);
374            }
375            "unordered" => {
376                let Some(flag) = payload.as_bool() else {
377                    return Err(invalid(format!(
378                        "{FIELD}: `unordered` must be a boolean, got {payload}"
379                    )));
380                };
381                unordered = flag;
382            }
383            "count" | "atLeast" | "atMost" => {
384                let bound = payload.as_u64().ok_or_else(|| {
385                    invalid(format!(
386                        "{FIELD}: `{key}` must be a non-negative integer, got {payload}"
387                    ))
388                })?;
389                match key.as_str() {
390                    "count" => count = Some(bound),
391                    "atLeast" => at_least = Some(bound),
392                    _ => at_most = Some(bound),
393                }
394            }
395            other => {
396                return Err(invalid(format!(
397                    "{FIELD}: unknown field `{other}`; expected {}",
398                    backticked(KEYS)
399                )));
400            }
401        }
402    }
403    // Row patterns and a count bound describe different subjects (the
404    // returned rows vs their number): declaring both has no meaning.
405    if rows.is_some() && (count.is_some() || at_least.is_some() || at_most.is_some()) {
406        let mut bound_keys: Vec<&str> = Vec::new();
407        if count.is_some() {
408            bound_keys.push("count");
409        }
410        if at_least.is_some() {
411            bound_keys.push("atLeast");
412        }
413        if at_most.is_some() {
414            bound_keys.push("atMost");
415        }
416        return Err(invalid(format!(
417            "{FIELD}: `rows` and {} are exclusive: declare either row patterns or a row-count \
418             bound",
419            backticked(&bound_keys)
420        )));
421    }
422    // The count keys themselves follow the partner exclusivity law:
423    // exactly one bound form, and a range needs `atLeast <= atMost`.
424    if count.is_some() && (at_least.is_some() || at_most.is_some()) {
425        let mut others: Vec<&str> = Vec::new();
426        if at_least.is_some() {
427            others.push("atLeast");
428        }
429        if at_most.is_some() {
430            others.push("atMost");
431        }
432        return Err(invalid(format!(
433            "{FIELD}: `count` and {} are exclusive: declare exactly one bound form",
434            backticked(&others)
435        )));
436    }
437    let bound = if let Some(exact) = count {
438        Some(CountBound::Exact(exact))
439    } else if let (Some(min), Some(max)) = (at_least, at_most) {
440        if min > max {
441            return Err(invalid(format!(
442                "{FIELD}: `atLeast` ({min}) must not exceed `atMost` ({max})"
443            )));
444        }
445        Some(CountBound::Range(min, max))
446    } else if let Some(n) = at_least {
447        Some(CountBound::AtLeast(n))
448    } else {
449        at_most.map(CountBound::AtMost)
450    };
451    // An expectation that names neither shape asserts nothing and
452    // almost certainly hides a typo'd key.
453    if rows.is_none() && bound.is_none() {
454        return Err(invalid(format!(
455            "{FIELD}: requires either `rows` or a count bound: `count`, `atLeast`, or `atMost`"
456        )));
457    }
458    // When `columns` is declared, every row must match its width: the
459    // projection happens by name at the call site, so a mismatched row
460    // would silently misalign. Without `columns`, widths are checked
461    // at execution against the query's projection.
462    if let (Some(columns), Some(rows)) = (&columns, &rows) {
463        for (row_index, row) in rows.iter().enumerate() {
464            if row.len() != columns.len() {
465                return Err(invalid(format!(
466                    "{FIELD}: row {row_index} declares {} cells but `columns` names {}; the \
467                     widths must match",
468                    row.len(),
469                    columns.len()
470                )));
471            }
472        }
473    }
474    Ok(RowsExpectation {
475        columns,
476        unordered,
477        rows,
478        bound,
479    })
480}
481
482/// Whether the sql query carries no `ORDER BY` clause (bd rc-25lup.2):
483/// a case-insensitive token search for `order` and `by` separated by
484/// whitespace (`\s+` covers `ORDER\nBY`). A string literal containing
485/// the words (`select 'totally ordered by intent' from t`) trips the
486/// predicate — a documented false positive; the advisory only warns,
487/// it never rejects. The pattern is static: the `is_ok_and` fallback
488/// treats an impossible compile failure as "lacks", which only
489/// over-warns.
490pub(crate) fn sql_query_lacks_order_by(query: &str) -> bool {
491    !regex::Regex::new(r"(?i)\border\s+by\b").is_ok_and(|order_by| order_by.is_match(query))
492}