Skip to main content

aspero_core/
http.rs

1//! The transport seam between providers and the network.
2//!
3//! Providers build [`HttpRequest`]s and map [`HttpResponse`]s; they never own
4//! a client. The engine supplies the [`HttpClient`], which is where per-request
5//! pacing, timeouts and the user agent live, so every provider gets the same
6//! politeness for free. Tests supply a client that replays recorded fixtures.
7//!
8//! A client returns `Ok` for any HTTP response, whatever the status; only
9//! transport failures (connection, TLS, timeout) are `Err`. Mapping statuses
10//! to [`Error`]s is provider knowledge.
11
12use std::fmt;
13use std::time::Duration;
14
15use async_trait::async_trait;
16use jiff::Timestamp;
17
18use crate::error::Error;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21#[non_exhaustive]
22pub enum Method {
23    Get,
24    Post,
25}
26
27/// Header names whose values are never shown in `Debug` output.
28const SENSITIVE_HEADERS: &[&str] = &["authorization", "x-api-key"];
29
30#[derive(Clone, PartialEq, Eq)]
31#[non_exhaustive]
32pub struct HttpRequest {
33    pub method: Method,
34    pub url: String,
35    /// Header names are lowercase.
36    pub headers: Vec<(String, String)>,
37    pub body: Option<Vec<u8>>,
38}
39
40impl HttpRequest {
41    pub fn get(url: impl Into<String>) -> Self {
42        HttpRequest {
43            method: Method::Get,
44            url: url.into(),
45            headers: Vec::new(),
46            body: None,
47        }
48    }
49
50    pub fn header(mut self, name: &str, value: impl Into<String>) -> Self {
51        self.headers.push((name.to_ascii_lowercase(), value.into()));
52        self
53    }
54}
55
56impl fmt::Debug for HttpRequest {
57    /// Redacts credentials, so requests can be logged safely.
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        let headers: Vec<(&str, &str)> = self
60            .headers
61            .iter()
62            .map(|(k, v)| {
63                let v = if SENSITIVE_HEADERS.contains(&k.as_str()) {
64                    "<redacted>"
65                } else {
66                    v.as_str()
67                };
68                (k.as_str(), v)
69            })
70            .collect();
71        f.debug_struct("HttpRequest")
72            .field("method", &self.method)
73            .field("url", &self.url)
74            .field("headers", &headers)
75            .field("body_len", &self.body.as_ref().map(Vec::len))
76            .finish()
77    }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81#[non_exhaustive]
82pub struct HttpResponse {
83    pub status: u16,
84    /// Header names are lowercase.
85    pub headers: Vec<(String, String)>,
86    pub body: Vec<u8>,
87    /// When the response arrived, stamped by the client. Providers use it for
88    /// `fetched_at` and to turn relative reset times into timestamps.
89    pub received_at: Timestamp,
90}
91
92impl HttpResponse {
93    pub fn new(
94        status: u16,
95        headers: Vec<(String, String)>,
96        body: Vec<u8>,
97        received_at: Timestamp,
98    ) -> Self {
99        let headers = headers
100            .into_iter()
101            .map(|(k, v)| (k.to_ascii_lowercase(), v))
102            .collect();
103        HttpResponse {
104            status,
105            headers,
106            body,
107            received_at,
108        }
109    }
110
111    /// First value of a header, by case-insensitive name.
112    pub fn header(&self, name: &str) -> Option<&str> {
113        self.headers
114            .iter()
115            .find(|(k, _)| k.eq_ignore_ascii_case(name))
116            .map(|(_, v)| v.as_str())
117    }
118
119    /// A `Retry-After` given in seconds. The HTTP-date form isn't read.
120    pub fn retry_after(&self) -> Option<Duration> {
121        self.header("retry-after")
122            .and_then(|v| v.trim().parse().ok())
123            .map(Duration::from_secs)
124    }
125
126    pub fn is_success(&self) -> bool {
127        (200..300).contains(&self.status)
128    }
129
130    /// The body as text, lossily, for error messages.
131    pub fn text(&self) -> String {
132        String::from_utf8_lossy(&self.body).into_owned()
133    }
134}
135
136/// The error for a non-2xx status a provider doesn't map itself: 404 is not
137/// found, 429 is slow down, and anything else a server failure. Whether 401
138/// and 403 refuse the provider's credentials is the provider's to say.
139pub fn status_error(resp: &HttpResponse, message: Option<String>) -> Error {
140    match resp.status {
141        404 => Error::NotFound,
142        429 => Error::RateLimited {
143            retry_after: resp.retry_after(),
144        },
145        status => Error::Server { status, message },
146    }
147}
148
149/// Characters escaped by [`path_segment`].
150const PATH_ESCAPES: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS
151    .add(b' ')
152    .add(b'"')
153    .add(b'#')
154    .add(b'%')
155    .add(b'<')
156    .add(b'>')
157    .add(b'?')
158    .add(b'[')
159    .add(b'\\')
160    .add(b']')
161    .add(b'^')
162    .add(b'`')
163    .add(b'{')
164    .add(b'|')
165    .add(b'}');
166
167/// Escapes an identifier for use in a URL path, leaving `/` and `:` literal:
168/// APIs expect `/works/doi:10.1038/nature14539` as written, but DOIs may
169/// contain `#`, `?` or `<>`, which must not be taken as URL syntax.
170pub fn path_segment(s: &str) -> String {
171    percent_encoding::utf8_percent_encode(s, PATH_ESCAPES).to_string()
172}
173
174/// Escapes Lucene/Solr query syntax in user text (DataCite, Open Library),
175/// so "BERT: Pre-training" is searched as words, not parsed as a field query.
176pub fn escape_lucene(text: &str) -> String {
177    let mut out = String::with_capacity(text.len());
178    for c in text.chars() {
179        if "+-&|!(){}[]^\"~*?:\\/".contains(c) {
180            out.push('\\');
181        }
182        out.push(c);
183    }
184    out
185}
186
187#[async_trait]
188pub trait HttpClient: Send + Sync {
189    /// Sends one request. `Ok` for every HTTP status; `Err` only for transport
190    /// failures (`Network`, `Timeout`).
191    async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Error>;
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn debug_redacts_credentials() {
200        let req = HttpRequest::get("https://example.org")
201            .header("Authorization", "Bearer s3cret")
202            .header("Accept", "application/json");
203        let shown = format!("{req:?}");
204        assert!(!shown.contains("s3cret"), "{shown}");
205        assert!(shown.contains("application/json"));
206    }
207
208    #[test]
209    fn path_segment_keeps_doi_slashes_but_escapes_url_syntax() {
210        assert_eq!(
211            path_segment("doi:10.1038/nature14539"),
212            "doi:10.1038/nature14539"
213        );
214        assert_eq!(
215            path_segment("10.1002/(sici)1097<34::aid>3.0.co;2-#"),
216            "10.1002/(sici)1097%3C34::aid%3E3.0.co;2-%23"
217        );
218    }
219
220    #[test]
221    fn response_headers_are_case_insensitive() {
222        let resp = HttpResponse::new(
223            200,
224            vec![("X-RateLimit-Cost-USD".into(), "0.001".into())],
225            Vec::new(),
226            Timestamp::UNIX_EPOCH,
227        );
228        assert_eq!(resp.header("x-ratelimit-cost-usd"), Some("0.001"));
229    }
230
231    #[test]
232    fn retry_after_reads_seconds() {
233        let resp = |v: &str| {
234            let headers = vec![("Retry-After".into(), v.into())];
235            HttpResponse::new(429, headers, Vec::new(), Timestamp::UNIX_EPOCH)
236        };
237        assert_eq!(resp(" 7 ").retry_after(), Some(Duration::from_secs(7)));
238        assert_eq!(
239            resp("18446744073709551615").retry_after(),
240            Some(Duration::from_secs(u64::MAX))
241        );
242        assert_eq!(resp("Wed, 21 Oct 2015 07:28:00 GMT").retry_after(), None);
243    }
244}