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}