Skip to main content

typesafe_jev/
transport.rs

1use ureq::http::{HeaderValue, Uri};
2
3use crate::{Config, Error};
4
5/// What came back from one HTTP POST, before the client interprets it.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct Reply {
8    /// The HTTP status code.
9    pub status: u16,
10    /// The `Retry-After` header, verbatim, when the server sent one.
11    pub retry_after: Option<String>,
12    /// The response body, decoded as text.
13    pub body: String,
14}
15
16/// The wire: one POST of a JSON request body to the API endpoint.
17///
18/// [`Client::new`](crate::Client::new) builds an HTTPS transport; [`Client::with_transport`](crate::Client::with_transport)
19/// takes any implementation, so tests and local fakes need no network. `Err` is a connection-level
20/// failure, which the client retries; an HTTP error status is an `Ok` reply that the client
21/// interprets. The trait is implemented for any `Fn(&[u8]) -> Result<Reply, String>` closure.
22pub trait Transport: Send + Sync {
23    /// Sends `body` (a JSON document) and returns the reply, or a connection-level failure.
24    fn post(&self, body: &[u8]) -> Result<Reply, String>;
25}
26
27impl<F: Fn(&[u8]) -> Result<Reply, String> + Send + Sync> Transport for F {
28    fn post(&self, body: &[u8]) -> Result<Reply, String> {
29        self(body)
30    }
31}
32
33impl Transport for Box<dyn Transport> {
34    fn post(&self, body: &[u8]) -> Result<Reply, String> {
35        (**self).post(body)
36    }
37}
38
39/// The real transport: a pooled HTTPS agent that sends a bearer token.
40pub(crate) struct Http {
41    agent: ureq::Agent,
42    url: String,
43    auth: String,
44}
45
46impl Http {
47    /// Vets everything that would otherwise fail identically on every attempt, so that a typo in
48    /// the configuration is one error at construction instead of a full round of retries.
49    ///
50    /// The messages never quote the key, nor the URL, which may carry credentials of its own.
51    pub(crate) fn new(api_key: &str, cfg: &Config) -> Result<Self, Error> {
52        let api_key = api_key.trim();
53        if api_key.is_empty() {
54            return Err(Error::Auth("the API key is empty".into()));
55        }
56        let auth = format!("Bearer {api_key}");
57        if HeaderValue::from_str(&auth).is_err() {
58            return Err(Error::Auth("the API key has characters that cannot be sent in an HTTP header".into()));
59        }
60        let uri: Uri = cfg.base_url.parse().map_err(|e| Error::InvalidConfig(format!("`Config::base_url` is not a URL: {e}")))?;
61        if !matches!(uri.scheme_str(), Some("http" | "https")) || uri.host().is_none_or(str::is_empty) {
62            return Err(Error::InvalidConfig("`Config::base_url` must be an absolute `https://` (or `http://`) URL".into()));
63        }
64        if HeaderValue::from_str(&cfg.user_agent).is_err() {
65            return Err(Error::InvalidConfig("`Config::user_agent` has characters that cannot be sent in an HTTP header".into()));
66        }
67        let agent: ureq::Agent = ureq::Agent::config_builder()
68            .http_status_as_error(false)
69            .timeout_connect(Some(cfg.connect_timeout))
70            .timeout_send_body(Some(cfg.timeout))
71            .timeout_recv_response(Some(cfg.timeout))
72            .timeout_recv_body(Some(cfg.timeout))
73            .max_idle_connections(cfg.pool_size)
74            .max_idle_connections_per_host(cfg.pool_size)
75            .user_agent(&cfg.user_agent)
76            .build()
77            .into();
78        Ok(Http { agent, url: cfg.base_url.clone(), auth })
79    }
80}
81
82/// Bodies larger than this are not read; no answer set is that big.
83const BODY_LIMIT: u64 = 256 << 20;
84
85impl Transport for Http {
86    fn post(&self, body: &[u8]) -> Result<Reply, String> {
87        let mut resp = self
88            .agent
89            .post(&self.url)
90            .header("Authorization", &self.auth)
91            .header("Content-Type", "application/json")
92            .send(body)
93            .map_err(|e| e.to_string())?;
94        let retry_after = resp.headers().get("retry-after").and_then(|v| v.to_str().ok()).map(str::to_owned);
95        let status = resp.status().as_u16();
96        let body = resp.body_mut().with_config().limit(BODY_LIMIT).read_to_string().map_err(|e| e.to_string())?;
97        Ok(Reply { status, retry_after, body })
98    }
99}