Skip to main content

millipede_core/
http_client.rs

1//! Backend-independent HTTP request and response abstractions.
2
3use std::{borrow::Cow, fmt, sync::Arc, time::Duration};
4
5use bytes::Bytes;
6use futures_util::stream::BoxStream;
7use http::{HeaderMap, StatusCode};
8use url::Url;
9
10use crate::{
11    cookies::CookieJar,
12    request::{Method, Request, RequestBody},
13};
14
15/// A typed HTTP status carried inside a [`crate::errors::CrawlError`].
16///
17/// # Examples
18///
19/// ```
20/// use http::StatusCode;
21/// use millipede_core::http_client::HttpStatusError;
22///
23/// let error = HttpStatusError::new(StatusCode::TOO_MANY_REQUESTS);
24/// assert_eq!(error.status, StatusCode::TOO_MANY_REQUESTS);
25/// ```
26#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
27#[error("HTTP status {status}")]
28pub struct HttpStatusError {
29    /// The response status.
30    pub status: StatusCode,
31    /// Parsed Retry-After response header, when the response carried one.
32    pub retry_after: Option<Duration>,
33}
34
35impl HttpStatusError {
36    /// Creates a status carrier.
37    pub fn new(status: StatusCode) -> Self {
38        Self {
39            status,
40            retry_after: None,
41        }
42    }
43
44    /// Attaches a parsed Retry-After duration.
45    pub fn with_retry_after(mut self, retry_after: Duration) -> Self {
46        self.retry_after = Some(retry_after);
47        self
48    }
49}
50
51/// An error produced while preparing or executing an HTTP request.
52///
53/// # Examples
54///
55/// ```
56/// use millipede_core::http_client::HttpClientError;
57///
58/// let error = HttpClientError::timeout(anyhow::anyhow!("deadline elapsed"));
59/// assert!(error.is_timeout());
60/// assert!(!error.is_connect());
61/// ```
62#[non_exhaustive]
63#[derive(Debug, thiserror::Error)]
64pub enum HttpClientError {
65    /// The HTTP client could not be built.
66    #[error("failed to build HTTP client: {0}")]
67    Build(#[source] anyhow::Error),
68    /// The request was invalid.
69    #[error("invalid HTTP request: {0}")]
70    InvalidRequest(#[source] anyhow::Error),
71    /// The remote endpoint could not be connected to.
72    #[error("HTTP connection failed: {0}")]
73    Connect(#[source] anyhow::Error),
74    /// The request timed out.
75    #[error("HTTP request timed out: {0}")]
76    Timeout(#[source] anyhow::Error),
77    /// Redirect processing failed.
78    #[error("HTTP redirect failed: {0}")]
79    Redirect(#[source] anyhow::Error),
80    /// The response could not be decoded.
81    #[error("HTTP response decode failed: {0}")]
82    Decode(#[source] anyhow::Error),
83    /// An input/output operation failed.
84    #[error("HTTP I/O failed: {0}")]
85    Io(#[source] anyhow::Error),
86    /// Another HTTP client error occurred.
87    #[error("HTTP client error: {0}")]
88    Other(#[source] anyhow::Error),
89}
90
91impl HttpClientError {
92    /// Returns whether this error represents a connection failure.
93    pub fn is_connect(&self) -> bool {
94        matches!(self, Self::Connect(_))
95    }
96
97    /// Returns whether this error represents a timeout.
98    pub fn is_timeout(&self) -> bool {
99        matches!(self, Self::Timeout(_))
100    }
101
102    /// Creates a client-build error.
103    pub fn build(error: impl Into<anyhow::Error>) -> Self {
104        Self::Build(error.into())
105    }
106
107    /// Creates an invalid-request error.
108    pub fn invalid_request(error: impl Into<anyhow::Error>) -> Self {
109        Self::InvalidRequest(error.into())
110    }
111
112    /// Creates a connection error.
113    pub fn connect(error: impl Into<anyhow::Error>) -> Self {
114        Self::Connect(error.into())
115    }
116
117    /// Creates a timeout error.
118    pub fn timeout(error: impl Into<anyhow::Error>) -> Self {
119        Self::Timeout(error.into())
120    }
121
122    /// Creates a redirect error.
123    pub fn redirect(error: impl Into<anyhow::Error>) -> Self {
124        Self::Redirect(error.into())
125    }
126
127    /// Creates a response-decode error.
128    pub fn decode(error: impl Into<anyhow::Error>) -> Self {
129        Self::Decode(error.into())
130    }
131
132    /// Creates an input/output error.
133    pub fn io(error: impl Into<anyhow::Error>) -> Self {
134        Self::Io(error.into())
135    }
136
137    /// Creates an otherwise unclassified HTTP client error.
138    pub fn other(error: impl Into<anyhow::Error>) -> Self {
139        Self::Other(error.into())
140    }
141}
142
143/// A backend-independent HTTP request.
144///
145/// Fingerprint-aware clients can generate browser-like headers when requested and use the
146/// optional session token to keep those headers consistent across related requests.
147///
148/// # Examples
149///
150/// ```
151/// use millipede_core::{http_client::HttpRequest, request::Method};
152/// use url::Url;
153///
154/// let request = HttpRequest::new(Url::parse("https://example.com/")?)
155///     .method(Method::HEAD)
156///     .max_redirects(3);
157/// assert_eq!(request.method, Method::HEAD);
158/// # Ok::<(), url::ParseError>(())
159/// ```
160#[non_exhaustive]
161#[derive(Debug, Clone)]
162pub struct HttpRequest {
163    /// URL to request.
164    pub url: Url,
165    /// HTTP method.
166    pub method: Method,
167    /// HTTP request headers.
168    pub headers: HeaderMap,
169    /// Optional request body.
170    pub body: Option<RequestBody>,
171    /// Optional cookie jar used for this request and its response.
172    pub cookie_jar: Option<Arc<CookieJar>>,
173    /// Optional proxy URL.
174    pub proxy: Option<Url>,
175    /// Optional request timeout.
176    pub timeout: Option<Duration>,
177    /// Maximum number of redirects to follow.
178    pub max_redirects: u32,
179    /// Whether the HTTP client should generate browser-like request headers.
180    pub use_header_generator: bool,
181    /// Token used to keep generated headers consistent across a session.
182    pub session_token: Option<crate::session::SessionToken>,
183}
184
185impl HttpRequest {
186    /// Creates a GET request with no headers, body, cookie jar, proxy, or timeout.
187    pub fn new(url: Url) -> Self {
188        Self {
189            url,
190            method: Method::GET,
191            headers: HeaderMap::new(),
192            body: None,
193            cookie_jar: None,
194            proxy: None,
195            timeout: None,
196            max_redirects: 10,
197            use_header_generator: false,
198            session_token: None,
199        }
200    }
201
202    /// Copies the HTTP-facing fields from a crawl request.
203    pub fn from_request(request: &Request) -> Self {
204        Self::new(request.url.clone())
205            .method(request.method.clone())
206            .headers(request.headers.clone())
207            .body_option(request.body.clone())
208    }
209
210    /// Sets the HTTP method.
211    pub fn method(mut self, method: Method) -> Self {
212        self.method = method;
213        self
214    }
215
216    /// Replaces the HTTP headers.
217    pub fn headers(mut self, headers: HeaderMap) -> Self {
218        self.headers = headers;
219        self
220    }
221
222    /// Sets the request body.
223    pub fn body(mut self, body: RequestBody) -> Self {
224        self.body = Some(body);
225        self
226    }
227
228    fn body_option(mut self, body: Option<RequestBody>) -> Self {
229        self.body = body;
230        self
231    }
232
233    /// Sets the cookie jar.
234    pub fn cookie_jar(mut self, cookie_jar: Arc<CookieJar>) -> Self {
235        self.cookie_jar = Some(cookie_jar);
236        self
237    }
238
239    /// Sets the proxy URL.
240    pub fn proxy(mut self, proxy: Url) -> Self {
241        self.proxy = Some(proxy);
242        self
243    }
244
245    /// Sets the request timeout.
246    pub fn timeout(mut self, timeout: Duration) -> Self {
247        self.timeout = Some(timeout);
248        self
249    }
250
251    /// Sets the maximum number of redirects to follow.
252    pub fn max_redirects(mut self, max_redirects: u32) -> Self {
253        self.max_redirects = max_redirects;
254        self
255    }
256
257    /// Enables or disables browser-like header generation.
258    pub fn use_header_generator(mut self, enabled: bool) -> Self {
259        self.use_header_generator = enabled;
260        self
261    }
262
263    /// Sets the token used for consistent per-session header generation.
264    pub fn session_token(mut self, token: crate::session::SessionToken) -> Self {
265        self.session_token = Some(token);
266        self
267    }
268}
269
270/// A fully buffered HTTP response.
271///
272/// # Examples
273///
274/// ```
275/// use bytes::Bytes;
276/// use http::{HeaderMap, StatusCode};
277/// use millipede_core::http_client::HttpResponse;
278/// use url::Url;
279///
280/// let response = HttpResponse::new(
281///     Url::parse("https://example.com/")?,
282///     StatusCode::OK,
283///     HeaderMap::new(),
284///     Bytes::from_static(b"hello"),
285/// );
286/// assert_eq!(response.text(), "hello");
287/// # Ok::<(), url::ParseError>(())
288/// ```
289#[non_exhaustive]
290#[derive(Debug, Clone)]
291pub struct HttpResponse {
292    /// Final URL after redirects.
293    pub url: Url,
294    /// HTTP response status.
295    pub status: StatusCode,
296    /// HTTP response headers.
297    pub headers: HeaderMap,
298    /// Fully buffered response body.
299    pub body: Bytes,
300    /// Intermediate redirect URLs in order, excluding the final URL.
301    pub redirect_chain: Vec<Url>,
302}
303
304impl HttpResponse {
305    /// Creates a response with an empty redirect chain.
306    pub fn new(url: Url, status: StatusCode, headers: HeaderMap, body: Bytes) -> Self {
307        Self {
308            url,
309            status,
310            headers,
311            body,
312            redirect_chain: Vec::new(),
313        }
314    }
315
316    /// Sets the intermediate redirect URLs.
317    pub fn with_redirect_chain(mut self, chain: Vec<Url>) -> Self {
318        self.redirect_chain = chain;
319        self
320    }
321
322    /// Returns the body decoded as UTF-8, replacing invalid sequences lossily.
323    pub fn text(&self) -> Cow<'_, str> {
324        String::from_utf8_lossy(&self.body)
325    }
326
327    /// Deserializes the response body as JSON.
328    pub fn json<T: serde::de::DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
329        serde_json::from_slice(&self.body)
330    }
331}
332
333/// An HTTP response whose body arrives as a byte stream.
334///
335/// # Examples
336///
337/// ```
338/// use futures_util::stream;
339/// use http::{HeaderMap, StatusCode};
340/// use millipede_core::http_client::StreamingResponse;
341/// use url::Url;
342///
343/// let response = StreamingResponse::new(
344///     Url::parse("https://example.com/")?,
345///     StatusCode::OK,
346///     HeaderMap::new(),
347///     Box::pin(stream::empty()),
348/// );
349/// assert_eq!(response.status, StatusCode::OK);
350/// # Ok::<(), url::ParseError>(())
351/// ```
352#[non_exhaustive]
353pub struct StreamingResponse {
354    /// Final URL after redirects.
355    pub url: Url,
356    /// HTTP response status.
357    pub status: StatusCode,
358    /// HTTP response headers.
359    pub headers: HeaderMap,
360    /// Stream of response body chunks.
361    pub body: BoxStream<'static, Result<Bytes, HttpClientError>>,
362}
363
364impl StreamingResponse {
365    /// Creates a streaming response.
366    pub fn new(
367        url: Url,
368        status: StatusCode,
369        headers: HeaderMap,
370        body: BoxStream<'static, Result<Bytes, HttpClientError>>,
371    ) -> Self {
372        Self {
373            url,
374            status,
375            headers,
376            body,
377        }
378    }
379}
380
381impl fmt::Debug for StreamingResponse {
382    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
383        formatter
384            .debug_struct("StreamingResponse")
385            .field("url", &self.url)
386            .field("status", &self.status)
387            .field("headers", &self.headers)
388            .finish_non_exhaustive()
389    }
390}
391
392/// An object-safe asynchronous HTTP client backend.
393///
394/// # Examples
395///
396/// ```
397/// use std::sync::Arc;
398/// use millipede_core::http_client::HttpClient;
399///
400/// fn accepts_client(_client: Arc<dyn HttpClient>) {}
401/// ```
402#[async_trait::async_trait]
403pub trait HttpClient: Send + Sync + 'static {
404    /// Sends a request and buffers the complete response body.
405    async fn send(&self, request: HttpRequest) -> Result<HttpResponse, HttpClientError>;
406
407    /// Sends a request and returns a streaming response body.
408    async fn stream(&self, request: HttpRequest) -> Result<StreamingResponse, HttpClientError>;
409}