Skip to main content

web_capture/
transport.rs

1//! Caller-owned HTTP transport and exact-byte response receipts.
2
3use std::{collections::BTreeMap, future::Future, pin::Pin};
4
5use serde::{Deserialize, Serialize};
6
7/// Headers retained in portable response receipts.
8pub const RECEIPT_HEADERS: [&str; 7] = [
9    "cache-control",
10    "content-encoding",
11    "content-length",
12    "content-type",
13    "etag",
14    "last-modified",
15    "location",
16];
17
18/// Owned request passed to an injectable transport.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct TransportRequest {
21    pub url: String,
22    pub method: String,
23    pub headers: BTreeMap<String, String>,
24}
25
26/// Structured transport outcome attached to a successful receipt.
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28#[serde(rename_all = "camelCase")]
29pub struct TransportDiagnostics {
30    pub outcome: String,
31}
32
33impl TransportDiagnostics {
34    #[must_use]
35    pub fn response() -> Self {
36        Self {
37            outcome: "response".to_string(),
38        }
39    }
40}
41
42/// Undecoded response bytes and provenance metadata.
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44#[serde(rename_all = "camelCase")]
45pub struct ResponseReceipt {
46    pub body: Vec<u8>,
47    pub final_url: String,
48    pub status: u16,
49    pub headers: BTreeMap<String, String>,
50    pub diagnostics: TransportDiagnostics,
51}
52
53/// Structured failure when no HTTP response receipt exists.
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, thiserror::Error)]
55#[error("{message}")]
56#[serde(rename_all = "camelCase")]
57pub struct TransportError {
58    pub kind: String,
59    pub message: String,
60    pub source_url: String,
61}
62
63pub type TransportFuture<'a> =
64    Pin<Box<dyn Future<Output = std::result::Result<ResponseReceipt, TransportError>> + Send + 'a>>;
65
66/// Injectable asynchronous transport.
67///
68/// Cancellation is explicit through normal Rust future lifetime semantics:
69/// dropping the future returned by [`Transport::execute`] cancels the request.
70pub trait Transport: Send + Sync {
71    fn execute(&self, request: TransportRequest) -> TransportFuture<'_>;
72}
73
74impl<F, Fut> Transport for F
75where
76    F: Fn(TransportRequest) -> Fut + Send + Sync,
77    Fut: Future<Output = std::result::Result<ResponseReceipt, TransportError>> + Send + 'static,
78{
79    fn execute(&self, request: TransportRequest) -> TransportFuture<'_> {
80        Box::pin(self(request))
81    }
82}
83
84/// Default reqwest implementation. Callers may supply an already configured client.
85#[cfg(feature = "runtime")]
86#[derive(Debug, Clone)]
87pub struct ReqwestTransport {
88    client: reqwest::Client,
89}
90
91#[cfg(feature = "runtime")]
92impl ReqwestTransport {
93    #[must_use]
94    pub const fn new(client: reqwest::Client) -> Self {
95        Self { client }
96    }
97}
98
99#[cfg(feature = "runtime")]
100impl Default for ReqwestTransport {
101    fn default() -> Self {
102        Self::new(reqwest::Client::new())
103    }
104}
105
106#[cfg(feature = "runtime")]
107impl Transport for ReqwestTransport {
108    fn execute(&self, request: TransportRequest) -> TransportFuture<'_> {
109        Box::pin(async move {
110            let method =
111                reqwest::Method::from_bytes(request.method.as_bytes()).map_err(|error| {
112                    TransportError {
113                        kind: "invalid_request".to_string(),
114                        message: error.to_string(),
115                        source_url: request.url.clone(),
116                    }
117                })?;
118            let mut builder = self.client.request(method, &request.url);
119            for (name, value) in &request.headers {
120                builder = builder.header(name, value);
121            }
122            let response = builder.send().await.map_err(|error| TransportError {
123                kind: if error.is_timeout() {
124                    "timeout"
125                } else if error.is_connect() {
126                    "connect"
127                } else {
128                    "transport"
129                }
130                .to_string(),
131                message: error.to_string(),
132                source_url: request.url.clone(),
133            })?;
134            let status = response.status().as_u16();
135            let final_url = response.url().to_string();
136            let headers = RECEIPT_HEADERS
137                .iter()
138                .filter_map(|name| {
139                    response
140                        .headers()
141                        .get(*name)
142                        .and_then(|value| value.to_str().ok())
143                        .map(|value| ((*name).to_string(), value.to_string()))
144                })
145                .collect();
146            let body = response
147                .bytes()
148                .await
149                .map_err(|error| TransportError {
150                    kind: "body".to_string(),
151                    message: error.to_string(),
152                    source_url: request.url,
153                })?
154                .to_vec();
155            Ok(ResponseReceipt {
156                body,
157                final_url,
158                status,
159                headers,
160                diagnostics: TransportDiagnostics::response(),
161            })
162        })
163    }
164}
165
166/// Capture an HTTP response through caller-supplied transport without decoding it.
167pub async fn capture_response_with_transport(
168    request: TransportRequest,
169    transport: &dyn Transport,
170) -> std::result::Result<ResponseReceipt, TransportError> {
171    transport.execute(request).await
172}
173
174/// Capture an HTTP response with the default reqwest transport.
175#[cfg(feature = "runtime")]
176pub async fn capture_response(
177    request: TransportRequest,
178) -> std::result::Result<ResponseReceipt, TransportError> {
179    capture_response_with_transport(request, &ReqwestTransport::default()).await
180}