camel_integration_test/document/
validate.rs1use std::collections::BTreeMap;
12
13use camel_api::Value;
14use camel_matchers::{CountBound, Expectation, PathFilter, RowsExpectation};
15
16use super::{DocError, EndpointRef, PartnerExpectation};
17
18#[derive(Debug, Clone, PartialEq)]
20#[non_exhaustive]
21pub enum ScenarioTarget {
22 LastReceived(EndpointRef),
24 Variable(String),
27 Partner(EndpointRef),
34 Sql(SqlTarget),
39}
40
41#[derive(Debug, Clone, PartialEq)]
48pub struct SqlTarget {
49 pub datasource: String,
51 pub query: String,
53}
54
55#[derive(Debug, Clone, PartialEq)]
60#[non_exhaustive]
61pub enum ValidateExpectation {
62 Message(Expectation),
64 Partner(PartnerExpectation),
66 Rows(RowsExpectation),
70}
71
72fn 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
87pub(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
151pub(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
291pub(super) fn backticked(fields: &[&str]) -> String {
293 fields
294 .iter()
295 .map(|field| format!("`{field}`"))
296 .collect::<Vec<_>>()
297 .join(", ")
298}
299
300pub(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 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 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 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 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
482pub(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}