use std::borrow::Cow;
use std::fmt::Debug;
use std::time::Duration;
use super::http_targets::HomeserverFeatures;
use crate::{cross_log, errors::BuildError};
const DEFAULT_USER_AGENT: &str = concat!("pubky.org", "@", env!("CARGO_PKG_VERSION"),);
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone, Default)]
struct NativeHttpConfig {
request_timeout: Option<Duration>,
read_timeout: Option<Duration>,
pool_max_idle_per_host: Option<usize>,
}
#[derive(Debug, Clone)]
#[must_use]
#[derive(Default)]
pub struct PubkyHttpClientBuilder {
pkarr: pkarr::ClientBuilder,
user_agent_extra: Option<String>,
#[cfg(not(target_arch = "wasm32"))]
native_http: NativeHttpConfig,
#[cfg(target_arch = "wasm32")]
testnet_host: Option<String>,
}
impl PubkyHttpClientBuilder {
#[cfg(test)]
pub(crate) fn isolated_pkarr_test(&mut self) -> &mut Self {
self.pkarr
.no_default_network()
.bootstrap(&["127.0.0.1:1"])
.dht_report_policy(pkarr::dht::ReportPolicy::testnet());
self
}
pub fn testnet(&mut self) -> &mut Self {
self.testnet_with_host("localhost")
}
#[cfg_attr(
target_arch = "wasm32",
allow(
clippy::semicolon_outside_block,
clippy::unnecessary_operation,
clippy::semicolon_if_nothing_returned,
reason = "WASM-only block preserves conditional assignment formatting"
)
)]
pub fn testnet_with_host(&mut self, host: &str) -> &mut Self {
cross_log!(info, "Configuring testnet builders for host {host}");
#[cfg(not(target_arch = "wasm32"))]
self.pkarr
.bootstrap(&[format!(
"{}:{}",
host,
pubky_common::constants::testnet_ports::BOOTSTRAP
)])
.dht_report_policy(pkarr::dht::ReportPolicy::testnet());
self.pkarr
.relays(&[format!(
"http://{}:{}",
host,
pubky_common::constants::testnet_ports::PKARR_RELAY
)])
.expect("relays urls infallible");
#[cfg(target_arch = "wasm32")]
{
self.testnet_host = Some(host.to_string());
}
self
}
pub fn pkarr<F>(&mut self, f: F) -> &mut Self
where
F: FnOnce(&mut pkarr::ClientBuilder) -> &mut pkarr::ClientBuilder,
{
f(&mut self.pkarr);
self
}
pub fn user_agent_extra<S: Into<String>>(&mut self, extra: S) -> &mut Self {
self.user_agent_extra = Some(extra.into());
self
}
pub fn build(&self) -> Result<PubkyHttpClient, BuildError> {
let pkarr = self.pkarr.build()?;
let user_agent = self
.user_agent_extra
.as_deref()
.map(str::trim)
.filter(|extra| !extra.is_empty())
.map_or_else(
|| Cow::Borrowed(DEFAULT_USER_AGENT),
|extra| Cow::Owned(format!("{DEFAULT_USER_AGENT} {extra}")),
);
#[cfg(not(target_arch = "wasm32"))]
cross_log!(
info,
"Building PubkyHttpClient (request_timeout: {:?}, read_timeout: {:?}, user_agent: {}, pool_max_idle_per_host: {:?})",
self.native_http.request_timeout,
self.native_http.read_timeout,
user_agent,
self.native_http.pool_max_idle_per_host
);
#[cfg(target_arch = "wasm32")]
cross_log!(
info,
"Building PubkyHttpClient (user_agent: {})",
user_agent
);
#[cfg(not(target_arch = "wasm32"))]
let mut http_builder =
reqwest::ClientBuilder::from(pkarr.clone()).user_agent(user_agent.as_ref());
#[cfg(target_arch = "wasm32")]
let http_builder = reqwest::Client::builder().user_agent(user_agent.as_ref());
#[cfg(not(target_arch = "wasm32"))]
let mut icann_http_builder = reqwest::Client::builder()
.user_agent(user_agent.as_ref())
.tls_backend_preconfigured(icann_tls_config_without_revocation_check());
#[cfg(not(target_arch = "wasm32"))]
if let Some(timeout) = self.native_http.request_timeout {
http_builder = http_builder.timeout(timeout);
icann_http_builder = icann_http_builder.timeout(timeout);
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(timeout) = self.native_http.read_timeout {
http_builder = http_builder.read_timeout(timeout);
icann_http_builder = icann_http_builder.read_timeout(timeout);
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(max) = self.native_http.pool_max_idle_per_host {
http_builder = http_builder.pool_max_idle_per_host(max);
icann_http_builder = icann_http_builder.pool_max_idle_per_host(max);
}
#[cfg(not(target_arch = "wasm32"))]
let features = HomeserverFeatures::new(self.native_http.request_timeout);
#[cfg(target_arch = "wasm32")]
let features = HomeserverFeatures::default();
Ok(PubkyHttpClient {
pkarr,
http: http_builder.build()?,
features,
#[cfg(not(target_arch = "wasm32"))]
icann_http: icann_http_builder.build()?,
#[cfg(not(target_arch = "wasm32"))]
transport: super::http_targets::native::TransportResolver::new(),
#[cfg(target_arch = "wasm32")]
testnet_host: self.testnet_host.clone(),
})
}
}
#[cfg(not(target_arch = "wasm32"))]
impl PubkyHttpClientBuilder {
pub fn request_timeout(&mut self, timeout: Duration) -> &mut Self {
self.native_http.request_timeout = Some(timeout);
self
}
pub fn read_timeout(&mut self, timeout: Duration) -> &mut Self {
self.native_http.read_timeout = Some(timeout);
self
}
pub fn pool_max_idle_per_host(&mut self, max: usize) -> &mut Self {
self.native_http.pool_max_idle_per_host = Some(max);
self
}
}
#[cfg(target_arch = "wasm32")]
impl PubkyHttpClientBuilder {
#[deprecated(
note = "HTTP request timeout is not supported on WASM and has never had any effect; use `.pkarr(|p| p.request_timeout(..))` for pkarr/DHT timeouts"
)]
pub fn request_timeout(&mut self, _timeout: Duration) -> &mut Self {
self
}
#[deprecated(note = "HTTP read timeout is not supported on WASM")]
pub fn read_timeout(&mut self, _timeout: Duration) -> &mut Self {
self
}
}
#[cfg(not(target_arch = "wasm32"))]
fn icann_tls_config_without_revocation_check() -> rustls::ClientConfig {
let mut root_store = rustls::RootCertStore::empty();
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let mut tls_config = rustls::ClientConfig::builder_with_provider(std::sync::Arc::new(
rustls::crypto::aws_lc_rs::default_provider(),
))
.with_safe_default_protocol_versions()
.expect("aws-lc-rs provides safe default protocol versions")
.with_root_certificates(root_store)
.with_no_client_auth();
tls_config.alpn_protocols = vec![b"http/1.1".to_vec()];
tls_config
}
#[derive(Clone, Debug)]
pub struct PubkyHttpClient {
pub(crate) http: reqwest::Client,
pub(crate) pkarr: pkarr::Client,
pub(crate) features: HomeserverFeatures,
#[cfg(not(target_arch = "wasm32"))]
pub(crate) icann_http: reqwest::Client,
#[cfg(not(target_arch = "wasm32"))]
pub(crate) transport: super::http_targets::native::TransportResolver,
#[cfg(target_arch = "wasm32")]
pub(crate) testnet_host: Option<String>,
}
#[doc(hidden)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreparedFetch {
#[doc(hidden)]
pub pubky_host_header: Option<String>,
#[doc(hidden)]
pub is_pubky_target: bool,
}
impl PubkyHttpClient {
pub fn new() -> Result<Self, BuildError> {
cross_log!(
info,
"Constructing PubkyHttpClient with default configuration"
);
Self::builder().build()
}
pub fn builder() -> PubkyHttpClientBuilder {
PubkyHttpClientBuilder::default()
}
pub fn testnet() -> Result<Self, BuildError> {
cross_log!(
info,
"Constructing PubkyHttpClient configured for local testnet"
);
let mut builder = Self::builder();
builder.testnet();
builder.build()
}
#[must_use]
pub const fn pkarr(&self) -> &pkarr::Client {
&self.pkarr
}
}
#[cfg(test)]
mod test {
use httpmock::MockServer;
use reqwest::{Method, StatusCode};
#[cfg(not(target_arch = "wasm32"))]
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
};
use super::*;
#[cfg(not(target_arch = "wasm32"))]
async fn stalling_http_server() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
let mut buffer = [0; 1024];
loop {
let read = stream.read(&mut buffer).await.unwrap();
if read == 0 {
break;
}
request.extend_from_slice(&buffer[..read]);
if request.ends_with(b"\r\n\r\n") {
break;
}
}
stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1\r\nConnection: close\r\n\r\n")
.await
.unwrap();
tokio::time::sleep(Duration::from_secs(1)).await;
});
format!("http://{address}")
}
#[tokio::test]
async fn test_fetch() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method("GET").path("/health");
then.status(200).body("ok");
});
let client = PubkyHttpClient::new().unwrap();
let response = client
.request(Method::GET, &server.url("/health"))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
mock.assert();
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn native_timeouts_are_opt_in() {
let config = NativeHttpConfig::default();
assert_eq!(config.request_timeout, None);
assert_eq!(config.read_timeout, None);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn native_timeout_setters_store_values() {
let request_timeout = Duration::from_secs(30);
let read_timeout = Duration::from_secs(10);
let mut builder = PubkyHttpClient::builder();
builder
.request_timeout(request_timeout)
.read_timeout(read_timeout);
assert_eq!(builder.native_http.request_timeout, Some(request_timeout));
assert_eq!(builder.native_http.read_timeout, Some(read_timeout));
}
#[cfg(not(target_arch = "wasm32"))]
#[tokio::test]
async fn read_timeout_applies_to_all_native_http_clients() {
let client = PubkyHttpClient::builder()
.read_timeout(Duration::from_millis(50))
.build()
.unwrap();
for http in [&client.http, &client.icann_http] {
let response = http.get(stalling_http_server().await).send().await.unwrap();
let error = tokio::time::timeout(Duration::from_secs(1), response.bytes())
.await
.expect("read should finish with reqwest's timeout")
.expect_err("stalled response body should time out");
assert!(error.is_timeout());
}
}
}