1use std::{
2 collections::HashMap,
3 fmt,
4 sync::{Arc, Mutex},
5 time::Duration,
6};
7
8use anyhow::anyhow;
9use async_trait::async_trait;
10use futures_util::TryStreamExt;
11use http::{
12 HeaderName, HeaderValue,
13 header::{COOKIE, LOCATION, USER_AGENT},
14};
15use millipede_core::{
16 http_client::{HttpClient, HttpClientError, HttpRequest, HttpResponse, StreamingResponse},
17 request::{Method, RequestBody},
18};
19use url::Url;
20
21#[derive(Debug, Clone)]
39#[non_exhaustive]
40#[must_use = "client options do nothing unless passed to ReqwestClient::with_options"]
41pub struct ReqwestClientOptions {
42 pub connect_timeout: Duration,
44 pub default_timeout: Duration,
46 pub max_cached_clients: usize,
48 pub default_user_agent: Option<String>,
50 pub header_generator: Arc<millipede_fingerprint::HeaderGenerator>,
52}
53
54impl Default for ReqwestClientOptions {
55 fn default() -> Self {
56 Self {
57 connect_timeout: Duration::from_secs(10),
58 default_timeout: Duration::from_secs(30),
59 max_cached_clients: 8,
60 default_user_agent: Some(
61 "millipede/0.1 (+https://github.com/satvik007/millipede)".to_owned(),
62 ),
63 header_generator: Arc::new(millipede_fingerprint::HeaderGenerator::new()),
64 }
65 }
66}
67
68impl ReqwestClientOptions {
69 pub fn with_connect_timeout(mut self, timeout: Duration) -> Self {
71 self.connect_timeout = timeout;
72 self
73 }
74
75 pub fn with_default_timeout(mut self, timeout: Duration) -> Self {
77 self.default_timeout = timeout;
78 self
79 }
80
81 pub fn with_max_cached_clients(mut self, maximum: usize) -> Self {
83 self.max_cached_clients = maximum;
84 self
85 }
86
87 pub fn with_default_user_agent(mut self, user_agent: Option<String>) -> Self {
89 self.default_user_agent = user_agent;
90 self
91 }
92
93 pub fn with_header_generator(
95 mut self,
96 generator: Arc<millipede_fingerprint::HeaderGenerator>,
97 ) -> Self {
98 self.header_generator = generator;
99 self
100 }
101}
102
103pub struct ReqwestClient {
114 options: ReqwestClientOptions,
115 clients: Mutex<HashMap<Option<Url>, Arc<reqwest::Client>>>,
116}
117
118impl ReqwestClient {
119 pub fn new() -> Result<Self, HttpClientError> {
121 Self::with_options(ReqwestClientOptions::default())
122 }
123
124 pub fn with_options(options: ReqwestClientOptions) -> Result<Self, HttpClientError> {
126 let client = Arc::new(Self::build_client(&options, None)?);
127 let mut clients = HashMap::new();
128 clients.insert(None, client);
129 Ok(Self {
130 options,
131 clients: Mutex::new(clients),
132 })
133 }
134
135 fn build_client(
136 options: &ReqwestClientOptions,
137 proxy: Option<&Url>,
138 ) -> Result<reqwest::Client, HttpClientError> {
139 let mut builder = reqwest::Client::builder()
140 .redirect(reqwest::redirect::Policy::none())
141 .connect_timeout(options.connect_timeout)
142 .timeout(options.default_timeout);
143
144 if let Some(proxy_url) = proxy {
145 let mut reqwest_proxy = reqwest::Proxy::all(proxy_url.as_str())
146 .map_err(|error| HttpClientError::build(anyhow::Error::new(error)))?;
147 if !proxy_url.username().is_empty() {
148 let username = percent_decode(proxy_url.username());
149 let password = percent_decode(proxy_url.password().unwrap_or_default());
150 reqwest_proxy = reqwest_proxy.basic_auth(&username, &password);
151 }
152 builder = builder.proxy(reqwest_proxy);
153 } else {
154 builder = builder.no_proxy();
155 }
156
157 builder
158 .build()
159 .map_err(|error| HttpClientError::build(anyhow::Error::new(error)))
160 }
161
162 fn client_for(&self, proxy: Option<&Url>) -> Result<Arc<reqwest::Client>, HttpClientError> {
163 let key = proxy.cloned();
164 let mut clients = self
165 .clients
166 .lock()
167 .unwrap_or_else(|error| error.into_inner());
168 if let Some(client) = clients.get(&key) {
169 return Ok(Arc::clone(client));
170 }
171
172 let client = Arc::new(Self::build_client(&self.options, proxy)?);
173 if clients.len() >= self.options.max_cached_clients {
175 clients.clear();
176 }
177 clients.insert(key, Arc::clone(&client));
178 Ok(client)
179 }
180
181 async fn execute_following_redirects(
182 &self,
183 request: &HttpRequest,
184 ) -> Result<(reqwest::Response, Vec<Url>), HttpClientError> {
185 let mut current_url = request.url.clone();
186 let mut current_method = request.method.clone();
187 let mut current_body = request.body.clone();
188 let mut chain = Vec::new();
189
190 loop {
191 let client = self.client_for(request.proxy.as_ref())?;
192 let mut headers = request.headers.clone();
193 if request.use_header_generator {
194 let seed = request
195 .session_token
196 .as_ref()
197 .map(|token| token.as_str().to_owned())
198 .unwrap_or_else(|| current_url.as_str().to_owned());
199 let profile = self.options.header_generator.generate(&seed);
200 if !headers.contains_key(USER_AGENT) {
201 if let Ok(value) = profile.user_agent.parse() {
202 headers.insert(USER_AGENT, value);
203 }
204 }
205 for (name, value) in profile.headers {
206 if let (Ok(name), Ok(value)) =
207 (name.parse::<HeaderName>(), value.parse::<HeaderValue>())
208 {
209 if !headers.contains_key(&name) {
210 headers.insert(name, value);
211 }
212 }
213 }
214 }
215 if !headers.contains_key(USER_AGENT) {
216 if let Some(user_agent) = &self.options.default_user_agent {
217 let value = user_agent.parse().map_err(|error| {
218 HttpClientError::invalid_request(anyhow!(
219 "invalid default User-Agent: {error}"
220 ))
221 })?;
222 headers.insert(USER_AGENT, value);
223 }
224 }
225 if let Some(jar) = &request.cookie_jar {
226 if let Some(jar_cookie) = jar.cookie_header_for(¤t_url) {
227 if let Some(existing) = headers.get(COOKIE) {
228 let mut combined = Vec::with_capacity(
229 existing.as_bytes().len() + 2 + jar_cookie.as_bytes().len(),
230 );
231 combined.extend_from_slice(existing.as_bytes());
232 combined.extend_from_slice(b"; ");
233 combined.extend_from_slice(jar_cookie.as_bytes());
234 let value = http::HeaderValue::from_bytes(&combined).map_err(|error| {
235 HttpClientError::invalid_request(anyhow::Error::new(error))
236 })?;
237 headers.insert(COOKIE, value);
238 } else {
239 headers.insert(COOKIE, jar_cookie);
240 }
241 }
242 }
243
244 let mut builder = client
245 .request(current_method.clone(), current_url.clone())
246 .headers(headers);
247 if let Some(body) = ¤t_body {
248 builder = match body {
249 RequestBody::Bytes(bytes) => builder.body(bytes.clone()),
250 RequestBody::Form(pairs) => builder.form(pairs),
251 RequestBody::Json(value) => builder.json(value),
252 };
253 }
254 if let Some(timeout) = request.timeout {
255 builder = builder.timeout(timeout);
256 }
257
258 let response = builder.send().await.map_err(map_reqwest_error)?;
259 let status = response.status();
260 if let Some(jar) = &request.cookie_jar {
261 jar.store_response_cookies(¤t_url, response.headers());
262 }
263
264 if status.is_redirection() {
265 if let Some(location) = response.headers().get(LOCATION) {
266 if chain.len() as u32 >= request.max_redirects {
267 return Err(HttpClientError::redirect(anyhow!(
268 "exceeded {} redirects",
269 request.max_redirects
270 )));
271 }
272 let location = location.to_str().map_err(|error| {
273 HttpClientError::redirect(anyhow!(
274 "invalid redirect Location header: {error}"
275 ))
276 })?;
277 let next_url = current_url.join(location).map_err(|error| {
278 HttpClientError::redirect(anyhow!("invalid redirect target: {error}"))
279 })?;
280 chain.push(current_url);
281
282 if status == http::StatusCode::SEE_OTHER
284 || ((status == http::StatusCode::MOVED_PERMANENTLY
285 || status == http::StatusCode::FOUND)
286 && current_method != Method::GET
287 && current_method != Method::HEAD)
288 {
289 current_method = Method::GET;
290 current_body = None;
291 }
292 current_url = next_url;
293 continue;
294 }
295 }
296
297 return Ok((response, chain));
298 }
299 }
300}
301
302#[async_trait]
303impl HttpClient for ReqwestClient {
304 async fn send(&self, request: HttpRequest) -> Result<HttpResponse, HttpClientError> {
305 let (response, chain) = self.execute_following_redirects(&request).await?;
306 let url = response.url().clone();
307 let status = response.status();
308 let headers = response.headers().clone();
309 let body = response
310 .bytes()
311 .await
312 .map_err(|error| HttpClientError::decode(anyhow::Error::new(error)))?;
313 Ok(HttpResponse::new(url, status, headers, body).with_redirect_chain(chain))
314 }
315
316 async fn stream(&self, request: HttpRequest) -> Result<StreamingResponse, HttpClientError> {
317 let (response, _chain) = self.execute_following_redirects(&request).await?;
318 let url = response.url().clone();
319 let status = response.status();
320 let headers = response.headers().clone();
321 let body = response
322 .bytes_stream()
323 .map_err(|error| HttpClientError::io(anyhow::Error::new(error)));
324 Ok(StreamingResponse::new(url, status, headers, Box::pin(body)))
325 }
326}
327
328impl fmt::Debug for ReqwestClient {
329 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
330 let cached_clients = self
331 .clients
332 .lock()
333 .unwrap_or_else(|error| error.into_inner())
334 .len();
335 formatter
336 .debug_struct("ReqwestClient")
337 .field("options", &self.options)
338 .field("cached_clients", &cached_clients)
339 .finish()
340 }
341}
342
343fn map_reqwest_error(error: reqwest::Error) -> HttpClientError {
344 if error.is_timeout() {
345 HttpClientError::timeout(anyhow::Error::new(error))
346 } else if error.is_connect() {
347 HttpClientError::connect(anyhow::Error::new(error))
348 } else if error.is_builder() || error.is_request() {
349 HttpClientError::invalid_request(anyhow::Error::new(error))
350 } else if error.is_decode() || error.is_body() {
351 HttpClientError::decode(anyhow::Error::new(error))
352 } else {
353 HttpClientError::other(anyhow::Error::new(error))
354 }
355}
356
357fn percent_decode(value: &str) -> String {
358 let bytes = value.as_bytes();
359 let mut decoded = Vec::with_capacity(bytes.len());
360 let mut index = 0;
361 while index < bytes.len() {
362 if bytes[index] == b'%' && index + 2 < bytes.len() {
363 if let (Some(high), Some(low)) = (hex(bytes[index + 1]), hex(bytes[index + 2])) {
364 decoded.push(high * 16 + low);
365 index += 3;
366 continue;
367 }
368 }
369 decoded.push(bytes[index]);
370 index += 1;
371 }
372 String::from_utf8_lossy(&decoded).into_owned()
373}
374
375fn hex(byte: u8) -> Option<u8> {
376 match byte {
377 b'0'..=b'9' => Some(byte - b'0'),
378 b'a'..=b'f' => Some(byte - b'a' + 10),
379 b'A'..=b'F' => Some(byte - b'A' + 10),
380 _ => None,
381 }
382}