Skip to main content

sendra_core/request/
mod.rs

1//! [`Request`]: the on-disk shape of one request, its parsing/validation, and
2//! [`Method`]. The three "structured input becomes the final wire form"
3//! passes — [`Request::resolve_query`], [`Request::resolve_body`],
4//! [`Request::resolve_auth`] — live in [`resolve`], each field's own type
5//! lives in [`multipart`]/[`auth`], and the repeated-header (de)serializers
6//! live in `headers` (private: only `Request`'s own `#[serde(...)]`
7//! attributes need them).
8
9pub mod auth;
10pub(crate) mod headers;
11pub mod multipart;
12pub mod resolve;
13
14use std::path::Path;
15
16use serde::{Deserialize, Serialize};
17
18use crate::assertions::Assertions;
19use crate::capture::Captures;
20use crate::error::SendraError;
21use crate::request::auth::Auth;
22use crate::request::headers::{deserialize_headers, serialize_headers};
23use crate::request::multipart::MultipartPart;
24
25/// HTTP methods Sendra can send. Deliberately a closed set for now — an
26/// arbitrary-method escape hatch can be added when something needs it.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
29#[serde(rename_all = "UPPERCASE")]
30pub enum Method {
31    Get,
32    Post,
33    Put,
34    Patch,
35    Delete,
36    Head,
37    Options,
38}
39
40impl Method {
41    pub fn as_str(self) -> &'static str {
42        match self {
43            Method::Get => "GET",
44            Method::Post => "POST",
45            Method::Put => "PUT",
46            Method::Patch => "PATCH",
47            Method::Delete => "DELETE",
48            Method::Head => "HEAD",
49            Method::Options => "OPTIONS",
50        }
51    }
52}
53
54impl From<Method> for reqwest::Method {
55    fn from(m: Method) -> Self {
56        match m {
57            Method::Get => reqwest::Method::GET,
58            Method::Post => reqwest::Method::POST,
59            Method::Put => reqwest::Method::PUT,
60            Method::Patch => reqwest::Method::PATCH,
61            Method::Delete => reqwest::Method::DELETE,
62            Method::Head => reqwest::Method::HEAD,
63            Method::Options => reqwest::Method::OPTIONS,
64        }
65    }
66}
67
68impl std::fmt::Display for Method {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        f.write_str(self.as_str())
71    }
72}
73
74/// A single request, as described by one YAML file.
75///
76/// The on-disk shape is the contract other Sendra features build on:
77///
78/// ```text
79/// name: Get user
80/// method: GET
81/// url: https://api.example.com/users/1
82/// headers:
83///   Accept: application/json
84/// body: null
85/// assertions:
86///   status: 200
87/// ```
88///
89/// Everything but `method` and `url` is optional.
90///
91/// **Headers are a `Vec` of pairs, not a map** — matching [`Response::headers`](crate::Response::headers)
92/// and for the same reason: HTTP allows a header name to repeat (multiple
93/// `Set-Cookie`-shaped headers, repeated `X-Forwarded-For` values), and a map
94/// cannot represent that. Order is preserved exactly as written in the file.
95///
96/// A standard YAML mapping still cannot have two keys with the same name, so
97/// writing a repeated header names it once with a *list* of values instead of
98/// a scalar:
99///
100/// ```text
101/// headers:
102///   Accept: application/json    # scalar: one header
103///   X-Forwarded-For:            # list: one header per entry, in order
104///     - 1.2.3.4
105///     - 5.6.7.8
106/// ```
107///
108/// Two entries with the same name *and* the same value are accepted rather
109/// than rejected: Sendra's stance elsewhere is to reject ambiguity, not
110/// redundancy, and a client is allowed to send the same header twice even
111/// when doing so is pointless.
112///
113/// `Eq` is deliberately absent where `PartialEq` is derived: an expected JSON
114/// value in an [`Assertions`] block can be a float, and JSON floats are not
115/// `Eq`. Nothing keys a map on a request, so the bound was never load-bearing.
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
118#[serde(deny_unknown_fields)]
119pub struct Request {
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub name: Option<String>,
122    pub method: Method,
123    pub url: String,
124    #[serde(
125        default,
126        skip_serializing_if = "Vec::is_empty",
127        deserialize_with = "deserialize_headers",
128        serialize_with = "serialize_headers"
129    )]
130    #[cfg_attr(
131        feature = "schema",
132        schemars(schema_with = "headers::header_map_schema")
133    )]
134    pub headers: Vec<(String, String)>,
135    /// Query parameters, merged onto whatever `url` already has and
136    /// percent-encoded properly — the alternative to hand-building a query
137    /// string inside `url` itself, where a value containing a space, `&`,
138    /// `=` or non-ASCII character has to be encoded by hand or the request
139    /// silently means something different than intended.
140    ///
141    /// ```text
142    /// url: https://api.example.com/search
143    /// query:
144    ///   q: coffee & tea      # -> q=coffee+%26+tea
145    ///   tag:                 # list: one `tag=` per entry, in order
146    ///     - hot
147    ///     - iced
148    /// ```
149    ///
150    /// [`Request::resolve_query`] merges this onto `url`'s own query string
151    /// (if it has one) with **`query` winning**: a key present in both is
152    /// sent only with the value(s) from here, not the URL's. `query` is the
153    /// more structured, explicit source, so a key repeated between the two
154    /// is far more likely to be a stale copy left in `url` than a
155    /// deliberately duplicated value.
156    ///
157    /// Deserialized the same way [`headers`](Self::headers) is — a value may
158    /// be a scalar or, for a repeated key (`?tag=hot&tag=iced`), a list of
159    /// scalars — since a repeated query parameter is the same shape of
160    /// problem a repeated header already had a good answer for. An unquoted
161    /// number or boolean is coerced to its string form rather than rejected,
162    /// matching header values.
163    #[serde(
164        default,
165        skip_serializing_if = "Vec::is_empty",
166        deserialize_with = "deserialize_headers",
167        serialize_with = "serialize_headers"
168    )]
169    #[cfg_attr(
170        feature = "schema",
171        schemars(schema_with = "headers::header_map_schema")
172    )]
173    pub query: Vec<(String, String)>,
174    /// Raw body, sent verbatim.
175    ///
176    /// One of five ways to specify a body — `body`, [`json`](Self::json),
177    /// [`body_file`](Self::body_file), [`form`](Self::form) or
178    /// [`multipart`](Self::multipart) — and a request may set at most one of
179    /// them; [`Request::validate`] rejects any other combination at parse
180    /// time. By the time a `pre_request` script or [`send_prepared`](crate::send_prepared) sees a
181    /// request, whichever of the five was set has already been resolved down
182    /// to this field by [`Request::resolve_body`] — see there for exactly
183    /// what each one becomes.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub body: Option<String>,
186    /// A body given as YAML — a mapping, a list, a string, whatever value —
187    /// serialized to JSON and sent as `application/json`.
188    ///
189    /// ```text
190    /// json:
191    ///   name: ada
192    ///   roles: [admin, user]
193    /// ```
194    ///
195    /// [`Request::resolve_body`] sets `Content-Type: application/json` only
196    /// when the request has not already set that header itself — an explicit
197    /// header always wins. See [`Request::body`] for how this relates to the
198    /// other four ways of specifying a body.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub json: Option<serde_json::Value>,
201    /// A body read from a file, verbatim, sent exactly as `body` would be.
202    ///
203    /// ```text
204    /// body_file: ./payload.json
205    /// ```
206    ///
207    /// The path is resolved relative to the *request file's own directory*,
208    /// not the process's current working directory — see
209    /// [`Request::resolve_body`] for why. Unlike [`json`](Self::json) and
210    /// [`form`](Self::form), no `Content-Type` is set automatically: Sendra
211    /// cannot know what an arbitrary file contains, so a request using this
212    /// field is responsible for its own `headers:` if the server needs one.
213    /// The file's content must be valid UTF-8 — see the module docs.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub body_file: Option<String>,
216    /// A body given as name/value pairs, URL-encoded and sent as
217    /// `application/x-www-form-urlencoded` — the same encoding an HTML form
218    /// submission uses.
219    ///
220    /// ```text
221    /// form:
222    ///   username: ada
223    ///   remember_me: "true"
224    /// ```
225    ///
226    /// A plain YAML mapping cannot repeat a key, so unlike
227    /// [`headers`](Self::headers) this has no list form for a repeated field
228    /// name — nothing in Sendra has needed one yet. See
229    /// [`Request::resolve_body`] for the `Content-Type` rule, which matches
230    /// [`json`](Self::json)'s.
231    ///
232    /// Deserialized the same way [`headers`](Self::headers) is — a repeated
233    /// field name is written as a list rather than rejected as a duplicate
234    /// key — since a form field repeating (an HTML multi-select, say) is the
235    /// same shape of problem a repeated header already had a good answer for.
236    #[serde(
237        default,
238        skip_serializing_if = "Vec::is_empty",
239        deserialize_with = "deserialize_headers",
240        serialize_with = "serialize_headers"
241    )]
242    #[cfg_attr(
243        feature = "schema",
244        schemars(schema_with = "headers::header_map_schema")
245    )]
246    pub form: Vec<(String, String)>,
247    /// A body given as named parts, each either inline text or a file, sent
248    /// as `multipart/form-data`.
249    ///
250    /// ```text
251    /// multipart:
252    ///   - name: description
253    ///     value: a photo of my cat
254    ///   - name: photo
255    ///     path: ./cat.jpg
256    /// ```
257    ///
258    /// Each part is exactly one of a text part (`value`) or a file part
259    /// (`path`, resolved the same way [`body_file`](Self::body_file) is) —
260    /// [`Request::validate`] rejects a part with both or neither. See the
261    /// module docs for why a file part's content must be valid UTF-8 in this
262    /// version.
263    #[serde(default, skip_serializing_if = "Vec::is_empty")]
264    pub multipart: Vec<MultipartPart>,
265    /// How to authenticate this request: bearer token, basic credentials or
266    /// a static API key, resolved to a plain header (and, for `api_key` in
267    /// `query` form, a query parameter) before anything else sees it.
268    ///
269    /// ```text
270    /// auth:
271    ///   bearer: {{token}}
272    ///
273    /// # or
274    ///
275    /// auth:
276    ///   basic:
277    ///     user: {{username}}
278    ///     pass: {{password}}
279    ///
280    /// # or
281    ///
282    /// auth:
283    ///   api_key:
284    ///     in: header          # or: query
285    ///     name: X-API-Key     # or a query param name
286    ///     value: {{api_key}}
287    /// ```
288    ///
289    /// Exactly one of `bearer`/`basic`/`api_key` may be set — [`Request::validate`]
290    /// rejects any other combination, the same shape as the five body
291    /// fields above. A request may not set `auth` *and* an explicit header
292    /// (or, for `api_key` in `query` form, query parameter) of the same
293    /// name it would itself set: both trying to control the same header (or
294    /// parameter) is far more likely a mistake than a deliberate layering
295    /// (unlike, say, a config default header and a request header, where
296    /// "the request wins" is a sensible answer), so `validate` rejects the
297    /// combination rather than silently picking one.
298    ///
299    /// [`Request::resolve_auth`] turns this into the final header (or query
300    /// parameter) and clears the field, following the same "scripts see the
301    /// final resolved form" precedent as [`Request::resolve_body`]: a
302    /// `pre_request` script reads or overrides
303    /// `request.headers["Authorization"]` (or any other header `api_key`
304    /// set) like any other header, with no separate `request.auth` API.
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub auth: Option<Auth>,
307    /// Declarative checks on the response, evaluated by
308    /// [`Assertions::evaluate`] once it arrives.
309    ///
310    /// `None` — no `assertions:` key at all — is not the same as an empty
311    /// block, and both are kept distinct on the way back out to YAML. Neither
312    /// changes how the request is sent: assertions are read after the response,
313    /// never before it, and they do not decide the process exit code. See the
314    /// module docs on [`assertions`](crate::assertions).
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub assertions: Option<Assertions>,
317
318    /// A script run against this request just before it is sent, as inline
319    /// Rhai source.
320    ///
321    /// Written as a YAML block scalar, which is what a multiline script needs
322    /// and the reason the file format is YAML rather than JSON or TOML:
323    ///
324    /// ```text
325    /// pre_request: |
326    ///   request.headers["X-Request-Id"] = "abc-123";
327    /// ```
328    ///
329    /// It runs *after* environment substitution and *after* the config is
330    /// applied, as the final mutation step before the wire. **Its own source is
331    /// never substituted** — a `{{var}}` inside a script is just those
332    /// characters. See the [`script`](crate::script) module for both decisions and for what
333    /// the script can see.
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub pre_request: Option<String>,
336
337    /// A script run against the response, before assertions are evaluated.
338    ///
339    /// ```text
340    /// post_request: |
341    ///   if response.status != 201 {
342    ///     throw "expected 201, got " + response.status;
343    ///   }
344    /// ```
345    ///
346    /// `throw` is how it reports a failure. Like an assertion, that failure is
347    /// visible in the output and decides `sendra test`'s verdict without
348    /// changing `sendra run`'s exit code. Compiled before the request is sent,
349    /// so a syntax error here stops the request rather than being discovered
350    /// after it.
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub post_request: Option<String>,
353
354    /// Values to pull out of the response and hand to the requests after this
355    /// one, as variable name to JSON path:
356    ///
357    /// ```text
358    /// capture:
359    ///   auth_token: $.token
360    ///   user_id: $.user.id
361    /// ```
362    ///
363    /// Each name becomes usable as `{{name}}` in every request *after* this one
364    /// in file order, within the same `sendra run` or `sendra test`
365    /// invocation — nothing is written to disk and a fresh process starts with
366    /// nothing captured.
367    ///
368    /// `None` — no `capture:` key at all — is kept distinct from an empty
369    /// block on the way back out to YAML, the same way an `assertions` block
370    /// is. Neither changes how this request is sent: a capture is read after
371    /// the response, never before it. See the [`capture`](crate::capture) module for what a
372    /// path may select and for what happens when one does not match.
373    ///
374    /// **The block is not substituted.** A `{{var}}` in a capture path or name
375    /// stays those characters; see [`Environment::apply`](crate::Environment::apply).
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub capture: Option<Captures>,
378
379    /// Retry this request on a true failure — no response at all: a DNS,
380    /// connection, TLS or timeout error — up to `count` additional attempts,
381    /// waiting `delay_ms` (default `0`, no wait) between each.
382    ///
383    /// ```text
384    /// retry:
385    ///   count: 2
386    ///   delay_ms: 200
387    /// ```
388    ///
389    /// Only a failure to get *any* response triggers a retry. A 4xx/5xx is
390    /// still a response — [`send_prepared`](crate::send_prepared) returns it
391    /// as `Ok`, not `Err` — so it is never retried by this field; retrying on
392    /// a specific status is a separate, more advanced feature this does not
393    /// attempt. Simple, fixed backoff: no exponential delay or jitter.
394    ///
395    /// **Only the final attempt's outcome is reported.** A request that fails
396    /// twice and then succeeds is reported as a plain, ordinary success — the
397    /// two failed attempts before it are never counted toward `sendra test`'s
398    /// summary or either subcommand's exit code, though each retry is logged
399    /// to stderr for visibility. A request that exhausts every attempt is
400    /// reported as the one failure it always would have been, with no
401    /// separate record of the attempts that came before it.
402    ///
403    /// `None` — no `retry:` key at all — means a request is sent once and
404    /// whatever happens is final, exactly as it was before this field
405    /// existed.
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub retry: Option<RetryConfig>,
408}
409
410/// How many extra times to try a request, and how long to wait between
411/// attempts, when it fails to get any response — see [`Request::retry`].
412#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
413#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
414#[serde(deny_unknown_fields)]
415pub struct RetryConfig {
416    /// Additional attempts beyond the first — `count: 2` means up to three
417    /// attempts total. `0` is accepted and means what writing no `retry:`
418    /// block at all already means.
419    pub count: u32,
420    /// Milliseconds to wait before each retry. `None`/omitted is `0`: retry
421    /// immediately.
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub delay_ms: Option<u64>,
424}
425
426impl Request {
427    /// Parse a request from a YAML string.
428    pub fn from_yaml_str(yaml: &str) -> Result<Self, SendraError> {
429        let request: Request = serde_yaml::from_str(yaml).map_err(SendraError::ParseStr)?;
430        request.validate()?;
431        Ok(request)
432    }
433
434    /// Read and parse a request from a YAML file on disk.
435    pub fn from_path(path: impl AsRef<Path>) -> Result<Self, SendraError> {
436        let path = path.as_ref();
437        let raw = std::fs::read_to_string(path).map_err(|source| SendraError::Io {
438            path: path.to_path_buf(),
439            source,
440        })?;
441        let request: Request = serde_yaml::from_str(&raw).map_err(|source| SendraError::Parse {
442            path: path.to_path_buf(),
443            source,
444        })?;
445        request.validate()?;
446        Ok(request)
447    }
448
449    /// Rules the `Deserialize` impl cannot express: at most one of
450    /// `body`/`json`/`body_file`/`form`/`multipart` may be set, and each
451    /// `multipart` part needs exactly one of `value`/`path`.
452    ///
453    /// Checked here, at parse time, rather than left for
454    /// [`resolve_body`](Self::resolve_body) to discover: a request with two
455    /// body sources is a broken file the same way an unnamed request in a
456    /// collection is, and both are worth catching before anything is sent
457    /// rather than resolved by silently picking one and ignoring the rest.
458    pub(crate) fn validate(&self) -> Result<(), SendraError> {
459        let invalid = |reason: String| Err(SendraError::InvalidRequest { reason });
460
461        let mut set = Vec::new();
462        if self.body.is_some() {
463            set.push("body");
464        }
465        if self.json.is_some() {
466            set.push("json");
467        }
468        if self.body_file.is_some() {
469            set.push("body_file");
470        }
471        if !self.form.is_empty() {
472            set.push("form");
473        }
474        if !self.multipart.is_empty() {
475            set.push("multipart");
476        }
477        if set.len() > 1 {
478            return invalid(format!(
479                "at most one of `body`, `json`, `body_file`, `form`, `multipart` may be set, but found: {}",
480                set.join(", ")
481            ));
482        }
483
484        for part in &self.multipart {
485            match (&part.value, &part.path) {
486                (Some(_), Some(_)) => {
487                    return invalid(format!(
488                        "multipart part `{}` has both `value` and `path`; exactly one is required",
489                        part.name
490                    ));
491                }
492                (None, None) => {
493                    return invalid(format!(
494                        "multipart part `{}` has neither `value` nor `path`; exactly one is required",
495                        part.name
496                    ));
497                }
498                _ => {}
499            }
500        }
501
502        if let Some(auth) = &self.auth {
503            if let Err(reason) = auth.validate_exclusivity() {
504                return invalid(reason);
505            }
506            if let Some(oauth) = &auth.oauth {
507                if let Err(reason) = oauth.validate_grant_fields() {
508                    return invalid(reason);
509                }
510            }
511            if let Some(reason) = auth.collision_reason(&self.headers, &self.query) {
512                return invalid(reason);
513            }
514        }
515
516        Ok(())
517    }
518
519    /// Display label: the `name` field if present, else `METHOD url`.
520    pub fn label(&self) -> String {
521        match &self.name {
522            Some(name) => name.clone(),
523            None => format!("{} {}", self.method, self.url),
524        }
525    }
526
527    /// The first header with exactly this name, if any.
528    ///
529    /// A convenience for callers that know (or only care about) at most one
530    /// occurrence; a header that may legitimately repeat should read
531    /// `.headers` directly rather than lose every occurrence but the first.
532    pub fn header(&self, name: &str) -> Option<&str> {
533        self.headers
534            .iter()
535            .find(|(existing, _)| existing == name)
536            .map(|(_, value)| value.as_str())
537    }
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543
544    #[test]
545    fn parses_a_valid_request() {
546        let yaml = "\
547name: Get user
548method: GET
549url: https://api.example.com/users/1
550headers:
551  Accept: application/json
552body: null
553";
554        let request = Request::from_yaml_str(yaml).expect("valid yaml should parse");
555
556        let expected_headers = vec![("Accept".to_string(), "application/json".to_string())];
557
558        assert_eq!(
559            request,
560            Request {
561                name: Some("Get user".to_string()),
562                method: Method::Get,
563                url: "https://api.example.com/users/1".to_string(),
564                headers: expected_headers,
565                query: Vec::new(),
566                body: None,
567                json: None,
568                body_file: None,
569                form: Vec::new(),
570                multipart: Vec::new(),
571                auth: None,
572                assertions: None,
573                pre_request: None,
574                post_request: None,
575                capture: None,
576                retry: None,
577            }
578        );
579    }
580
581    #[test]
582    fn a_header_value_that_is_a_list_expands_to_one_header_per_entry() {
583        let request = Request::from_yaml_str(
584            "\
585method: GET
586url: https://example.com
587headers:
588  Accept: application/json
589  X-Forwarded-For:
590    - 1.2.3.4
591    - 5.6.7.8
592",
593        )
594        .expect("a list-valued header is part of the file contract");
595
596        assert_eq!(
597            request.headers,
598            vec![
599                ("Accept".to_string(), "application/json".to_string()),
600                ("X-Forwarded-For".to_string(), "1.2.3.4".to_string()),
601                ("X-Forwarded-For".to_string(), "5.6.7.8".to_string()),
602            ]
603        );
604    }
605
606    #[test]
607    fn two_identical_headers_are_kept_not_rejected() {
608        // Redundant, but not ambiguous: Sendra rejects ambiguity elsewhere, not
609        // a user's explicit (if pointless) choice to repeat a value.
610        let request = Request::from_yaml_str(
611            "\
612method: GET
613url: https://example.com
614headers:
615  X-Tag:
616    - same
617    - same
618",
619        )
620        .expect("identical repeated headers are allowed, not an error");
621
622        assert_eq!(
623            request.headers,
624            vec![
625                ("X-Tag".to_string(), "same".to_string()),
626                ("X-Tag".to_string(), "same".to_string()),
627            ]
628        );
629    }
630
631    #[test]
632    fn an_unquoted_scalar_header_value_is_still_read_as_a_string() {
633        // What the field did when it was a `BTreeMap<String, String>`: a plain
634        // scalar is the header value spelled out. The type change must not
635        // start rejecting `X-Api-Version: 2`.
636        let request = Request::from_yaml_str(
637            "\
638method: GET
639url: https://example.com
640headers:
641  X-Api-Version: 2
642  X-Enabled: true
643",
644        )
645        .expect("an unquoted scalar is a header value, as it always was");
646
647        assert_eq!(request.header("X-Api-Version"), Some("2"));
648        assert_eq!(request.header("X-Enabled"), Some("true"));
649    }
650
651    #[test]
652    fn a_header_value_that_is_neither_a_scalar_nor_a_list_says_so() {
653        let err = Request::from_yaml_str(
654            "\
655method: GET
656url: https://example.com
657headers:
658  X:
659    nested: map
660",
661        )
662        .expect_err("a nested map is not a header value");
663
664        let message = std::error::Error::source(&err)
665            .expect("the serde error is the source")
666            .to_string();
667        assert!(
668            message.contains("expected a string or a list of strings"),
669            "the message should name the shape a header value may take: {message}"
670        );
671    }
672
673    #[test]
674    fn repeated_headers_round_trip_through_yaml() {
675        let request = Request::from_yaml_str(
676            "\
677method: GET
678url: https://example.com
679headers:
680  X-Forwarded-For:
681    - 1.2.3.4
682    - 5.6.7.8
683",
684        )
685        .unwrap();
686
687        let yaml = serde_yaml::to_string(&request).expect("a repeated header serialises");
688        let round_tripped = Request::from_yaml_str(&yaml).expect("and reparses");
689        assert_eq!(round_tripped.headers, request.headers, "got {yaml}");
690    }
691
692    #[test]
693    fn parses_a_minimal_request() {
694        let request = Request::from_yaml_str("method: POST\nurl: https://example.com\n")
695            .expect("method + url is enough");
696        assert_eq!(request.method, Method::Post);
697        assert!(request.headers.is_empty());
698        assert_eq!(request.body, None);
699        assert_eq!(
700            request.assertions, None,
701            "a file written before assertions existed still parses to no assertions"
702        );
703        assert_eq!(request.label(), "POST https://example.com");
704    }
705
706    #[test]
707    fn parses_a_request_with_an_assertions_block() {
708        // The whole on-disk shape at once; what each entry *means* is tested in
709        // the `assertions` module, this is the file contract.
710        let request = Request::from_yaml_str(
711            "\
712method: GET
713url: https://api.example.com/users/1
714assertions:
715  status: 200
716  headers:
717    content-type: application/json
718    x-request-id:
719  body_contains: ada
720  json:
721    $.user.id: 42
722",
723        )
724        .expect("an assertions block is part of the request shape");
725
726        let assertions = request.assertions.expect("the block parsed");
727        assert_eq!(assertions.status, Some(200));
728        assert_eq!(
729            assertions.headers.get("content-type"),
730            Some(&Some("application/json".to_string()))
731        );
732        // A key with no value is presence-only, not a missing entry.
733        assert_eq!(assertions.headers.get("x-request-id"), Some(&None));
734        assert_eq!(assertions.body_contains.as_deref(), Some("ada"));
735        assert_eq!(assertions.json["$.user.id"], serde_json::json!(42));
736    }
737
738    #[test]
739    fn an_empty_assertions_block_is_kept_distinct_from_no_block_at_all() {
740        // `assertions: {}` asserts nothing, which is what an absent block does
741        // too — but the file said something, and round-tripping it should not
742        // silently rewrite it into a different file.
743        let empty =
744            Request::from_yaml_str("method: GET\nurl: https://example.com\nassertions: {}\n")
745                .unwrap();
746        assert_eq!(empty.assertions, Some(Assertions::default()));
747        assert!(empty.assertions.as_ref().unwrap().is_empty());
748
749        // A null block is the absent one: `assertions:` with nothing under it
750        // is a key the author has not filled in yet.
751        let null =
752            Request::from_yaml_str("method: GET\nurl: https://example.com\nassertions:\n").unwrap();
753        assert_eq!(null.assertions, None);
754    }
755
756    #[test]
757    fn a_request_with_no_assertions_serialises_without_the_key() {
758        // The round trip other Sendra features build on: nothing that did not
759        // write an `assertions` block gets one back.
760        let request = Request::from_yaml_str("method: GET\nurl: https://example.com\n").unwrap();
761        let yaml = serde_yaml::to_string(&request).expect("a request serialises");
762        assert!(!yaml.contains("assertions"), "got {yaml}");
763    }
764
765    #[test]
766    fn parses_a_request_with_a_capture_block() {
767        let request = Request::from_yaml_str(
768            "method: POST
769url: https://api.example.com/login
770capture:
771  auth_token: $.token
772  user_id: $.user.id
773",
774        )
775        .expect("a capture block is part of the request shape");
776
777        let capture = request.capture.expect("the block parsed");
778        assert_eq!(capture.variables(), vec!["auth_token", "user_id"]);
779        assert_eq!(
780            capture.entries()["auth_token"],
781            crate::capture::CaptureSource::JsonPath("$.token".to_string())
782        );
783        assert_eq!(
784            capture.entries()["user_id"],
785            crate::capture::CaptureSource::JsonPath("$.user.id".to_string())
786        );
787    }
788
789    #[test]
790    fn an_empty_capture_block_is_kept_distinct_from_no_block_at_all() {
791        // Same rule as `assertions`: the file said something, and a round trip
792        // should not silently rewrite it into a different file.
793        let empty = Request::from_yaml_str(
794            "method: GET
795url: https://example.com
796capture: {}
797",
798        )
799        .unwrap();
800        assert!(empty.capture.as_ref().unwrap().is_empty());
801
802        let null = Request::from_yaml_str(
803            "method: GET
804url: https://example.com
805capture:
806",
807        )
808        .unwrap();
809        assert_eq!(null.capture, None);
810    }
811
812    #[test]
813    fn a_request_with_no_capture_block_serialises_without_the_key() {
814        let request = Request::from_yaml_str(
815            "method: GET
816url: https://example.com
817",
818        )
819        .unwrap();
820        let yaml = serde_yaml::to_string(&request).expect("a request serialises");
821        assert!(!yaml.contains("capture"), "got {yaml}");
822    }
823
824    #[test]
825    fn a_capture_path_is_not_validated_when_the_file_is_loaded() {
826        // Deliberate, and the same call `assertions` makes: loading a request
827        // file must never depend on the path grammar of the JSON path crate,
828        // or a stricter release would start rejecting files that used to load.
829        // A broken path is reported against the response instead.
830        let request = Request::from_yaml_str(
831            "method: GET
832url: https://example.com
833capture:
834  v: nonsense
835",
836        )
837        .expect("the file loads");
838        assert_eq!(
839            request.capture.unwrap().entries()["v"],
840            crate::capture::CaptureSource::JsonPath("nonsense".to_string())
841        );
842    }
843
844    #[test]
845    fn malformed_yaml_is_a_parse_error_not_a_panic() {
846        // Unclosed flow sequence: not valid YAML at all.
847        let err = Request::from_yaml_str("method: [GET\nurl: https://example.com\n")
848            .expect_err("malformed yaml must not parse");
849        assert!(matches!(err, SendraError::ParseStr(_)), "got {err:?}");
850    }
851
852    #[test]
853    fn unknown_method_is_a_parse_error() {
854        let err = Request::from_yaml_str("method: TELEPORT\nurl: https://example.com\n")
855            .expect_err("unknown method must not parse");
856        assert!(matches!(err, SendraError::ParseStr(_)), "got {err:?}");
857    }
858
859    #[test]
860    fn missing_file_is_an_io_error_carrying_the_path() {
861        let err = Request::from_path("does/not/exist.yaml").expect_err("missing file must error");
862        match err {
863            SendraError::Io { path, .. } => assert_eq!(path, Path::new("does/not/exist.yaml")),
864            other => panic!("expected Io, got {other:?}"),
865        }
866    }
867
868    // --- `retry` -----------------------------------------------------------
869
870    #[test]
871    fn no_retry_key_at_all_is_none() {
872        // The no-op guarantee: a file written before this field existed
873        // parses to exactly the same `Request` it always did.
874        let request = Request::from_yaml_str("method: GET\nurl: https://example.com\n").unwrap();
875        assert_eq!(request.retry, None);
876    }
877
878    #[test]
879    fn retry_parses_count_and_an_optional_delay() {
880        let request = Request::from_yaml_str(
881            "method: GET\nurl: https://example.com\nretry:\n  count: 2\n  delay_ms: 250\n",
882        )
883        .unwrap();
884
885        assert_eq!(
886            request.retry,
887            Some(RetryConfig {
888                count: 2,
889                delay_ms: Some(250),
890            })
891        );
892    }
893
894    #[test]
895    fn retry_delay_ms_is_optional_and_defaults_to_none() {
896        let request =
897            Request::from_yaml_str("method: GET\nurl: https://example.com\nretry:\n  count: 3\n")
898                .unwrap();
899
900        assert_eq!(
901            request.retry,
902            Some(RetryConfig {
903                count: 3,
904                delay_ms: None,
905            })
906        );
907    }
908
909    #[test]
910    fn retry_without_count_is_a_parse_error() {
911        // `count` has no default: writing `retry:` at all is a statement of
912        // intent to retry, and a block that does not say how many times is a
913        // broken file rather than "retry zero times".
914        let err = Request::from_yaml_str(
915            "method: GET\nurl: https://example.com\nretry:\n  delay_ms: 100\n",
916        )
917        .expect_err("a `retry` block with no `count` must not parse");
918        assert!(matches!(err, SendraError::ParseStr(_)), "got {err:?}");
919    }
920
921    #[test]
922    fn retry_rejects_an_unknown_field() {
923        let err = Request::from_yaml_str(
924            "method: GET\nurl: https://example.com\nretry:\n  count: 1\n  backoff: exponential\n",
925        )
926        .expect_err("an unknown `retry` field must not silently parse");
927        assert!(matches!(err, SendraError::ParseStr(_)), "got {err:?}");
928    }
929}