pub mod transport;
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use bytes::Bytes;
use hyper::header::{HeaderMap, CONTENT_TYPE};
use hyper::{Method, StatusCode};
use serde::de::DeserializeOwned;
use serde::Serialize;
#[derive(Clone)]
pub struct FetchClient {
backend: Backend,
}
#[derive(Clone)]
enum Backend {
#[cfg(not(target_arch = "wasm32"))]
Reqwest(reqwest::Client, usize),
Transport {
timeout: Duration,
max_body: usize,
user_agent: Option<String>,
wire: transport::Wire,
},
}
impl FetchClient {
#[cfg(not(target_arch = "wasm32"))]
pub fn from_reqwest(client: reqwest::Client) -> Self {
Self {
backend: Backend::Reqwest(client, transport::MAX_RESPONSE_BODY_BYTES),
}
}
pub fn transport(timeout: Duration) -> Self {
Self {
backend: Backend::Transport {
timeout,
max_body: transport::MAX_RESPONSE_BODY_BYTES,
user_agent: None,
wire: transport::Wire::Http1,
},
}
}
pub fn with_max_body(mut self, max_body: usize) -> Self {
match &mut self.backend {
#[cfg(not(target_arch = "wasm32"))]
Backend::Reqwest(_, bound) => *bound = max_body,
Backend::Transport {
max_body: bound, ..
} => *bound = max_body,
}
self
}
#[allow(irrefutable_let_patterns)]
pub fn with_user_agent(mut self, user_agent: &str) -> Self {
if let Backend::Transport { user_agent: ua, .. } = &mut self.backend {
*ua = Some(user_agent.to_string());
}
self
}
#[allow(irrefutable_let_patterns)]
pub fn http2_prior_knowledge(mut self) -> Self {
if let Backend::Transport { wire, .. } = &mut self.backend {
*wire = transport::Wire::Http2PriorKnowledge;
}
self
}
pub fn request(&self, method: Method, url: &str) -> FetchRequest {
match &self.backend {
#[cfg(not(target_arch = "wasm32"))]
Backend::Reqwest(client, max_body) => FetchRequest {
inner: RequestInner::Reqwest(client.request(method, url), *max_body),
},
Backend::Transport {
timeout,
max_body,
user_agent,
wire,
} => FetchRequest {
inner: RequestInner::Transport(TransportRequest {
method,
url: url.to_string(),
headers: Vec::new(),
user_agent: user_agent.clone(),
wire: *wire,
body: None,
timeout: *timeout,
max_body: *max_body,
error: None,
}),
},
}
}
pub fn get(&self, url: &str) -> FetchRequest {
self.request(Method::GET, url)
}
pub fn post(&self, url: &str) -> FetchRequest {
self.request(Method::POST, url)
}
pub fn put(&self, url: &str) -> FetchRequest {
self.request(Method::PUT, url)
}
pub fn delete(&self, url: &str) -> FetchRequest {
self.request(Method::DELETE, url)
}
pub fn head(&self, url: &str) -> FetchRequest {
self.request(Method::HEAD, url)
}
pub fn patch(&self, url: &str) -> FetchRequest {
self.request(Method::PATCH, url)
}
}
pub fn check_url(url: &str) -> Result<()> {
#[cfg(target_arch = "wasm32")]
transport::parse_http_url(url)?;
#[cfg(not(target_arch = "wasm32"))]
let _ = url;
Ok(())
}
pub fn origin_of(url: &str) -> Result<String> {
let parsed = url::Url::parse(url).with_context(|| format!("{url:?} is not a URL"))?;
anyhow::ensure!(
matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(),
"{url:?} is not an http(s) URL with a host"
);
Ok(parsed.origin().ascii_serialization())
}
pub fn resolve_same_origin(base_url: &str, path: &str) -> Result<String> {
let base = if base_url.contains("://") {
base_url.to_string()
} else {
format!("http://{base_url}")
};
let bound = origin_of(&base)?;
let base_parsed = url::Url::parse(&base).with_context(|| format!("{base:?} is not a URL"))?;
let has_scheme = |scheme: &str| {
path.as_bytes()
.get(..scheme.len())
.is_some_and(|head| head.eq_ignore_ascii_case(scheme.as_bytes()))
};
let is_absolute = has_scheme("http://") || has_scheme("https://");
let candidate = if is_absolute {
path.to_string()
} else if path.is_empty() || path.starts_with(['/', '?', '#']) || base.ends_with('/') {
format!("{base}{path}")
} else {
format!("{base}/{path}")
};
let resolved = url::Url::parse(&candidate)
.with_context(|| format!("path {path:?} does not form a URL"))?;
let asked = origin_of(resolved.as_str())?;
anyhow::ensure!(
bound == asked,
"path {path:?} is on {asked}, but this client is bound to {bound}; use a path \
relative to that origin, or open a client for {asked}"
);
anyhow::ensure!(
resolved.username() == base_parsed.username()
&& resolved.password() == base_parsed.password(),
"path {path:?} carries credentials in the URL, which this client bound to {bound} \
refuses; set an Authorization header instead"
);
Ok(resolved.into())
}
#[cfg(not(target_arch = "wasm32"))]
pub fn same_origin_redirects() -> reqwest::redirect::Policy {
reqwest::redirect::Policy::custom(|attempt| {
let first = attempt
.previous()
.first()
.map(|u| u.origin().ascii_serialization());
let next = attempt.url().origin().ascii_serialization();
if attempt.previous().len() >= 5 || first.as_deref() != Some(next.as_str()) {
attempt.stop()
} else {
attempt.follow()
}
})
}
pub struct FetchRequest {
inner: RequestInner,
}
enum RequestInner {
#[cfg(not(target_arch = "wasm32"))]
Reqwest(reqwest::RequestBuilder, usize),
Transport(TransportRequest),
}
struct TransportRequest {
method: Method,
url: String,
headers: Vec<(String, String)>,
body: Option<Bytes>,
user_agent: Option<String>,
wire: transport::Wire,
timeout: Duration,
max_body: usize,
error: Option<anyhow::Error>,
}
impl TransportRequest {
fn has_header(&self, name: &str) -> bool {
self.headers
.iter()
.any(|(n, _)| n.eq_ignore_ascii_case(name))
}
fn fail(&mut self, error: anyhow::Error) {
if self.error.is_none() {
self.error = Some(error);
}
}
}
impl FetchRequest {
pub fn header(self, name: impl AsRef<str>, value: impl AsRef<str>) -> Self {
match self.inner {
#[cfg(not(target_arch = "wasm32"))]
RequestInner::Reqwest(builder, max_body) => Self {
inner: RequestInner::Reqwest(
builder.header(name.as_ref(), value.as_ref()),
max_body,
),
},
RequestInner::Transport(mut req) => {
req.headers
.push((name.as_ref().to_string(), value.as_ref().to_string()));
Self {
inner: RequestInner::Transport(req),
}
}
}
}
pub fn json<T: Serialize + ?Sized>(self, value: &T) -> Self {
match self.inner {
#[cfg(not(target_arch = "wasm32"))]
RequestInner::Reqwest(builder, max_body) => Self {
inner: RequestInner::Reqwest(builder.json(value), max_body),
},
RequestInner::Transport(mut req) => {
match serde_json::to_vec(value) {
Ok(body) => {
if !req.has_header(CONTENT_TYPE.as_str()) {
req.headers.push((
CONTENT_TYPE.as_str().to_string(),
"application/json".to_string(),
));
}
req.body = Some(Bytes::from(body));
}
Err(e) => req.fail(anyhow!("serialize the JSON request body: {e}")),
}
Self {
inner: RequestInner::Transport(req),
}
}
}
}
pub fn query<T: Serialize + ?Sized>(self, query: &T) -> Self {
match self.inner {
#[cfg(not(target_arch = "wasm32"))]
RequestInner::Reqwest(builder, max_body) => Self {
inner: RequestInner::Reqwest(builder.query(query), max_body),
},
RequestInner::Transport(mut req) => {
match serde_urlencoded::to_string(query) {
Ok(encoded) if encoded.is_empty() => {}
Ok(encoded) => {
let separator = if req.url.contains('?') { '&' } else { '?' };
req.url = format!("{}{separator}{encoded}", req.url);
}
Err(e) => req.fail(anyhow!("serialize the query string: {e}")),
}
Self {
inner: RequestInner::Transport(req),
}
}
}
}
pub fn form<T: Serialize + ?Sized>(self, value: &T) -> Self {
match self.inner {
#[cfg(not(target_arch = "wasm32"))]
RequestInner::Reqwest(builder, max_body) => Self {
inner: RequestInner::Reqwest(builder.form(value), max_body),
},
RequestInner::Transport(mut req) => {
match serde_urlencoded::to_string(value) {
Ok(body) => {
if !req.has_header(CONTENT_TYPE.as_str()) {
req.headers.push((
CONTENT_TYPE.as_str().to_string(),
"application/x-www-form-urlencoded".to_string(),
));
}
req.body = Some(Bytes::from(body));
}
Err(e) => req.fail(anyhow!("serialize the form request body: {e}")),
}
Self {
inner: RequestInner::Transport(req),
}
}
}
}
pub fn body(self, body: impl Into<Bytes>) -> Self {
let body = body.into();
match self.inner {
#[cfg(not(target_arch = "wasm32"))]
RequestInner::Reqwest(builder, max_body) => Self {
inner: RequestInner::Reqwest(builder.body(body), max_body),
},
RequestInner::Transport(mut req) => {
req.body = Some(body);
Self {
inner: RequestInner::Transport(req),
}
}
}
}
pub fn basic_auth(
self,
user: impl std::fmt::Display,
password: Option<impl std::fmt::Display>,
) -> Self {
match self.inner {
#[cfg(not(target_arch = "wasm32"))]
RequestInner::Reqwest(builder, max_body) => Self {
inner: RequestInner::Reqwest(builder.basic_auth(user, password), max_body),
},
RequestInner::Transport(mut req) => {
use base64::Engine;
let credentials = match password {
Some(password) => format!("{user}:{password}"),
None => format!("{user}:"),
};
let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
req.headers.push((
hyper::header::AUTHORIZATION.as_str().to_string(),
format!("Basic {encoded}"),
));
Self {
inner: RequestInner::Transport(req),
}
}
}
}
pub fn timeout(self, timeout: Duration) -> Self {
match self.inner {
#[cfg(not(target_arch = "wasm32"))]
RequestInner::Reqwest(builder, max_body) => Self {
inner: RequestInner::Reqwest(builder.timeout(timeout), max_body),
},
RequestInner::Transport(mut req) => {
req.timeout = timeout;
Self {
inner: RequestInner::Transport(req),
}
}
}
}
pub async fn send(self) -> Result<FetchResponse> {
match self.inner {
#[cfg(not(target_arch = "wasm32"))]
RequestInner::Reqwest(builder, max_body) => Ok(FetchResponse {
inner: ResponseInner::Reqwest(builder.send().await?, max_body),
}),
RequestInner::Transport(mut req) => {
if let Some(error) = req.error.take() {
return Err(error);
}
if let Some(user_agent) = req.user_agent.take() {
if !req.has_header(hyper::header::USER_AGENT.as_str()) {
req.headers
.push((hyper::header::USER_AGENT.as_str().to_string(), user_agent));
}
}
let response = transport::fetch_response_on(
req.wire,
req.method.as_str(),
&req.url,
&req.headers,
req.body,
req.timeout,
req.max_body,
)
.await?;
let (parts, body) = response.into_parts();
Ok(FetchResponse {
inner: ResponseInner::Transport {
status: parts.status,
version: parts.version,
headers: parts.headers,
body: Some(body),
},
})
}
}
}
}
pub struct FetchResponse {
inner: ResponseInner,
}
enum ResponseInner {
#[cfg(not(target_arch = "wasm32"))]
Reqwest(reqwest::Response, usize),
Transport {
status: StatusCode,
version: hyper::Version,
headers: HeaderMap,
body: Option<Bytes>,
},
}
impl FetchResponse {
pub fn status(&self) -> StatusCode {
match &self.inner {
#[cfg(not(target_arch = "wasm32"))]
ResponseInner::Reqwest(response, _) => response.status(),
ResponseInner::Transport { status, .. } => *status,
}
}
pub fn version(&self) -> hyper::Version {
match &self.inner {
#[cfg(not(target_arch = "wasm32"))]
ResponseInner::Reqwest(response, _) => response.version(),
ResponseInner::Transport { version, .. } => *version,
}
}
pub fn headers(&self) -> &HeaderMap {
match &self.inner {
#[cfg(not(target_arch = "wasm32"))]
ResponseInner::Reqwest(response, _) => response.headers(),
ResponseInner::Transport { headers, .. } => headers,
}
}
pub fn content_length(&self) -> Option<u64> {
match &self.inner {
#[cfg(not(target_arch = "wasm32"))]
ResponseInner::Reqwest(response, _) => response.content_length(),
ResponseInner::Transport { body, headers, .. } => {
body.as_ref().map(|b| b.len() as u64).or_else(|| {
headers
.get(hyper::header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok())
})
}
}
}
pub async fn chunk(&mut self) -> Result<Option<Bytes>> {
match &mut self.inner {
#[cfg(not(target_arch = "wasm32"))]
ResponseInner::Reqwest(response, _) => Ok(response.chunk().await?),
ResponseInner::Transport { body, .. } => Ok(body.take().filter(|b| !b.is_empty())),
}
}
pub async fn bytes(self) -> Result<Bytes> {
match self.inner {
#[cfg(not(target_arch = "wasm32"))]
ResponseInner::Reqwest(response, limit) => read_response_bytes(response, limit).await,
ResponseInner::Transport { body, .. } => Ok(body.unwrap_or_default()),
}
}
pub async fn text(self) -> Result<String> {
match self.inner {
#[cfg(not(target_arch = "wasm32"))]
ResponseInner::Reqwest(response, limit) => read_response_text(response, limit).await,
ResponseInner::Transport { body, .. } => {
Ok(String::from_utf8_lossy(&body.unwrap_or_default()).into_owned())
}
}
}
pub async fn json<T: DeserializeOwned>(self) -> Result<T> {
match self.inner {
#[cfg(not(target_arch = "wasm32"))]
ResponseInner::Reqwest(response, limit) => read_response_json(response, limit).await,
ResponseInner::Transport { body, .. } => {
serde_json::from_slice(&body.unwrap_or_default())
.context("decode the response body as JSON")
}
}
}
}
#[derive(Debug)]
pub struct FetchError(pub anyhow::Error);
impl std::fmt::Display for FetchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:#}", self.0)
}
}
impl std::error::Error for FetchError {}
pub async fn round_trip_parts(
client: &FetchClient,
method: &str,
url: &str,
headers: Vec<(String, Vec<u8>)>,
body: Vec<u8>,
) -> std::result::Result<(u16, Vec<(String, Vec<u8>)>, Bytes), FetchError> {
let method: Method = method
.parse()
.map_err(|e| FetchError(anyhow!("invalid HTTP method {method:?}: {e}")))?;
let mut request = client.request(method, url);
for (name, value) in &headers {
request = request.header(name, String::from_utf8_lossy(value));
}
if !body.is_empty() {
request = request.body(body);
}
let response = request.send().await.map_err(FetchError)?;
let status = response.status().as_u16();
let headers = response
.headers()
.iter()
.map(|(name, value)| (name.as_str().to_string(), value.as_bytes().to_vec()))
.collect();
let body = response.bytes().await.map_err(FetchError)?;
Ok((status, headers, body))
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn read_response_bytes(mut response: reqwest::Response, limit: usize) -> Result<Bytes> {
if response
.content_length()
.is_some_and(|length| length > limit as u64)
{
anyhow::bail!("HTTP response body exceeds the {limit}-byte cap");
}
tokio::time::timeout(transport::REQUEST_TIMEOUT, async move {
let mut body = bytes::BytesMut::new();
while let Some(chunk) = response.chunk().await? {
if chunk.len() > limit.saturating_sub(body.len()) {
anyhow::bail!("HTTP response body exceeds the {limit}-byte cap");
}
body.extend_from_slice(&chunk);
}
Ok(body.freeze())
})
.await
.context("HTTP response body deadline exceeded")?
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn read_response_text(response: reqwest::Response, limit: usize) -> Result<String> {
let headers = response.headers().clone();
let status = response.status();
let body = read_response_bytes(response, limit).await?;
let mut buffered = hyper::Response::new(body);
*buffered.headers_mut() = headers;
*buffered.status_mut() = status;
Ok(reqwest::Response::from(buffered).text().await?)
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn read_response_json<T: DeserializeOwned>(
response: reqwest::Response,
limit: usize,
) -> Result<T> {
serde_json::from_slice(&read_response_bytes(response, limit).await?)
.context("decode the response body as JSON")
}