use std::time::Duration;
use anyhow::{anyhow, bail, Context, Result};
use bytes::Bytes;
use http_body_util::{BodyExt, Full, Limited};
use hyper_util::rt::TokioIo;
use tokio::io::{AsyncRead, AsyncWrite};
#[derive(Debug, Clone)]
pub struct HttpExchange {
pub status_code: u16,
pub status_text: String,
pub headers: serde_json::Map<String, serde_json::Value>,
pub body: String,
}
impl HttpExchange {
fn from_response(response: hyper::Response<Bytes>) -> Self {
let status = response.status();
let mut headers = serde_json::Map::new();
for (name, value) in response.headers() {
if let Ok(text) = value.to_str() {
headers.insert(name.to_string(), serde_json::json!(text));
}
}
HttpExchange {
status_code: status.as_u16(),
status_text: status.to_string(),
headers,
body: String::from_utf8_lossy(response.body()).into_owned(),
}
}
}
pub const MAX_RESPONSE_BODY_BYTES: usize = 8 * 1024 * 1024;
pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
pub const HTTPS_UNSUPPORTED: &str =
"https:// is not available on this transport: it has no TLS stack, and in the browser build \
no server on the page's virtual network holds a certificate a client could verify. Use \
http:// (NetGet's own servers in the page speak plain HTTP)";
struct ConnectionDriver(tokio::task::JoinHandle<()>);
impl Drop for ConnectionDriver {
fn drop(&mut self) {
self.0.abort();
}
}
fn parse_method(method: &str) -> Result<hyper::Method> {
const STANDARD_METHODS: [&str; 7] =
["GET", "POST", "PUT", "DELETE", "HEAD", "PATCH", "OPTIONS"];
let method = STANDARD_METHODS
.into_iter()
.find(|standard| method.eq_ignore_ascii_case(standard))
.unwrap_or(method);
hyper::Method::from_bytes(method.as_bytes()).context("invalid HTTP method")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpTarget {
pub host: String,
pub port: u16,
pub authority: String,
pub path_and_query: String,
}
pub fn parse_http_url(url: &str) -> Result<HttpTarget> {
if url
.bytes()
.any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace())
{
bail!("HTTP URL contains unescaped whitespace or control characters");
}
let parsed = url::Url::parse(url).context("invalid HTTP URL")?;
if parsed.host_str().is_none() {
bail!("HTTP URL requires a host");
}
let uri: hyper::Uri = url.parse().with_context(|| format!("not a URL: {url}"))?;
match uri.scheme_str() {
Some("http") => {}
Some("https") => bail!("{HTTPS_UNSUPPORTED} (asked for {url})"),
Some(other) => bail!("unsupported URL scheme {other:?} in {url}; only http:// is"),
None => bail!("no scheme in {url}; expected http://host:port/path"),
}
let authority = uri
.authority()
.with_context(|| format!("no host in {url}"))?;
let host = authority
.host()
.trim_start_matches('[')
.trim_end_matches(']')
.to_string();
if authority.as_str().contains('@') {
bail!("HTTP URL userinfo is unsupported; use an explicit Authorization header");
}
if host.is_empty() {
bail!("HTTP URL requires a nonempty host");
}
let port = match authority.port() {
Some(port) => port
.as_str()
.parse::<u16>()
.context("invalid HTTP URL port")?,
None if authority.as_str().ends_with(':') => bail!("HTTP URL port is empty"),
None => 80,
};
let path_and_query = uri
.path_and_query()
.map(|p| p.as_str().to_string())
.filter(|p| !p.is_empty())
.unwrap_or_else(|| "/".to_string());
Ok(HttpTarget {
host,
port,
authority: authority.as_str().to_string(),
path_and_query,
})
}
pub async fn fetch(
method: &str,
url: &str,
headers: &[(String, String)],
body: Option<String>,
timeout: Duration,
max_body: usize,
) -> Result<HttpExchange> {
fetch_response(
method,
url,
headers,
body.map(Bytes::from),
timeout,
max_body,
)
.await
.map(HttpExchange::from_response)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Wire {
Http1,
Http2PriorKnowledge,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct SpawnExecutor;
impl<F> hyper::rt::Executor<F> for SpawnExecutor
where
F: std::future::Future + Send + 'static,
F::Output: Send + 'static,
{
fn execute(&self, future: F) {
drop(tokio::spawn(future));
}
}
pub async fn fetch_response(
method: &str,
url: &str,
headers: &[(String, String)],
body: Option<Bytes>,
timeout: Duration,
max_body: usize,
) -> Result<hyper::Response<Bytes>> {
fetch_response_on(Wire::Http1, method, url, headers, body, timeout, max_body).await
}
#[allow(clippy::too_many_arguments)]
pub async fn fetch_response_on(
wire: Wire,
method: &str,
url: &str,
headers: &[(String, String)],
body: Option<Bytes>,
timeout: Duration,
max_body: usize,
) -> Result<hyper::Response<Bytes>> {
let target = parse_http_url(url)?;
let exchange = async {
let stream = tokio::net::TcpStream::connect((target.host.clone(), target.port))
.await
.with_context(|| format!("connect to {}:{}", target.host, target.port))?;
match wire {
Wire::Http1 => {
exchange_response(stream, method, &target, headers, body, max_body).await
}
Wire::Http2PriorKnowledge => {
exchange_response_h2(stream, method, &target, headers, body, max_body).await
}
}
};
tokio::time::timeout(timeout, exchange)
.await
.map_err(|_| anyhow!("no complete response from {url} within {timeout:?}"))?
}
pub async fn exchange<S>(
io: S,
method: &str,
target: &HttpTarget,
headers: &[(String, String)],
body: Option<String>,
max_body: usize,
) -> Result<HttpExchange>
where
S: AsyncRead + AsyncWrite + Send + Unpin + 'static,
{
exchange_response(io, method, target, headers, body.map(Bytes::from), max_body)
.await
.map(HttpExchange::from_response)
}
pub async fn exchange_response<S>(
io: S,
method: &str,
target: &HttpTarget,
headers: &[(String, String)],
body: Option<Bytes>,
max_body: usize,
) -> Result<hyper::Response<Bytes>>
where
S: AsyncRead + AsyncWrite + Send + Unpin + 'static,
{
let method = parse_method(method)?;
let mut builder = hyper::Request::builder()
.method(method.as_str())
.uri(target.path_and_query.as_str())
.header(hyper::header::HOST, target.authority.as_str());
for (name, value) in headers {
let name = hyper::header::HeaderName::from_bytes(name.as_bytes())
.with_context(|| format!("invalid header name {name:?}"))?;
let value = hyper::header::HeaderValue::from_str(value)
.with_context(|| format!("invalid value for header {name}"))?;
let map = builder
.headers_mut()
.context("request builder rejected an earlier part")?;
if name == hyper::header::HOST {
map.insert(name, value);
} else {
map.append(name, value);
}
}
let request = builder
.body(Full::new(body.unwrap_or_default()))
.context("build HTTP request")?;
let (mut sender, connection) = hyper::client::conn::http1::handshake(TokioIo::new(io))
.await
.context("HTTP/1.1 handshake")?;
let _driver = ConnectionDriver(tokio::spawn(async move {
let _ = connection.await;
}));
let result = async {
let response = sender
.send_request(request)
.await
.context("send HTTP request")?;
let (parts, body) = response.into_parts();
let collected = Limited::new(body, max_body).collect().await.map_err(|e| {
if e.downcast_ref::<http_body_util::LengthLimitError>()
.is_some()
{
anyhow!("response body exceeds the {max_body}-byte limit; refused")
} else {
anyhow!("read HTTP response body: {e}")
}
})?;
Ok(hyper::Response::from_parts(parts, collected.to_bytes()))
}
.await;
result
}
pub async fn exchange_response_h2<S>(
io: S,
method: &str,
target: &HttpTarget,
headers: &[(String, String)],
body: Option<Bytes>,
max_body: usize,
) -> Result<hyper::Response<Bytes>>
where
S: AsyncRead + AsyncWrite + Send + Unpin + 'static,
{
let method = parse_method(method)?;
let uri = format!("http://{}{}", target.authority, target.path_and_query);
let mut builder = hyper::Request::builder()
.method(method.as_str())
.uri(uri.as_str())
.version(hyper::Version::HTTP_2);
for (name, value) in headers {
let name = hyper::header::HeaderName::from_bytes(name.as_bytes())
.with_context(|| format!("invalid header name {name:?}"))?;
let value = hyper::header::HeaderValue::from_str(value)
.with_context(|| format!("invalid value for header {name}"))?;
builder
.headers_mut()
.context("request builder rejected an earlier part")?
.append(name, value);
}
let request = builder
.body(Full::new(body.unwrap_or_default()))
.context("build HTTP/2 request")?;
let (mut sender, connection) =
hyper::client::conn::http2::handshake(SpawnExecutor, TokioIo::new(io))
.await
.context("HTTP/2 handshake (prior knowledge)")?;
let _driver = ConnectionDriver(tokio::spawn(async move {
let _ = connection.await;
}));
let result = async {
let response = sender
.send_request(request)
.await
.context("send HTTP/2 request")?;
let (parts, body) = response.into_parts();
let collected = Limited::new(body, max_body).collect().await.map_err(|e| {
if e.downcast_ref::<http_body_util::LengthLimitError>()
.is_some()
{
anyhow!("response body exceeds the {max_body}-byte limit; refused")
} else {
anyhow!("read HTTP/2 response body: {e}")
}
})?;
Ok(hyper::Response::from_parts(parts, collected.to_bytes()))
}
.await;
result
}