Skip to main content

renox_core/
http.rs

1//! An HTTP client for calling other services (payment gateways, shipping
2//! rates, webhooks you send), with timeouts, retries and a fake for tests.
3//!
4//! ```
5//! # use renox::prelude::*;
6//! # use std::time::Duration;
7//! #[derive(serde::Deserialize)]
8//! struct Rates { eur: f64 }
9//!
10//! # async fn demo(state: AppState, token: &str) -> Result {
11//! let rates: Rates = state
12//!     .http
13//!     .get("https://api.example.com/rates")
14//!     .query(&[("base", "USD")])
15//!     .bearer(token)
16//!     .timeout(Duration::from_secs(5))
17//!     .retry(3, Duration::from_millis(200)) // on connection errors, 429 and 5xx
18//!     .send()
19//!     .await?
20//!     .error_for_status()?                   // a 4xx/5xx becomes an error
21//!     .json()?;
22//!
23//! let created = state
24//!     .http
25//!     .post("https://api.example.com/orders")
26//!     .json(&renox::serde_json::json!({ "total": 7_500 }))
27//!     .send()
28//!     .await?;
29//! assert!(created.ok());
30//! # let _ = rates; Ok(()) }
31//! ```
32//!
33//! In tests, [`TestApp::fake_http`](crate::testing::TestApp::fake_http)
34//! answers requests instead of the network and records them:
35//!
36//! ```
37//! # use renox::prelude::*;
38//! use renox::http::FakeResponse;
39//! # async fn demo(app: renox::testing::TestApp) {
40//! let http = app.fake_http();
41//! http.on("https://api.example.com/rates*", FakeResponse::json(200, json!({ "eur": 0.92 })));
42//! http.on("POST https://api.example.com/orders", FakeResponse::status(503)); // then…
43//! http.on("POST https://api.example.com/orders", FakeResponse::json(201, json!({ "id": 7 })));
44//! // … exercise the app …
45//! http.assert_sent(|r| r.method == "POST" && r.body.contains("7500"));
46//! # }
47//! ```
48//!
49//! Requests with no fake fail while faking, so a test never reaches the
50//! network by accident. Real requests need the `http` feature (on by
51//! default).
52
53use std::fmt;
54use std::sync::{Arc, Mutex};
55use std::time::Duration;
56
57use axum::http::{Method, StatusCode};
58use serde::Serialize;
59use serde::de::DeserializeOwned;
60
61use crate::{Error, Result};
62
63/// How long a request may take unless it says otherwise.
64const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
65
66/// The app's HTTP client (`state.http`).
67#[derive(Clone, Default)]
68pub struct Http {
69    fake: Arc<Mutex<Option<Arc<Mutex<FakeState>>>>>,
70}
71
72impl fmt::Debug for Http {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        f.write_str("Http")
75    }
76}
77
78impl Http {
79    /// A `GET` request to `url`.
80    pub fn get(&self, url: impl Into<String>) -> Request {
81        self.request(Method::GET, url)
82    }
83
84    /// A `POST` request to `url`.
85    pub fn post(&self, url: impl Into<String>) -> Request {
86        self.request(Method::POST, url)
87    }
88
89    /// A `PUT` request to `url`.
90    pub fn put(&self, url: impl Into<String>) -> Request {
91        self.request(Method::PUT, url)
92    }
93
94    /// A `PATCH` request to `url`.
95    pub fn patch(&self, url: impl Into<String>) -> Request {
96        self.request(Method::PATCH, url)
97    }
98
99    /// A `DELETE` request to `url`.
100    pub fn delete(&self, url: impl Into<String>) -> Request {
101        self.request(Method::DELETE, url)
102    }
103
104    /// A request with any method; 30 s timeout and no retries unless changed.
105    pub fn request(&self, method: Method, url: impl Into<String>) -> Request {
106        Request {
107            http: self.clone(),
108            method,
109            url: url.into(),
110            query: Vec::new(),
111            headers: Vec::new(),
112            body: None,
113            timeout: DEFAULT_TIMEOUT,
114            retries: 0,
115            retry_delay: Duration::from_millis(100),
116            error: None,
117        }
118    }
119
120    /// Answers requests with fakes from now on (see [`FakeHttp`]); the
121    /// same fake for every clone of this client.
122    pub fn fake(&self) -> FakeHttp {
123        let mut slot = self.fake.lock().unwrap_or_else(|e| e.into_inner());
124        let state = slot.get_or_insert_with(Arc::default).clone();
125        FakeHttp(state)
126    }
127
128    fn faked(&self) -> Option<Arc<Mutex<FakeState>>> {
129        self.fake.lock().unwrap_or_else(|e| e.into_inner()).clone()
130    }
131}
132
133/// A request being built; send it with [`Request::send`].
134#[must_use = "a request does nothing until sent"]
135pub struct Request {
136    http: Http,
137    method: Method,
138    url: String,
139    query: Vec<(String, String)>,
140    headers: Vec<(String, String)>,
141    body: Option<(String, Vec<u8>)>,
142    timeout: Duration,
143    retries: u32,
144    retry_delay: Duration,
145    error: Option<Error>,
146}
147
148impl Request {
149    /// Adds query parameters (encoded).
150    pub fn query(mut self, pairs: &[(&str, &str)]) -> Self {
151        self.query.extend(
152            pairs
153                .iter()
154                .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())),
155        );
156        self
157    }
158
159    /// Adds a header (repeatable: a name may be sent more than once).
160    pub fn header(mut self, name: &str, value: impl Into<String>) -> Self {
161        self.headers.push((name.to_owned(), value.into()));
162        self
163    }
164
165    /// `Authorization: Bearer <token>`.
166    pub fn bearer(self, token: &str) -> Self {
167        self.header("authorization", format!("Bearer {token}"))
168    }
169
170    /// `Authorization: Basic …`.
171    pub fn basic_auth(self, user: &str, password: &str) -> Self {
172        use base64::Engine;
173        let encoded =
174            base64::engine::general_purpose::STANDARD.encode(format!("{user}:{password}"));
175        self.header("authorization", format!("Basic {encoded}"))
176    }
177
178    /// A JSON body (`Content-Type: application/json`).
179    pub fn json(mut self, body: &impl Serialize) -> Self {
180        match serde_json::to_vec(body) {
181            Ok(bytes) => self.body = Some(("application/json".into(), bytes)),
182            Err(err) => self.error = Some(err.into()),
183        }
184        self
185    }
186
187    /// A form body (`application/x-www-form-urlencoded`).
188    pub fn form(mut self, body: &impl Serialize) -> Self {
189        match serde_urlencoded::to_string(body) {
190            Ok(text) => {
191                self.body = Some((
192                    "application/x-www-form-urlencoded".into(),
193                    text.into_bytes(),
194                ));
195            }
196            Err(err) => self.error = Some(anyhow::Error::new(err).into()),
197        }
198        self
199    }
200
201    /// A raw body with its content type.
202    pub fn body(mut self, content_type: &str, body: impl Into<Vec<u8>>) -> Self {
203        self.body = Some((content_type.to_owned(), body.into()));
204        self
205    }
206
207    /// How long one attempt may take (30 s by default).
208    pub fn timeout(mut self, timeout: Duration) -> Self {
209        self.timeout = timeout;
210        self
211    }
212
213    /// Tries again up to `times` more times after a connection error, a
214    /// timeout, a 429 or a 5xx, waiting `delay`, then twice that, and so on.
215    pub fn retry(mut self, times: u32, delay: Duration) -> Self {
216        self.retries = times;
217        self.retry_delay = delay;
218        self
219    }
220
221    /// The URL with its query parameters.
222    fn full_url(&self) -> Result<String> {
223        if self.query.is_empty() {
224            return Ok(self.url.clone());
225        }
226        let query = serde_urlencoded::to_string(&self.query).map_err(anyhow::Error::new)?;
227        let joiner = if self.url.contains('?') { '&' } else { '?' };
228        Ok(format!("{}{joiner}{query}", self.url))
229    }
230
231    /// Sends the request (with its retries) and reads the whole response.
232    /// Any status is a response; see [`Response::error_for_status`].
233    pub async fn send(mut self) -> Result<Response> {
234        if let Some(err) = self.error.take() {
235            return Err(err);
236        }
237        let url = self.full_url()?;
238        let mut attempt = 0;
239        loop {
240            attempt += 1;
241            let outcome = match self.http.faked() {
242                Some(fake) => fake_send(&fake, &self, &url),
243                None => self.send_once(&url).await,
244            };
245            let retryable = match &outcome {
246                Ok(response) => {
247                    response.status == StatusCode::TOO_MANY_REQUESTS
248                        || response.status.is_server_error()
249                }
250                Err(_) => true,
251            };
252            if !retryable || attempt > self.retries {
253                return outcome;
254            }
255            tracing::debug!(url = %url, attempt, "HTTP request failed, retrying");
256            tokio::time::sleep(self.retry_delay * attempt).await;
257        }
258    }
259
260    #[cfg(feature = "http")]
261    async fn send_once(&self, url: &str) -> Result<Response> {
262        let mut request = client()
263            .request(self.method.clone(), url)
264            .timeout(self.timeout);
265        for (name, value) in &self.headers {
266            request = request.header(name, value);
267        }
268        if let Some((content_type, body)) = &self.body {
269            request = request
270                .header("content-type", content_type)
271                .body(body.clone());
272        }
273        let response = request
274            .send()
275            .await
276            .map_err(|err| anyhow::anyhow!("{} {url}: {}", self.method, err_chain(&err)))?;
277        let status = response.status();
278        let headers = response
279            .headers()
280            .iter()
281            .map(|(k, v)| {
282                (
283                    k.as_str().to_owned(),
284                    v.to_str().unwrap_or_default().to_owned(),
285                )
286            })
287            .collect();
288        let body = response
289            .bytes()
290            .await
291            .map_err(|err| anyhow::anyhow!("{} {url}: {}", self.method, err_chain(&err)))?;
292        Ok(Response {
293            status,
294            headers,
295            body: body.to_vec(),
296            url: url.to_owned(),
297        })
298    }
299
300    #[cfg(not(feature = "http"))]
301    async fn send_once(&self, url: &str) -> Result<Response> {
302        Err(anyhow::anyhow!(
303            "{} {url}: Renox was built without the `http` feature",
304            self.method
305        )
306        .into())
307    }
308}
309
310#[cfg(feature = "http")]
311fn err_chain(err: &dyn std::error::Error) -> String {
312    let mut text = err.to_string();
313    let mut source = err.source();
314    while let Some(cause) = source {
315        text.push_str(": ");
316        text.push_str(&cause.to_string());
317        source = cause.source();
318    }
319    text
320}
321
322/// One client for the process: it keeps connections open between requests.
323#[cfg(feature = "http")]
324pub(crate) fn client() -> &'static reqwest::Client {
325    static CLIENT: std::sync::LazyLock<reqwest::Client> = std::sync::LazyLock::new(|| {
326        // reqwest is built without a crypto provider (so aws-lc isn't
327        // compiled); use ring, like the mailer. Fails only if one is installed.
328        let _ = rustls::crypto::ring::default_provider().install_default();
329        reqwest::Client::builder()
330            .user_agent(concat!("renox/", env!("CARGO_PKG_VERSION")))
331            .build()
332            .unwrap_or_default()
333    });
334    &CLIENT
335}
336
337/// A response, read whole.
338#[derive(Debug, Clone)]
339pub struct Response {
340    status: StatusCode,
341    headers: Vec<(String, String)>,
342    body: Vec<u8>,
343    url: String,
344}
345
346impl Response {
347    /// The response's status code.
348    pub fn status(&self) -> StatusCode {
349        self.status
350    }
351
352    /// A 2xx status.
353    pub fn ok(&self) -> bool {
354        self.status.is_success()
355    }
356
357    /// The first header `name` (any case).
358    pub fn header(&self, name: &str) -> Option<&str> {
359        self.headers
360            .iter()
361            .find(|(k, _)| k.eq_ignore_ascii_case(name))
362            .map(|(_, v)| v.as_str())
363    }
364
365    /// The body as raw bytes.
366    pub fn bytes(&self) -> &[u8] {
367        &self.body
368    }
369
370    /// The body as text; invalid UTF-8 becomes U+FFFD.
371    pub fn text(&self) -> String {
372        String::from_utf8_lossy(&self.body).into_owned()
373    }
374
375    /// The body as JSON into `T`.
376    pub fn json<T: DeserializeOwned>(&self) -> Result<T> {
377        serde_json::from_slice(&self.body).map_err(|err| {
378            anyhow::anyhow!(
379                "the response of {} is not the expected JSON: {err}",
380                self.url
381            )
382            .into()
383        })
384    }
385
386    /// The response, or an error for a 4xx or 5xx status (with the start
387    /// of the body, which usually says why).
388    pub fn error_for_status(self) -> Result<Self> {
389        if self.status.is_client_error() || self.status.is_server_error() {
390            let body = self.text();
391            let excerpt: String = body.chars().take(300).collect();
392            return Err(anyhow::anyhow!("{} answered {}: {excerpt}", self.url, self.status).into());
393        }
394        Ok(self)
395    }
396}
397
398/// A request the fake received.
399#[derive(Debug, Clone)]
400#[non_exhaustive]
401pub struct SentRequest {
402    /// The HTTP method, upper case (`"POST"`).
403    pub method: String,
404    /// With its query string.
405    pub url: String,
406    /// The headers sent, including `content-type` when the request had a body.
407    pub headers: Vec<(String, String)>,
408    /// The body as text (lossy UTF-8); empty when there was none.
409    pub body: String,
410}
411
412impl SentRequest {
413    /// The first header `name` (any case).
414    pub fn header(&self, name: &str) -> Option<&str> {
415        self.headers
416            .iter()
417            .find(|(k, _)| k.eq_ignore_ascii_case(name))
418            .map(|(_, v)| v.as_str())
419    }
420
421    /// The body as JSON.
422    pub fn json(&self) -> serde_json::Value {
423        serde_json::from_str(&self.body).unwrap_or_default()
424    }
425}
426
427/// A canned answer for [`FakeHttp::on`].
428#[derive(Debug, Clone)]
429pub struct FakeResponse {
430    status: StatusCode,
431    headers: Vec<(String, String)>,
432    body: Vec<u8>,
433    /// Fail as if the connection broke.
434    fails: bool,
435}
436
437impl FakeResponse {
438    /// An empty response with this status (500 if the code is invalid).
439    pub fn status(status: u16) -> Self {
440        Self {
441            status: StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
442            headers: Vec::new(),
443            body: Vec::new(),
444            fails: false,
445        }
446    }
447
448    /// A JSON response with `content-type: application/json`.
449    pub fn json(status: u16, body: serde_json::Value) -> Self {
450        Self::status(status)
451            .header("content-type", "application/json")
452            .body(body.to_string())
453    }
454
455    /// A plain-text response with this status and body.
456    pub fn text(status: u16, body: impl Into<String>) -> Self {
457        Self::status(status).body(body.into())
458    }
459
460    /// A connection error instead of a response.
461    pub fn connection_error() -> Self {
462        Self {
463            fails: true,
464            ..Self::status(500)
465        }
466    }
467
468    /// Adds a response header.
469    pub fn header(mut self, name: &str, value: &str) -> Self {
470        self.headers.push((name.to_owned(), value.to_owned()));
471        self
472    }
473
474    /// Sets the response body.
475    pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
476        self.body = body.into();
477        self
478    }
479}
480
481#[derive(Default)]
482struct FakeState {
483    /// Pattern and its answers, used in order; the last one repeats.
484    routes: Vec<(String, Vec<FakeResponse>)>,
485    sent: Vec<SentRequest>,
486}
487
488/// The fake behind `state.http` in tests; from [`Http::fake`] or
489/// `TestApp::fake_http`.
490#[derive(Clone)]
491pub struct FakeHttp(Arc<Mutex<FakeState>>);
492
493impl FakeHttp {
494    /// Answers requests matching `pattern` with `response`. The pattern is a
495    /// URL where `*` matches anything, optionally after a method
496    /// (`"POST https://api.example.com/orders"`). Several answers for one
497    /// pattern are given in turn; the last one repeats.
498    pub fn on(&self, pattern: &str, response: FakeResponse) -> &Self {
499        let mut state = self.0.lock().unwrap_or_else(|e| e.into_inner());
500        match state.routes.iter_mut().find(|(p, _)| p == pattern) {
501            Some((_, answers)) => answers.push(response),
502            None => state.routes.push((pattern.to_owned(), vec![response])),
503        }
504        self
505    }
506
507    /// The requests sent so far, oldest first.
508    pub fn sent(&self) -> Vec<SentRequest> {
509        self.0
510            .lock()
511            .unwrap_or_else(|e| e.into_inner())
512            .sent
513            .clone()
514    }
515
516    /// Panics unless a sent request matches.
517    #[track_caller]
518    pub fn assert_sent(&self, check: impl Fn(&SentRequest) -> bool) {
519        let sent = self.sent();
520        assert!(
521            sent.iter().any(check),
522            "no matching HTTP request was sent; sent: {:#?}",
523            sent.iter()
524                .map(|r| format!("{} {}", r.method, r.url))
525                .collect::<Vec<_>>()
526        );
527    }
528
529    /// Panics if a sent request matches.
530    #[track_caller]
531    pub fn assert_not_sent(&self, check: impl Fn(&SentRequest) -> bool) {
532        assert!(
533            !self.sent().iter().any(check),
534            "a matching HTTP request was sent"
535        );
536    }
537
538    /// Panics unless exactly `expected` requests were sent.
539    #[track_caller]
540    pub fn assert_sent_count(&self, expected: usize) {
541        assert_eq!(self.sent().len(), expected, "HTTP requests sent");
542    }
543}
544
545fn fake_send(fake: &Mutex<FakeState>, request: &Request, url: &str) -> Result<Response> {
546    let mut state = fake.lock().unwrap_or_else(|e| e.into_inner());
547    let mut headers = request.headers.clone();
548    if let Some((content_type, _)) = &request.body {
549        headers.push(("content-type".into(), content_type.clone()));
550    }
551    state.sent.push(SentRequest {
552        method: request.method.to_string(),
553        url: url.to_owned(),
554        headers,
555        body: request
556            .body
557            .as_ref()
558            .map(|(_, body)| String::from_utf8_lossy(body).into_owned())
559            .unwrap_or_default(),
560    });
561    let method = request.method.as_str();
562    let answers = state
563        .routes
564        .iter_mut()
565        .find(|(pattern, _)| matches(pattern, method, url))
566        .map(|(_, answers)| answers)
567        .ok_or_else(|| {
568            anyhow::anyhow!(
569                "no fake for {method} {url}: add one with `fake_http().on(\"{url}\", …)`"
570            )
571        })?;
572    let answer = if answers.len() > 1 {
573        answers.remove(0)
574    } else {
575        answers[0].clone()
576    };
577    if answer.fails {
578        return Err(anyhow::anyhow!("{method} {url}: connection refused (fake)").into());
579    }
580    Ok(Response {
581        status: answer.status,
582        headers: answer.headers,
583        body: answer.body,
584        url: url.to_owned(),
585    })
586}
587
588/// Whether `pattern` (`[METHOD ]url-with-*`) matches.
589fn matches(pattern: &str, method: &str, url: &str) -> bool {
590    let (want_method, pattern) = match pattern.split_once(' ') {
591        Some((m, rest)) if m.chars().all(|c| c.is_ascii_uppercase()) => (Some(m), rest),
592        _ => (None, pattern),
593    };
594    if want_method.is_some_and(|m| m != method) {
595        return false;
596    }
597    glob(pattern.as_bytes(), url.as_bytes())
598}
599
600fn glob(pattern: &[u8], text: &[u8]) -> bool {
601    match pattern.split_first() {
602        None => text.is_empty(),
603        Some((b'*', rest)) => (0..=text.len()).any(|i| glob(rest, &text[i..])),
604        Some((c, rest)) => text.first() == Some(c) && glob(rest, &text[1..]),
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    #[cfg(feature = "http")]
613    #[test]
614    fn the_client_builds_with_the_ring_provider() {
615        // Would panic if no rustls crypto provider were available.
616        let _ = client();
617    }
618
619    #[test]
620    fn patterns() {
621        assert!(matches("https://a.test/*", "GET", "https://a.test/x?y=1"));
622        assert!(matches("POST https://a.test/o", "POST", "https://a.test/o"));
623        assert!(!matches("POST https://a.test/o", "GET", "https://a.test/o"));
624        assert!(!matches("https://a.test/o", "GET", "https://a.test/o/1"));
625        assert!(matches("*", "DELETE", "https://b.test"));
626        assert!(matches("https://*.test/*/x", "GET", "https://b.test/1/2/x"));
627    }
628
629    #[tokio::test]
630    async fn fakes_answer_in_turn_and_retries_follow() {
631        let http = Http::default();
632        let fake = http.fake();
633        fake.on("POST https://a.test/pay", FakeResponse::connection_error())
634            .on("POST https://a.test/pay", FakeResponse::status(503))
635            .on(
636                "POST https://a.test/pay",
637                FakeResponse::json(201, serde_json::json!({"id": 7})),
638            );
639        let response = http
640            .post("https://a.test/pay")
641            .json(&serde_json::json!({"total": 5}))
642            .retry(2, Duration::ZERO)
643            .send()
644            .await
645            .unwrap();
646        assert_eq!(response.status(), StatusCode::CREATED);
647        assert_eq!(response.json::<serde_json::Value>().unwrap()["id"], 7);
648        fake.assert_sent_count(3);
649        assert_eq!(fake.sent()[0].json()["total"], 5);
650        assert_eq!(
651            fake.sent()[0].header("Content-Type"),
652            Some("application/json")
653        );
654
655        // Without retries the first answer is the result; the last repeats.
656        fake.on("https://a.test/flaky", FakeResponse::status(500));
657        let res = http.get("https://a.test/flaky").send().await.unwrap();
658        assert!(res.clone().error_for_status().is_err());
659        assert!(!res.ok());
660        assert!(
661            http.get("https://a.test/none").send().await.is_err(),
662            "no fake"
663        );
664    }
665
666    #[tokio::test]
667    async fn builds_urls_and_headers() {
668        let http = Http::default();
669        let fake = http.fake();
670        fake.on("*", FakeResponse::text(200, "ok"));
671        http.get("https://a.test/r?x=1")
672            .query(&[("q", "iced coffee"), ("n", "2")])
673            .bearer("t0k")
674            .send()
675            .await
676            .unwrap();
677        http.post("https://a.test/f")
678            .basic_auth("u", "p")
679            .form(&[("a", "1 2")])
680            .send()
681            .await
682            .unwrap();
683        let sent = fake.sent();
684        assert_eq!(sent[0].url, "https://a.test/r?x=1&q=iced+coffee&n=2");
685        assert_eq!(sent[0].header("authorization"), Some("Bearer t0k"));
686        assert_eq!(sent[1].header("authorization"), Some("Basic dTpw"));
687        assert_eq!(sent[1].body, "a=1+2");
688        fake.assert_sent(|r| r.method == "POST");
689        fake.assert_not_sent(|r| r.method == "DELETE");
690    }
691
692    // #252: the rest of the client's builder and response, on the fake.
693
694    #[tokio::test]
695    async fn put_patch_raw_bodies_and_response_helpers() {
696        let http = Http::default();
697        let fake = http.fake();
698        fake.on(
699            "PUT https://api.test/items/1",
700            FakeResponse::text(200, "not json").header("x-request-id", "r1"),
701        );
702        fake.on("PATCH https://api.test/items/1", FakeResponse::status(204));
703        let res = http
704            .put("https://api.test/items/1")
705            .body("text/csv", "a,b\n1,2")
706            .send()
707            .await
708            .unwrap();
709        assert_eq!(res.header("X-Request-Id"), Some("r1"));
710        assert_eq!(res.bytes(), b"not json");
711        let err = res.json::<serde_json::Value>().unwrap_err();
712        assert!(
713            format!("{err:?}")
714                .contains("the response of https://api.test/items/1 is not the expected JSON"),
715            "{err:?}"
716        );
717        http.patch("https://api.test/items/1").send().await.unwrap();
718        let sent = fake.sent();
719        assert_eq!(sent[0].method, "PUT");
720        assert_eq!(sent[0].body, "a,b\n1,2");
721        assert!(
722            sent[0]
723                .headers
724                .iter()
725                .any(|(k, v)| k == "content-type" && v == "text/csv")
726        );
727        assert_eq!(sent[1].method, "PATCH");
728        // Debug says what it is, nothing more.
729        assert_eq!(format!("{http:?}"), "Http");
730    }
731
732    #[tokio::test]
733    async fn bodies_that_cant_be_encoded_fail_at_send() {
734        let http = Http::default();
735        let fake = http.fake();
736        let mut odd = std::collections::HashMap::new();
737        odd.insert((1, 2), 3);
738        assert!(
739            http.post("https://api.test/a")
740                .json(&odd)
741                .send()
742                .await
743                .is_err()
744        );
745        let nested = [("a", vec![1, 2])];
746        assert!(
747            http.post("https://api.test/b")
748                .form(&nested)
749                .send()
750                .await
751                .is_err()
752        );
753        assert!(fake.sent().is_empty(), "nothing was sent");
754    }
755
756    #[tokio::test]
757    #[should_panic(
758        expected = "no matching HTTP request was sent; sent: [\n    \"GET https://api.test/x\",\n]"
759    )]
760    async fn assert_sent_lists_what_was_sent() {
761        let http = Http::default();
762        let fake = http.fake();
763        fake.on("https://api.test/*", FakeResponse::status(200));
764        http.get("https://api.test/x").send().await.unwrap();
765        fake.assert_sent(|r| r.method == "POST");
766    }
767}