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}