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;
13
14use async_trait::async_trait;
15use jiff::Timestamp;
16
17use crate::error::Error;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum Method {
22    Get,
23    Post,
24}
25
26/// Header names whose values are never shown in `Debug` output.
27const SENSITIVE_HEADERS: &[&str] = &["authorization", "x-api-key"];
28
29#[derive(Clone, PartialEq, Eq)]
30#[non_exhaustive]
31pub struct HttpRequest {
32    pub method: Method,
33    pub url: String,
34    /// Header names are lowercase.
35    pub headers: Vec<(String, String)>,
36    pub body: Option<Vec<u8>>,
37}
38
39impl HttpRequest {
40    pub fn get(url: impl Into<String>) -> Self {
41        HttpRequest {
42            method: Method::Get,
43            url: url.into(),
44            headers: Vec::new(),
45            body: None,
46        }
47    }
48
49    pub fn header(mut self, name: &str, value: impl Into<String>) -> Self {
50        self.headers.push((name.to_ascii_lowercase(), value.into()));
51        self
52    }
53}
54
55impl fmt::Debug for HttpRequest {
56    /// Redacts credentials, so requests can be logged safely.
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        let headers: Vec<(&str, &str)> = self
59            .headers
60            .iter()
61            .map(|(k, v)| {
62                let v = if SENSITIVE_HEADERS.contains(&k.as_str()) {
63                    "<redacted>"
64                } else {
65                    v.as_str()
66                };
67                (k.as_str(), v)
68            })
69            .collect();
70        f.debug_struct("HttpRequest")
71            .field("method", &self.method)
72            .field("url", &self.url)
73            .field("headers", &headers)
74            .field("body_len", &self.body.as_ref().map(Vec::len))
75            .finish()
76    }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80#[non_exhaustive]
81pub struct HttpResponse {
82    pub status: u16,
83    /// Header names are lowercase.
84    pub headers: Vec<(String, String)>,
85    pub body: Vec<u8>,
86    /// When the response arrived, stamped by the client. Providers use it for
87    /// `fetched_at` and to turn relative reset times into timestamps.
88    pub received_at: Timestamp,
89}
90
91impl HttpResponse {
92    pub fn new(
93        status: u16,
94        headers: Vec<(String, String)>,
95        body: Vec<u8>,
96        received_at: Timestamp,
97    ) -> Self {
98        let headers = headers
99            .into_iter()
100            .map(|(k, v)| (k.to_ascii_lowercase(), v))
101            .collect();
102        HttpResponse {
103            status,
104            headers,
105            body,
106            received_at,
107        }
108    }
109
110    /// First value of a header, by case-insensitive name.
111    pub fn header(&self, name: &str) -> Option<&str> {
112        self.headers
113            .iter()
114            .find(|(k, _)| k.eq_ignore_ascii_case(name))
115            .map(|(_, v)| v.as_str())
116    }
117
118    pub fn is_success(&self) -> bool {
119        (200..300).contains(&self.status)
120    }
121
122    /// The body as text, lossily, for error messages.
123    pub fn text(&self) -> String {
124        String::from_utf8_lossy(&self.body).into_owned()
125    }
126}
127
128/// Characters escaped by [`path_segment`].
129const PATH_ESCAPES: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS
130    .add(b' ')
131    .add(b'"')
132    .add(b'#')
133    .add(b'%')
134    .add(b'<')
135    .add(b'>')
136    .add(b'?')
137    .add(b'[')
138    .add(b'\\')
139    .add(b']')
140    .add(b'^')
141    .add(b'`')
142    .add(b'{')
143    .add(b'|')
144    .add(b'}');
145
146/// Escapes an identifier for use in a URL path, leaving `/` and `:` literal:
147/// APIs expect `/works/doi:10.1038/nature14539` as written, but DOIs may
148/// contain `#`, `?` or `<>`, which must not be taken as URL syntax.
149pub fn path_segment(s: &str) -> String {
150    percent_encoding::utf8_percent_encode(s, PATH_ESCAPES).to_string()
151}
152
153/// Escapes Lucene/Solr query syntax in user text (DataCite, Open Library),
154/// so "BERT: Pre-training" is searched as words, not parsed as a field query.
155pub fn escape_lucene(text: &str) -> String {
156    let mut out = String::with_capacity(text.len());
157    for c in text.chars() {
158        if "+-&|!(){}[]^\"~*?:\\/".contains(c) {
159            out.push('\\');
160        }
161        out.push(c);
162    }
163    out
164}
165
166#[async_trait]
167pub trait HttpClient: Send + Sync {
168    /// Sends one request. `Ok` for every HTTP status; `Err` only for transport
169    /// failures (`Network`, `Timeout`).
170    async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Error>;
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn debug_redacts_credentials() {
179        let req = HttpRequest::get("https://example.org")
180            .header("Authorization", "Bearer s3cret")
181            .header("Accept", "application/json");
182        let shown = format!("{req:?}");
183        assert!(!shown.contains("s3cret"), "{shown}");
184        assert!(shown.contains("application/json"));
185    }
186
187    #[test]
188    fn path_segment_keeps_doi_slashes_but_escapes_url_syntax() {
189        assert_eq!(
190            path_segment("doi:10.1038/nature14539"),
191            "doi:10.1038/nature14539"
192        );
193        assert_eq!(
194            path_segment("10.1002/(sici)1097<34::aid>3.0.co;2-#"),
195            "10.1002/(sici)1097%3C34::aid%3E3.0.co;2-%23"
196        );
197    }
198
199    #[test]
200    fn response_headers_are_case_insensitive() {
201        let resp = HttpResponse::new(
202            200,
203            vec![("X-RateLimit-Cost-USD".into(), "0.001".into())],
204            Vec::new(),
205            Timestamp::UNIX_EPOCH,
206        );
207        assert_eq!(resp.header("x-ratelimit-cost-usd"), Some("0.001"));
208    }
209}