sendra_core/capture.rs
1//! Values a request pulls out of its response and hands to the requests after
2//! it.
3//!
4//! A request file may carry a `capture` block: variable names mapped to a
5//! source to read from the response once it arrives. The default source,
6//! and originally the only one, is a JSON path evaluated against the
7//! response body — a bare string still means exactly that, for every file
8//! already written against it. Two more sources are read from a response's
9//! envelope rather than its body: a named header, and the status code
10//! itself.
11//!
12//! ```yaml
13//! name: Log in
14//! method: POST
15//! url: https://api.example.com/login
16//! capture:
17//! auth_token: $.token # JSON path (default, bare string)
18//! user_id: $.user.id
19//! session_id:
20//! header: Set-Cookie # a response header
21//! request_status:
22//! status: true # the numeric status, as a string
23//! ```
24//!
25//! # Header capture and repeated headers
26//!
27//! Header names are matched case-insensitively, the way [`assertions`
28//! matches them](crate::assertions). A header that does not repeat captures
29//! its one value. A header that repeats (`Set-Cookie` is the common case) is
30//! **ambiguous** rather than resolved by taking the first or the last: this
31//! mirrors the JSON-path rule that a path selecting more than one value is a
32//! failure rather than a silent pick (see [`CaptureFailure::Ambiguous`]).
33//! An assertion checking `headers: { set-cookie: ... }` passes if *any*
34//! repeated value matches, because it is testing a predicate; a capture
35//! binds a name to *one* value that later requests will substitute, and
36//! guessing which repeat that should be would make the same file behave
37//! differently depending on header order a server happens to send in. A
38//! file that wants one specific cookie out of several should be more
39//! specific than `header: Set-Cookie` can be today — see the module-level
40//! non-goal note below.
41//!
42//! # Only the final response's headers
43//!
44//! With `follow_redirects` on, header capture reads the headers of the
45//! response `evaluate` is called with — the *final* response in the chain.
46//! [`Response::redirects`](crate::Response) records each intermediate hop's
47//! status and the `Location` it pointed at, but deliberately does not carry
48//! that hop's full header set (see the type's own docs), so there is no
49//! intermediate `Set-Cookie` or other header for this to reach even if the
50//! schema grew a way to ask for one. Capturing the *final* hop's `Location`
51//! is possible today (a response that redirected already exposes its own
52//! `Location` if it is itself 3xx and redirects were disabled or exhausted),
53//! but reaching into an earlier hop is out of scope here: it would need
54//! `RedirectHop` extended to carry headers, which is a bigger, separate
55//! change. Documented as a non-goal rather than a partial `hop:` key that
56//! could only ever address the one field `RedirectHop` already has.
57//!
58//! Every name captured this way becomes usable as `{{auth_token}}` in **every
59//! request after this one, in file order**, through the same substitution pass
60//! an environment file feeds. Nothing is written anywhere: a capture lives for
61//! the rest of one `sendra run` or `sendra test` invocation and no longer. A
62//! fresh process starts with nothing captured, which is the same non-goal
63//! environments shipped with.
64//!
65//! # A capture is not a check
66//!
67//! [`Captures::evaluate`] returns a [`CaptureReport`] and no `Result`, exactly
68//! as [`Assertions::evaluate`](crate::Assertions::evaluate) does, and for the
69//! same reason: the response has already arrived, so there is nothing left to
70//! abort, and the only useful thing to do with a capture that did not work is
71//! to say precisely how it did not work, next to the ones that did.
72//!
73//! But it is not an assertion either, and the difference decides how a
74//! front-end counts it. An assertion is an expectation about the response; a
75//! capture is a *dependency of the rest of the run*. So a capture that succeeds
76//! says nothing about whether the response was correct — a request that
77//! captured a token and asserted nothing was still not checked — while a
78//! capture that fails is a genuine failure, because a value the file promised
79//! to the requests downstream is not there. See [`CaptureReport::passed`] and
80//! the `Summary` type in `sendra-cli` for where that lands.
81//!
82//! # Why the failures are typed rather than [`SendraError`]s
83//!
84//! A [`SendraError`] means "this request could not be completed", and every
85//! variant of it is raised on a path where there is no response: a file that
86//! does not parse, a `{{var}}` with nothing behind it, a refused connection, a
87//! `pre_request` script that threw. A capture failure is the opposite shape —
88//! the response arrived, was read, and did not contain what the file said it
89//! would — and folding it into that enum would have put it in the one category
90//! it is definitely not in.
91//!
92//! Typing it as [`CaptureFailure`] instead also keeps the block's entries
93//! independent: three captures against one response produce three results, the
94//! way three assertions do, rather than the first failure discarding whatever
95//! the other two would have found.
96
97use std::collections::BTreeMap;
98use std::path::PathBuf;
99
100use jsonpath_rust::JsonPath;
101use serde::{Deserialize, Serialize};
102
103use crate::environment::describe_environment;
104use crate::{Environment, Response};
105
106/// Where one `capture` entry reads its value from.
107///
108/// A bare string is the original and default form: a JSON path into the
109/// response body. The two additive forms are objects, so a bare string can
110/// never be confused with them: `header: Set-Cookie` reads a response
111/// header, and `status: true` reads the numeric status code.
112///
113/// `status: false` is rejected at parse time (a request file that says it
114/// does not want the status captured this way is not saying anything a
115/// schema should represent) rather than left to fail silently at evaluate
116/// time — the same call [`FollowRedirects`](crate::config::FollowRedirects)
117/// makes for a negative hop count.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
119#[serde(untagged)]
120pub enum CaptureSource {
121 // `JsonSchema` cannot be derived here: the shape below is not what
122 // `#[derive(JsonSchema)]` would infer from this enum plus its
123 // `#[serde(untagged)]`, because the real acceptance rule
124 // (`status: false` is rejected) lives in the hand-written `Deserialize`
125 // impl below, not in the enum's shape. See the manual `impl JsonSchema`
126 // a few lines down, which encodes that rule as `"const": true` — one of
127 // the few business rules from `Request::validate` and friends that a
128 // JSON Schema combinator genuinely can express, rather than merely
129 // approximate.
130 /// The default, bare-string form: a JSON path into the response body.
131 JsonPath(String),
132 /// An object form: `header: <name>`. Matched case-insensitively against
133 /// [`Response::headers`](crate::Response), the way
134 /// [`assertions`](crate::assertions) matches header names.
135 Header { header: String },
136 /// An object form: `status: true`. Captures the response's numeric
137 /// status code, rendered as a string (`"404"`, not `404`).
138 Status { status: bool },
139}
140
141impl<'de> Deserialize<'de> for CaptureSource {
142 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
143 where
144 D: serde::Deserializer<'de>,
145 {
146 // An intermediate shape so `status: false` can be told apart from
147 // `status: true` before committing to `CaptureSource::Status`, which
148 // (deliberately) has no way to spell "false" — see the type's docs.
149 #[derive(Deserialize)]
150 #[serde(untagged, deny_unknown_fields)]
151 enum Raw {
152 JsonPath(String),
153 Header { header: String },
154 Status { status: bool },
155 }
156
157 match Raw::deserialize(deserializer)? {
158 Raw::JsonPath(path) => Ok(CaptureSource::JsonPath(path)),
159 Raw::Header { header } => Ok(CaptureSource::Header { header }),
160 Raw::Status { status: true } => Ok(CaptureSource::Status { status: true }),
161 Raw::Status { status: false } => Err(serde::de::Error::custom(
162 "`status: false` does not capture anything; use `status: true` or remove this \
163 entry",
164 )),
165 }
166 }
167}
168
169/// Hand-written to match the hand-written [`Deserialize`] impl above rather
170/// than derived, and — unlike most of the manual impls in this crate — able
171/// to express the *whole* acceptance rule, `status: false` included: a JSON
172/// Schema validator that enforces `"const": true` on the `status` property
173/// will flag `status: false` as a schema violation, the same file Sendra's
174/// own parser rejects.
175#[cfg(feature = "schema")]
176impl schemars::JsonSchema for CaptureSource {
177 fn schema_name() -> std::borrow::Cow<'static, str> {
178 "CaptureSource".into()
179 }
180
181 fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
182 schemars::json_schema!({
183 "description": "Where one `capture` entry reads its value from: a bare string is a \
184 JSON path into the response body; `{ header: <name> }` reads a response header; \
185 `{ status: true }` captures the numeric status code. `status: false` is invalid.",
186 "oneOf": [
187 {
188 "type": "string",
189 "description": "A JSON path into the response body."
190 },
191 {
192 "type": "object",
193 "properties": { "header": { "type": "string" } },
194 "required": ["header"],
195 "additionalProperties": false
196 },
197 {
198 "type": "object",
199 "properties": { "status": { "type": "boolean", "const": true } },
200 "required": ["status"],
201 "additionalProperties": false
202 }
203 ]
204 })
205 }
206}
207
208impl CaptureSource {
209 /// The label a report shows for this entry — the path text unchanged
210 /// for the default JSON-path form (so [`CaptureResult::path`] and
211 /// everything reading it, `--json` output included, is untouched for
212 /// every file already written against the original bare-string form),
213 /// and a short description of the source for the two additive forms.
214 fn label(&self) -> String {
215 match self {
216 CaptureSource::JsonPath(path) => path.clone(),
217 CaptureSource::Header { header } => format!("header `{header}`"),
218 CaptureSource::Status { .. } => "status".to_string(),
219 }
220 }
221}
222
223/// The `capture` block of a request, exactly as it appears on disk: variable
224/// name to [`CaptureSource`].
225///
226/// A map with author-chosen keys, so unlike every *struct* in Sendra's schema
227/// there is no `deny_unknown_fields` to apply — every key here is data. The
228/// rule that a typo must not pass silently still holds, one level down: a path
229/// that selects nothing is a reported failure rather than a variable that
230/// quietly does not exist.
231///
232/// Names are not validated against a pattern. A variable is whatever
233/// `{{...}}` can spell, and an environment file has never restricted its own
234/// keys either; a name nothing references is harmless, and one that cannot be
235/// referenced is a mistake visible the moment it is used.
236#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
237#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
238#[serde(transparent)]
239pub struct Captures {
240 entries: BTreeMap<String, CaptureSource>,
241}
242
243impl Captures {
244 /// True when the block captures nothing — `capture: {}`.
245 pub fn is_empty(&self) -> bool {
246 self.entries.is_empty()
247 }
248
249 /// The variable names this block defines, sorted.
250 pub fn variables(&self) -> Vec<String> {
251 self.entries.keys().cloned().collect()
252 }
253
254 /// The name-to-source pairs, sorted by name.
255 pub fn entries(&self) -> &BTreeMap<String, CaptureSource> {
256 &self.entries
257 }
258
259 /// Extract every value this block names from `response`.
260 ///
261 /// `environment` is read for one thing only: a captured name that the
262 /// environment file already defines is rejected rather than allowed to
263 /// shadow it. See [`CaptureFailure::Shadowed`] for that decision.
264 ///
265 /// Entries are evaluated in sorted-name order and none short-circuits the
266 /// others, so a report always has exactly one result per entry — the same
267 /// contract [`AssertionReport`](crate::AssertionReport) makes.
268 pub fn evaluate(&self, response: &Response, environment: &Environment) -> CaptureReport {
269 if self.entries.is_empty() {
270 return CaptureReport::default();
271 }
272
273 // Parsed once for the whole block, not once per path: the body does not
274 // change between entries, and a body that is not JSON should report the
275 // same reason against every one of them.
276 let body = serde_json::from_str::<serde_json::Value>(&response.body);
277
278 CaptureReport {
279 results: self
280 .entries
281 .iter()
282 .map(|(variable, source)| {
283 capture_one(variable, source, body.as_ref(), response, environment)
284 })
285 .collect(),
286 }
287 }
288}
289
290impl FromIterator<(String, CaptureSource)> for Captures {
291 fn from_iter<T: IntoIterator<Item = (String, CaptureSource)>>(iter: T) -> Self {
292 Self {
293 entries: iter.into_iter().collect(),
294 }
295 }
296}
297
298/// Why one entry of a `capture` block did not produce a value.
299///
300/// Typed rather than a bare string so a front-end can branch on it — and so
301/// the granularity the assertion JSON paths already draw ("not a valid JSON
302/// path" is a broken file, "the body is not JSON" is a fact about this
303/// response, "matched nothing" is a fact about the pair) survives into
304/// anything reading a run rather than being recoverable only by matching on
305/// prose. [`Display`](std::fmt::Display) renders the wording, in core, so every
306/// front-end says the same thing about the same failure.
307#[derive(Debug, Clone, PartialEq, Eq)]
308pub enum CaptureFailure {
309 /// The name is already defined by the active environment file. See the
310 /// note on this variant's message for why that is rejected rather than
311 /// resolved in either direction.
312 Shadowed {
313 /// The environment file that already defines the name, or `None` when
314 /// the environment did not come from a file.
315 environment: Option<PathBuf>,
316 },
317
318 /// The path is not a JSON path at all.
319 InvalidPath { reason: String },
320
321 /// The response body did not parse as JSON, so there was nothing to query.
322 BodyNotJson {
323 /// serde_json's own message, position included.
324 reason: String,
325 /// The response's `content-type`, when it had one — a body with none at
326 /// all is a different mistake from one that announced `text/html`.
327 content_type: Option<String>,
328 },
329
330 /// The path is valid and the body is JSON, and the path selected nothing.
331 NoMatch,
332
333 /// The source selected more than one value, so there is no single value
334 /// to bind the name to. Raised for a JSON path matching several values,
335 /// and — the same philosophy applied to a second source — for a header
336 /// capture whose name repeats in the response (`Set-Cookie` is the
337 /// common case). See the module docs for why a capture does not resolve
338 /// a repeated header by taking the first or last value, the way an
339 /// assertion checking the same header does.
340 Ambiguous {
341 count: usize,
342 /// The first few matches, rendered, for the message.
343 sample: Vec<String>,
344 },
345
346 /// The path selected exactly one value and that value has no text form a
347 /// `{{name}}` could be replaced with: `null`, an array or an object.
348 NotAScalar {
349 /// `null`, `an array`, `an object`.
350 kind: &'static str,
351 },
352
353 /// A `header:` capture named a header the response does not carry.
354 HeaderNotFound {
355 header: String,
356 /// The header names the response does carry, for the same reason a
357 /// missing assertion header lists them: the answer is usually a
358 /// casing or spelling difference, visible once both are on screen.
359 present: Vec<String>,
360 },
361}
362
363impl std::fmt::Display for CaptureFailure {
364 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
365 match self {
366 CaptureFailure::Shadowed { environment } => write!(
367 f,
368 "{} already defines this variable; rename the capture or the environment entry",
369 describe_environment(environment)
370 ),
371 CaptureFailure::InvalidPath { reason } => write!(f, "not a valid JSON path: {reason}"),
372 CaptureFailure::BodyNotJson {
373 reason,
374 content_type,
375 } => write!(
376 f,
377 "the response body is not JSON: {reason}{}",
378 match content_type {
379 Some(content_type) => format!(" (content-type: {content_type})"),
380 None => " (no content-type header)".to_string(),
381 }
382 ),
383 CaptureFailure::NoMatch => f.write_str("matched nothing in the response body"),
384 CaptureFailure::Ambiguous { count, sample } => write!(
385 f,
386 "matched {count} values ({}); a capture needs a source that selects exactly one",
387 sample.join(", ")
388 ),
389 CaptureFailure::NotAScalar { kind } => write!(
390 f,
391 "matched {kind}, which has no text form to substitute; capture a string, \
392 number or boolean"
393 ),
394 CaptureFailure::HeaderNotFound { header, present } => write!(
395 f,
396 "no `{header}` header in the response{}",
397 if present.is_empty() {
398 "; the response carries no headers at all".to_string()
399 } else {
400 format!(" (the response has: {})", present.join(", "))
401 }
402 ),
403 }
404 }
405}
406
407/// One entry of a `capture` block, evaluated.
408///
409/// `value` and `failure` are the two halves of one answer and exactly one of
410/// them is ever set; the constructors below are private so a result cannot be
411/// built claiming both or neither.
412#[derive(Debug, Clone, PartialEq, Eq)]
413pub struct CaptureResult {
414 /// The variable name this entry defines.
415 pub variable: String,
416 /// Where it was read from: the JSON path text, unchanged, for the
417 /// default form; a short description (`` header `Set-Cookie` ``,
418 /// `status`) for the two additive ones. See
419 /// [`CaptureSource::label`](CaptureSource).
420 pub path: String,
421 value: Option<String>,
422 failure: Option<CaptureFailure>,
423}
424
425impl CaptureResult {
426 pub fn passed(&self) -> bool {
427 self.failure.is_none()
428 }
429
430 /// The text this entry captured, or `None` if it did not.
431 pub fn value(&self) -> Option<&str> {
432 self.value.as_deref()
433 }
434
435 /// Why the entry produced no value, or `None` if it did.
436 pub fn failure(&self) -> Option<&CaptureFailure> {
437 self.failure.as_ref()
438 }
439
440 fn captured(variable: &str, path: &str, value: String) -> Self {
441 Self {
442 variable: variable.to_string(),
443 path: path.to_string(),
444 value: Some(value),
445 failure: None,
446 }
447 }
448
449 fn fail(variable: &str, path: &str, failure: CaptureFailure) -> Self {
450 Self {
451 variable: variable.to_string(),
452 path: path.to_string(),
453 value: None,
454 failure: Some(failure),
455 }
456 }
457}
458
459/// Every entry of one request's `capture` block, evaluated against its
460/// response, in sorted-name order.
461///
462/// The default is the empty report, which is what a request with no `capture`
463/// block produces: nothing captured, nothing failed, and nothing printed.
464#[derive(Debug, Clone, Default, PartialEq, Eq)]
465pub struct CaptureReport {
466 results: Vec<CaptureResult>,
467}
468
469impl CaptureReport {
470 pub fn results(&self) -> &[CaptureResult] {
471 &self.results
472 }
473
474 /// No `capture` block was declared, or it was empty.
475 pub fn is_empty(&self) -> bool {
476 self.results.is_empty()
477 }
478
479 pub fn len(&self) -> usize {
480 self.results.len()
481 }
482
483 /// Every entry produced a value (vacuously true when there are none).
484 pub fn passed(&self) -> bool {
485 self.results.iter().all(CaptureResult::passed)
486 }
487
488 pub fn captured_count(&self) -> usize {
489 self.results.iter().filter(|result| result.passed()).count()
490 }
491
492 pub fn failed_count(&self) -> usize {
493 self.results.len() - self.captured_count()
494 }
495
496 /// Just the entries that produced no value, in evaluation order.
497 pub fn failures(&self) -> impl Iterator<Item = &CaptureResult> {
498 self.results.iter().filter(|result| !result.passed())
499 }
500
501 /// The name-to-value pairs this request contributes to the run.
502 ///
503 /// Only the entries that succeeded: a failed capture defines nothing, which
504 /// is what makes the downstream `{{name}}` a `VariableNotFound` naming the
505 /// variable rather than a request sent with an empty string in it.
506 pub fn values(&self) -> BTreeMap<String, String> {
507 self.results
508 .iter()
509 .filter_map(|result| {
510 result
511 .value()
512 .map(|value| (result.variable.clone(), value.to_string()))
513 })
514 .collect()
515 }
516}
517
518/// One entry of a `capture` block, against the already-parsed body.
519///
520/// The checks are ordered most-general-first, which is the same order
521/// `check_json_path` in [`assertions`](crate::assertions) uses and for the same
522/// reason: told about several problems at once, a reader wants the one that is
523/// wrong about every response rather than the one that is wrong about this one.
524/// A name that collides with the environment is wrong before a request is ever
525/// sent; a path that does not parse is wrong about every response there could
526/// be; a body that is not JSON is a fact about this response; a path that
527/// matched nothing is a fact about the two together.
528fn capture_one(
529 variable: &str,
530 source: &CaptureSource,
531 body: Result<&serde_json::Value, &serde_json::Error>,
532 response: &Response,
533 environment: &Environment,
534) -> CaptureResult {
535 let label = source.label();
536 let fail = |failure| CaptureResult::fail(variable, &label, failure);
537
538 if environment.variables.contains_key(variable) {
539 return fail(CaptureFailure::Shadowed {
540 environment: environment.source.clone(),
541 });
542 }
543
544 match source {
545 CaptureSource::JsonPath(path) => capture_json_path(variable, path, body, response),
546 CaptureSource::Header { header } => capture_header(variable, header, response),
547 CaptureSource::Status { .. } => {
548 CaptureResult::captured(variable, &label, response.status.to_string())
549 }
550 }
551}
552
553/// The JSON-path source: the original, default form, unchanged since it was
554/// the only one.
555fn capture_json_path(
556 variable: &str,
557 path: &str,
558 body: Result<&serde_json::Value, &serde_json::Error>,
559 response: &Response,
560) -> CaptureResult {
561 let fail = |failure| CaptureResult::fail(variable, path, failure);
562
563 // Checked here rather than when the file is loaded, even though it could
564 // be, for the reason `assertions` gives: loading a request file should
565 // never depend on the path grammar of this dependency, or a stricter
566 // release would start rejecting files that used to load.
567 if let Err(err) = jsonpath_rust::parser::parse_json_path(path) {
568 return fail(CaptureFailure::InvalidPath {
569 reason: err.to_string(),
570 });
571 }
572
573 let body = match body {
574 Ok(body) => body,
575 Err(err) => {
576 return fail(CaptureFailure::BodyNotJson {
577 reason: err.to_string(),
578 content_type: content_type(response).map(str::to_owned),
579 })
580 }
581 };
582
583 let selected = match body.query(path) {
584 Ok(selected) => selected,
585 Err(err) => {
586 return fail(CaptureFailure::InvalidPath {
587 reason: err.to_string(),
588 })
589 }
590 };
591
592 match selected.as_slice() {
593 [only] => match scalar_text(only) {
594 Ok(text) => CaptureResult::captured(variable, path, text),
595 Err(kind) => fail(CaptureFailure::NotAScalar { kind }),
596 },
597 [] => fail(CaptureFailure::NoMatch),
598 many => fail(CaptureFailure::Ambiguous {
599 count: many.len(),
600 sample: many
601 .iter()
602 .take(3)
603 .map(|value| serde_json::to_string(value).unwrap_or_else(|_| value.to_string()))
604 .collect(),
605 }),
606 }
607}
608
609/// The `header:` source. Names are matched case-insensitively, the way
610/// [`assertions`](crate::assertions) matches them; a name that repeats in the
611/// response is [`CaptureFailure::Ambiguous`] rather than a first-or-last
612/// pick — see the module docs.
613fn capture_header(variable: &str, header: &str, response: &Response) -> CaptureResult {
614 let label = format!("header `{header}`");
615 let fail = |failure| CaptureResult::fail(variable, &label, failure);
616
617 let matches: Vec<&str> = response
618 .headers
619 .iter()
620 .filter(|(name, _)| name.eq_ignore_ascii_case(header))
621 .map(|(_, value)| value.as_str())
622 .collect();
623
624 match matches.as_slice() {
625 [only] => CaptureResult::captured(variable, &label, only.to_string()),
626 [] => fail(CaptureFailure::HeaderNotFound {
627 header: header.to_string(),
628 present: response
629 .headers
630 .iter()
631 .map(|(name, _)| name.clone())
632 .collect(),
633 }),
634 many => fail(CaptureFailure::Ambiguous {
635 count: many.len(),
636 sample: many.iter().take(3).map(|value| value.to_string()).collect(),
637 }),
638 }
639}
640
641/// The text a captured JSON value is substituted as, or the name of the kind
642/// that has no such text.
643///
644/// A string captures **unquoted** — `"ada"` becomes `ada`, not `"ada"` — because
645/// substitution replaces `{{name}}` inside a URL, a header or a body, and the
646/// quotes are JSON's punctuation rather than part of the value.
647///
648/// Numbers and booleans capture as `serde_json` renders them, which is the
649/// value and not the spelling: `42` is `42` and `true` is `true`, but a body
650/// that said `1.50` captures as `1.5`, because the body was parsed into an
651/// `f64` before anything here saw it. That is a real difference from an
652/// environment file, where `port: 8080` is the *string* `8080` and nothing is
653/// normalised — and it is the honest one to expose, since the value really did
654/// make a round trip through a number. Pretending otherwise would need
655/// `serde_json`'s `arbitrary_precision`, which changes how every JSON assertion
656/// in the crate compares numbers; an endpoint whose exact digits matter should
657/// send them as a JSON string.
658///
659/// `null`, arrays and objects are refused. `null` has no text form that is not
660/// a guess between `""` and `null`. An array or an object has one — compact
661/// JSON — but substitution's entire safety argument is that a substituted value
662/// cannot change the shape of what it lands in, and pushing `{"a":1}` into a
663/// URL or a header is exactly that hazard. Refusing is the reversible choice:
664/// it can be relaxed later, while a build that had already been serialising
665/// objects into URLs could not be tightened.
666fn scalar_text(value: &serde_json::Value) -> Result<String, &'static str> {
667 use serde_json::Value;
668 match value {
669 Value::String(text) => Ok(text.clone()),
670 Value::Number(number) => Ok(number.to_string()),
671 Value::Bool(flag) => Ok(flag.to_string()),
672 Value::Null => Err("null"),
673 Value::Array(_) => Err("an array"),
674 Value::Object(_) => Err("an object"),
675 }
676}
677
678fn content_type(response: &Response) -> Option<&str> {
679 response
680 .headers
681 .iter()
682 .find(|(name, _)| name.eq_ignore_ascii_case("content-type"))
683 .map(|(_, value)| value.as_str())
684}
685
686#[cfg(test)]
687mod tests {
688 use super::*;
689
690 use std::time::Duration;
691
692 fn response(headers: &[(&str, &str)], body: &str) -> Response {
693 Response {
694 status: 200,
695 status_text: "OK".to_string(),
696 headers: headers
697 .iter()
698 .map(|(name, value)| (name.to_string(), value.to_string()))
699 .collect(),
700 body: body.to_string(),
701 elapsed: Duration::from_millis(1),
702 redirects: Vec::new(),
703 }
704 }
705
706 fn json_response() -> Response {
707 response(
708 &[("content-type", "application/json")],
709 r#"{"token": "abc123", "user": {"id": 42, "admin": true}, "tags": ["a", "b"],
710 "price": 1.50, "nothing": null}"#,
711 )
712 }
713
714 fn captures(yaml: &str) -> Captures {
715 serde_yaml::from_str(yaml).expect("test capture block should parse")
716 }
717
718 /// The report for `yaml` against `json_response`, with no environment to
719 /// collide with.
720 fn report(yaml: &str) -> CaptureReport {
721 captures(yaml).evaluate(&json_response(), &Environment::default())
722 }
723
724 fn only(report: &CaptureReport) -> &CaptureResult {
725 assert_eq!(report.len(), 1, "expected one result: {report:?}");
726 &report.results()[0]
727 }
728
729 #[test]
730 fn captures_a_string_without_its_json_quotes() {
731 let report = report("auth_token: $.token\n");
732 assert_eq!(only(&report).value(), Some("abc123"));
733 assert!(report.passed());
734 assert_eq!(
735 report.values(),
736 BTreeMap::from([("auth_token".to_string(), "abc123".to_string())])
737 );
738 }
739
740 #[test]
741 fn captures_numbers_and_booleans_as_their_value_not_their_spelling() {
742 // `1.50` in the body captures as `1.5`: the body was parsed into an
743 // `f64` before this saw it, and pinning that here is what stops the
744 // documented behaviour and the real one drifting apart.
745 let report = report("id: $.user.id\nadmin: $.user.admin\nprice: $.price\n");
746 assert_eq!(
747 report.values(),
748 BTreeMap::from([
749 ("admin".to_string(), "true".to_string()),
750 ("id".to_string(), "42".to_string()),
751 ("price".to_string(), "1.5".to_string()),
752 ])
753 );
754 }
755
756 #[test]
757 fn a_path_that_matches_nothing_is_a_reported_failure() {
758 let report = report("missing: $.nope\n");
759 assert!(!report.passed());
760 assert_eq!(only(&report).failure(), Some(&CaptureFailure::NoMatch));
761 assert_eq!(only(&report).value(), None);
762 assert!(report.values().is_empty(), "nothing is defined by a miss");
763 assert!(
764 only(&report)
765 .failure()
766 .unwrap()
767 .to_string()
768 .contains("matched nothing"),
769 "the message is the one a user reads"
770 );
771 }
772
773 #[test]
774 fn a_path_matching_several_values_is_ambiguous_rather_than_first_wins() {
775 let report = report("tag: $.tags[*]\n");
776 match only(&report).failure() {
777 Some(CaptureFailure::Ambiguous { count, sample }) => {
778 assert_eq!(*count, 2);
779 assert_eq!(sample, &[r#""a""#.to_string(), r#""b""#.to_string()]);
780 }
781 other => panic!("expected Ambiguous, got {other:?}"),
782 }
783 }
784
785 #[test]
786 fn null_arrays_and_objects_have_no_text_form_to_substitute() {
787 for (path, kind) in [
788 ("$.nothing", "null"),
789 ("$.tags", "an array"),
790 ("$.user", "an object"),
791 ] {
792 let report = report(&format!("v: {path}\n"));
793 assert_eq!(
794 only(&report).failure(),
795 Some(&CaptureFailure::NotAScalar { kind }),
796 "{path} should not capture"
797 );
798 }
799 }
800
801 #[test]
802 fn a_body_that_is_not_json_reports_the_parser_message_and_the_content_type() {
803 let captures = captures("v: $.token\n");
804 let report = captures.evaluate(
805 &response(&[("content-type", "text/html")], "<html></html>"),
806 &Environment::default(),
807 );
808 match only(&report).failure() {
809 Some(CaptureFailure::BodyNotJson {
810 reason,
811 content_type,
812 }) => {
813 assert!(!reason.is_empty());
814 assert_eq!(content_type.as_deref(), Some("text/html"));
815 }
816 other => panic!("expected BodyNotJson, got {other:?}"),
817 }
818 }
819
820 #[test]
821 fn a_body_with_no_content_type_says_so_rather_than_naming_one() {
822 let report =
823 captures("v: $.token\n").evaluate(&response(&[], "not json"), &Environment::default());
824 let message = only(&report).failure().unwrap().to_string();
825 assert!(message.contains("no content-type header"), "got {message}");
826 }
827
828 #[test]
829 fn a_path_that_is_not_a_json_path_is_told_apart_from_one_that_missed() {
830 let report = report("v: not a path\n");
831 assert!(
832 matches!(
833 only(&report).failure(),
834 Some(CaptureFailure::InvalidPath { .. })
835 ),
836 "got {:?}",
837 only(&report).failure()
838 );
839 }
840
841 #[test]
842 fn a_name_the_environment_already_defines_is_refused_rather_than_shadowing_it() {
843 // The precedence decision, at the point it is made: neither value
844 // silently wins, because the same `{{auth_token}}` would otherwise mean
845 // the environment's value before this request and the captured one
846 // after it.
847 let environment = Environment::from_yaml_str("auth_token: from-the-file\n").unwrap();
848 let report = captures("auth_token: $.token\n").evaluate(&json_response(), &environment);
849
850 assert!(!report.passed());
851 assert!(
852 matches!(
853 only(&report).failure(),
854 Some(CaptureFailure::Shadowed { .. })
855 ),
856 "got {:?}",
857 only(&report).failure()
858 );
859 assert!(
860 report.values().is_empty(),
861 "a refused capture defines nothing, so the environment's value stands"
862 );
863 }
864
865 #[test]
866 fn a_collision_is_checked_before_the_path_is_even_read() {
867 // A shadowed name is wrong about every response there could be, so it
868 // is the failure worth reporting even when the path is also broken.
869 let environment = Environment::from_yaml_str("v: x\n").unwrap();
870 let report = captures("v: not a path\n").evaluate(&json_response(), &environment);
871 assert!(
872 matches!(
873 only(&report).failure(),
874 Some(CaptureFailure::Shadowed { .. })
875 ),
876 "got {:?}",
877 only(&report).failure()
878 );
879 }
880
881 #[test]
882 fn one_entry_failing_does_not_stop_the_others() {
883 let report = report("good: $.token\nbad: $.nope\nalso_good: $.user.id\n");
884 assert_eq!(report.len(), 3, "one result per entry, always");
885 assert_eq!(report.captured_count(), 2);
886 assert_eq!(report.failed_count(), 1);
887 assert_eq!(report.failures().count(), 1);
888 assert_eq!(
889 report.values(),
890 BTreeMap::from([
891 ("also_good".to_string(), "42".to_string()),
892 ("good".to_string(), "abc123".to_string()),
893 ])
894 );
895 }
896
897 #[test]
898 fn an_empty_block_captures_nothing_and_reports_nothing() {
899 let report = captures("{}\n").evaluate(&json_response(), &Environment::default());
900 assert!(report.is_empty());
901 assert!(report.passed(), "vacuously");
902 assert!(report.values().is_empty());
903 }
904
905 // --- header and status capture ------------------------------------------
906
907 #[test]
908 fn a_bare_string_still_means_a_json_path_unchanged() {
909 // The regression this whole section guards against: `entries()` used
910 // to map straight to a path `String`; it now maps to a
911 // `CaptureSource`, and a bare-string entry must still deserialise to
912 // `JsonPath` holding that exact text, byte for byte.
913 let parsed = captures("auth_token: $.token\n");
914 assert_eq!(
915 parsed.entries()["auth_token"],
916 CaptureSource::JsonPath("$.token".to_string())
917 );
918
919 let report = report("auth_token: $.token\nuser_id: $.user.id\n");
920 assert_eq!(
921 report.values(),
922 BTreeMap::from([
923 ("auth_token".to_string(), "abc123".to_string()),
924 ("user_id".to_string(), "42".to_string()),
925 ])
926 );
927 // The label shown in a report is the path text, unchanged, for
928 // `--json` output and everything else that reads `CaptureResult::path`.
929 assert_eq!(
930 only(&captures("v: $.token\n").evaluate(&json_response(), &Environment::default()))
931 .path,
932 "$.token"
933 );
934 }
935
936 #[test]
937 fn captures_a_response_header_case_insensitively() {
938 let response = response(&[("X-Request-Id", "abc-123")], "{}");
939 let report =
940 captures("id: { header: x-request-id }\n").evaluate(&response, &Environment::default());
941 assert_eq!(only(&report).value(), Some("abc-123"));
942 assert!(report.passed());
943 }
944
945 #[test]
946 fn a_missing_header_is_a_reported_failure_naming_what_is_there() {
947 let response = response(&[("Content-Type", "application/json")], "{}");
948 let report =
949 captures("id: { header: x-request-id }\n").evaluate(&response, &Environment::default());
950 match only(&report).failure() {
951 Some(CaptureFailure::HeaderNotFound { header, present }) => {
952 assert_eq!(header, "x-request-id");
953 assert_eq!(present, &["Content-Type".to_string()]);
954 }
955 other => panic!("expected HeaderNotFound, got {other:?}"),
956 }
957 let message = only(&report).failure().unwrap().to_string();
958 assert!(message.contains("Content-Type"), "got {message}");
959 }
960
961 #[test]
962 fn a_repeated_header_is_ambiguous_rather_than_first_or_last_wins() {
963 // Same philosophy as a JSON path matching several values: a capture
964 // binds a name to one value, and picking silently between repeats
965 // would make the same file behave differently depending on header
966 // order. This deliberately differs from how `assertions` treats a
967 // repeated header (passes if any value matches) because an
968 // assertion checks a predicate and a capture commits to an identity.
969 let response = response(&[("Set-Cookie", "a=1"), ("Set-Cookie", "b=2")], "{}");
970 let report = captures("session: { header: Set-Cookie }\n")
971 .evaluate(&response, &Environment::default());
972 match only(&report).failure() {
973 Some(CaptureFailure::Ambiguous { count, sample }) => {
974 assert_eq!(*count, 2);
975 assert_eq!(sample, &["a=1".to_string(), "b=2".to_string()]);
976 }
977 other => panic!("expected Ambiguous, got {other:?}"),
978 }
979 assert!(report.values().is_empty());
980 }
981
982 #[test]
983 fn captures_the_status_code_as_a_string() {
984 let response = response(&[], "{}");
985 let report =
986 captures("code: { status: true }\n").evaluate(&response, &Environment::default());
987 assert_eq!(only(&report).value(), Some("200"));
988 assert!(report.passed());
989 }
990
991 #[test]
992 fn status_false_is_rejected_when_the_file_is_loaded() {
993 // A structural nonsense, not a fact about any particular response —
994 // rejected at parse time, the same call `follow_redirects: -1` makes,
995 // rather than surfacing as a per-response capture failure.
996 let err = serde_yaml::from_str::<Captures>("code: { status: false }\n").unwrap_err();
997 assert!(err.to_string().contains("status: false"), "got {err}");
998 }
999
1000 #[test]
1001 fn an_object_capture_with_neither_header_nor_status_fails_to_parse() {
1002 let err = serde_yaml::from_str::<Captures>("v: { nonsense: true }\n").unwrap_err();
1003 // Not asserting exact wording (that's serde's untagged-enum message),
1004 // only that the file does not load silently with an empty source.
1005 assert!(!err.to_string().is_empty());
1006 }
1007
1008 #[test]
1009 fn header_and_status_capture_are_shadowed_the_same_as_json_path() {
1010 let environment = Environment::from_yaml_str("session: from-the-file\n").unwrap();
1011 let response = response(&[("Set-Cookie", "a=1")], "{}");
1012 let report =
1013 captures("session: { header: Set-Cookie }\n").evaluate(&response, &environment);
1014 assert!(
1015 matches!(
1016 only(&report).failure(),
1017 Some(CaptureFailure::Shadowed { .. })
1018 ),
1019 "got {:?}",
1020 only(&report).failure()
1021 );
1022 }
1023
1024 #[test]
1025 fn a_capture_block_mixing_all_three_sources_evaluates_each_independently() {
1026 let response = response(&[("X-Trace-Id", "trace-1")], r#"{"token": "abc123"}"#);
1027 let report = captures(
1028 "auth_token: $.token\ntrace: { header: x-trace-id }\ncode: { status: true }\n",
1029 )
1030 .evaluate(&response, &Environment::default());
1031 assert_eq!(report.len(), 3);
1032 assert!(report.passed());
1033 assert_eq!(
1034 report.values(),
1035 BTreeMap::from([
1036 ("auth_token".to_string(), "abc123".to_string()),
1037 ("trace".to_string(), "trace-1".to_string()),
1038 ("code".to_string(), "200".to_string()),
1039 ])
1040 );
1041 }
1042}