1use 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
27const 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 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 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 pub headers: Vec<(String, String)>,
86 pub body: Vec<u8>,
87 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 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 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 pub fn text(&self) -> String {
132 String::from_utf8_lossy(&self.body).into_owned()
133 }
134}
135
136pub 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
149const 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
167pub fn path_segment(s: &str) -> String {
171 percent_encoding::utf8_percent_encode(s, PATH_ESCAPES).to_string()
172}
173
174pub 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 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}