netget/client/http_fetch/
transport.rs1use std::time::Duration;
30
31use anyhow::{anyhow, bail, Context, Result};
32use bytes::Bytes;
33use http_body_util::{BodyExt, Full, Limited};
34use hyper_util::rt::TokioIo;
35use tokio::io::{AsyncRead, AsyncWrite};
36
37#[derive(Debug, Clone)]
42pub struct HttpExchange {
43 pub status_code: u16,
44 pub status_text: String,
45 pub headers: serde_json::Map<String, serde_json::Value>,
46 pub body: String,
47}
48
49impl HttpExchange {
50 fn from_response(response: hyper::Response<Bytes>) -> Self {
51 let status = response.status();
52 let mut headers = serde_json::Map::new();
53 for (name, value) in response.headers() {
54 if let Ok(text) = value.to_str() {
55 headers.insert(name.to_string(), serde_json::json!(text));
56 }
57 }
58 HttpExchange {
59 status_code: status.as_u16(),
60 status_text: status.to_string(),
61 headers,
62 body: String::from_utf8_lossy(response.body()).into_owned(),
63 }
64 }
65}
66
67pub const MAX_RESPONSE_BODY_BYTES: usize = 8 * 1024 * 1024;
70
71pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
74
75pub const HTTPS_UNSUPPORTED: &str =
77 "https:// is not available on this transport: it has no TLS stack, and in the browser build \
78 no server on the page's virtual network holds a certificate a client could verify. Use \
79 http:// (NetGet's own servers in the page speak plain HTTP)";
80
81struct ConnectionDriver(tokio::task::JoinHandle<()>);
84
85impl Drop for ConnectionDriver {
86 fn drop(&mut self) {
87 self.0.abort();
88 }
89}
90
91fn parse_method(method: &str) -> Result<hyper::Method> {
92 const STANDARD_METHODS: [&str; 7] =
95 ["GET", "POST", "PUT", "DELETE", "HEAD", "PATCH", "OPTIONS"];
96 let method = STANDARD_METHODS
97 .into_iter()
98 .find(|standard| method.eq_ignore_ascii_case(standard))
99 .unwrap_or(method);
100 hyper::Method::from_bytes(method.as_bytes()).context("invalid HTTP method")
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct HttpTarget {
107 pub host: String,
108 pub port: u16,
109 pub authority: String,
110 pub path_and_query: String,
111}
112
113pub fn parse_http_url(url: &str) -> Result<HttpTarget> {
116 if url
117 .bytes()
118 .any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace())
119 {
120 bail!("HTTP URL contains unescaped whitespace or control characters");
121 }
122 let parsed = url::Url::parse(url).context("invalid HTTP URL")?;
123 if parsed.host_str().is_none() {
124 bail!("HTTP URL requires a host");
125 }
126 let uri: hyper::Uri = url.parse().with_context(|| format!("not a URL: {url}"))?;
127 match uri.scheme_str() {
128 Some("http") => {}
129 Some("https") => bail!("{HTTPS_UNSUPPORTED} (asked for {url})"),
130 Some(other) => bail!("unsupported URL scheme {other:?} in {url}; only http:// is"),
131 None => bail!("no scheme in {url}; expected http://host:port/path"),
132 }
133 let authority = uri
134 .authority()
135 .with_context(|| format!("no host in {url}"))?;
136 let host = authority
137 .host()
138 .trim_start_matches('[')
139 .trim_end_matches(']')
140 .to_string();
141 if authority.as_str().contains('@') {
142 bail!("HTTP URL userinfo is unsupported; use an explicit Authorization header");
143 }
144 if host.is_empty() {
145 bail!("HTTP URL requires a nonempty host");
146 }
147 let port = match authority.port() {
148 Some(port) => port
149 .as_str()
150 .parse::<u16>()
151 .context("invalid HTTP URL port")?,
152 None if authority.as_str().ends_with(':') => bail!("HTTP URL port is empty"),
153 None => 80,
154 };
155 let path_and_query = uri
156 .path_and_query()
157 .map(|p| p.as_str().to_string())
158 .filter(|p| !p.is_empty())
159 .unwrap_or_else(|| "/".to_string());
160 Ok(HttpTarget {
161 host,
162 port,
163 authority: authority.as_str().to_string(),
164 path_and_query,
165 })
166}
167
168pub async fn fetch(
172 method: &str,
173 url: &str,
174 headers: &[(String, String)],
175 body: Option<String>,
176 timeout: Duration,
177 max_body: usize,
178) -> Result<HttpExchange> {
179 fetch_response(
180 method,
181 url,
182 headers,
183 body.map(Bytes::from),
184 timeout,
185 max_body,
186 )
187 .await
188 .map(HttpExchange::from_response)
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub enum Wire {
194 Http1,
196 Http2PriorKnowledge,
198}
199
200#[derive(Clone, Copy, Debug, Default)]
205pub struct SpawnExecutor;
206
207impl<F> hyper::rt::Executor<F> for SpawnExecutor
208where
209 F: std::future::Future + Send + 'static,
210 F::Output: Send + 'static,
211{
212 fn execute(&self, future: F) {
213 drop(tokio::spawn(future));
216 }
217}
218
219pub async fn fetch_response(
221 method: &str,
222 url: &str,
223 headers: &[(String, String)],
224 body: Option<Bytes>,
225 timeout: Duration,
226 max_body: usize,
227) -> Result<hyper::Response<Bytes>> {
228 fetch_response_on(Wire::Http1, method, url, headers, body, timeout, max_body).await
229}
230
231#[allow(clippy::too_many_arguments)]
233pub async fn fetch_response_on(
234 wire: Wire,
235 method: &str,
236 url: &str,
237 headers: &[(String, String)],
238 body: Option<Bytes>,
239 timeout: Duration,
240 max_body: usize,
241) -> Result<hyper::Response<Bytes>> {
242 let target = parse_http_url(url)?;
243 let exchange = async {
244 let stream = tokio::net::TcpStream::connect((target.host.clone(), target.port))
245 .await
246 .with_context(|| format!("connect to {}:{}", target.host, target.port))?;
247 match wire {
248 Wire::Http1 => {
249 exchange_response(stream, method, &target, headers, body, max_body).await
250 }
251 Wire::Http2PriorKnowledge => {
252 exchange_response_h2(stream, method, &target, headers, body, max_body).await
253 }
254 }
255 };
256 tokio::time::timeout(timeout, exchange)
257 .await
258 .map_err(|_| anyhow!("no complete response from {url} within {timeout:?}"))?
259}
260
261pub async fn exchange<S>(
263 io: S,
264 method: &str,
265 target: &HttpTarget,
266 headers: &[(String, String)],
267 body: Option<String>,
268 max_body: usize,
269) -> Result<HttpExchange>
270where
271 S: AsyncRead + AsyncWrite + Send + Unpin + 'static,
272{
273 exchange_response(io, method, target, headers, body.map(Bytes::from), max_body)
274 .await
275 .map(HttpExchange::from_response)
276}
277
278pub async fn exchange_response<S>(
280 io: S,
281 method: &str,
282 target: &HttpTarget,
283 headers: &[(String, String)],
284 body: Option<Bytes>,
285 max_body: usize,
286) -> Result<hyper::Response<Bytes>>
287where
288 S: AsyncRead + AsyncWrite + Send + Unpin + 'static,
289{
290 let method = parse_method(method)?;
291
292 let mut builder = hyper::Request::builder()
293 .method(method.as_str())
294 .uri(target.path_and_query.as_str())
295 .header(hyper::header::HOST, target.authority.as_str());
296 for (name, value) in headers {
297 let name = hyper::header::HeaderName::from_bytes(name.as_bytes())
298 .with_context(|| format!("invalid header name {name:?}"))?;
299 let value = hyper::header::HeaderValue::from_str(value)
300 .with_context(|| format!("invalid value for header {name}"))?;
301 let map = builder
302 .headers_mut()
303 .context("request builder rejected an earlier part")?;
304 if name == hyper::header::HOST {
305 map.insert(name, value);
306 } else {
307 map.append(name, value);
308 }
309 }
310 let request = builder
311 .body(Full::new(body.unwrap_or_default()))
312 .context("build HTTP request")?;
313
314 let (mut sender, connection) = hyper::client::conn::http1::handshake(TokioIo::new(io))
315 .await
316 .context("HTTP/1.1 handshake")?;
317 let _driver = ConnectionDriver(tokio::spawn(async move {
320 let _ = connection.await;
321 }));
322 let result = async {
323 let response = sender
324 .send_request(request)
325 .await
326 .context("send HTTP request")?;
327 let (parts, body) = response.into_parts();
328 let collected = Limited::new(body, max_body).collect().await.map_err(|e| {
329 if e.downcast_ref::<http_body_util::LengthLimitError>()
330 .is_some()
331 {
332 anyhow!("response body exceeds the {max_body}-byte limit; refused")
333 } else {
334 anyhow!("read HTTP response body: {e}")
335 }
336 })?;
337 Ok(hyper::Response::from_parts(parts, collected.to_bytes()))
338 }
339 .await;
340 result
341}
342
343pub async fn exchange_response_h2<S>(
348 io: S,
349 method: &str,
350 target: &HttpTarget,
351 headers: &[(String, String)],
352 body: Option<Bytes>,
353 max_body: usize,
354) -> Result<hyper::Response<Bytes>>
355where
356 S: AsyncRead + AsyncWrite + Send + Unpin + 'static,
357{
358 let method = parse_method(method)?;
359
360 let uri = format!("http://{}{}", target.authority, target.path_and_query);
361 let mut builder = hyper::Request::builder()
362 .method(method.as_str())
363 .uri(uri.as_str())
364 .version(hyper::Version::HTTP_2);
365 for (name, value) in headers {
366 let name = hyper::header::HeaderName::from_bytes(name.as_bytes())
367 .with_context(|| format!("invalid header name {name:?}"))?;
368 let value = hyper::header::HeaderValue::from_str(value)
369 .with_context(|| format!("invalid value for header {name}"))?;
370 builder
371 .headers_mut()
372 .context("request builder rejected an earlier part")?
373 .append(name, value);
374 }
375 let request = builder
376 .body(Full::new(body.unwrap_or_default()))
377 .context("build HTTP/2 request")?;
378
379 let (mut sender, connection) =
380 hyper::client::conn::http2::handshake(SpawnExecutor, TokioIo::new(io))
381 .await
382 .context("HTTP/2 handshake (prior knowledge)")?;
383 let _driver = ConnectionDriver(tokio::spawn(async move {
384 let _ = connection.await;
385 }));
386 let result = async {
387 let response = sender
388 .send_request(request)
389 .await
390 .context("send HTTP/2 request")?;
391 let (parts, body) = response.into_parts();
392 let collected = Limited::new(body, max_body).collect().await.map_err(|e| {
393 if e.downcast_ref::<http_body_util::LengthLimitError>()
394 .is_some()
395 {
396 anyhow!("response body exceeds the {max_body}-byte limit; refused")
397 } else {
398 anyhow!("read HTTP/2 response body: {e}")
399 }
400 })?;
401 Ok(hyper::Response::from_parts(parts, collected.to_bytes()))
402 }
403 .await;
404 result
405}