1use 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
26const 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 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 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 pub headers: Vec<(String, String)>,
85 pub body: Vec<u8>,
86 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 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 pub fn text(&self) -> String {
124 String::from_utf8_lossy(&self.body).into_owned()
125 }
126}
127
128const 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
146pub fn path_segment(s: &str) -> String {
150 percent_encoding::utf8_percent_encode(s, PATH_ESCAPES).to_string()
151}
152
153pub 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 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}