Skip to main content

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}