use std::{
collections::{BTreeMap, HashSet},
net::{IpAddr, SocketAddr},
};
use serde_with::TryFromInto;
use url::Url;
use crate::*;
use ::reqwest;
serde! {
#[derive(Default)]
pub struct Client {
#[serde_as(as = "Option<HeaderMap>")]
pub default_headers: Option<http::HeaderMap>,
pub cookie_store: Option<bool>,
pub gzip: Option<bool>,
pub brotli: Option<bool>,
pub zstd: Option<bool>,
pub deflate: Option<bool>,
pub redirect: Option<usize>,
pub referer: Option<bool>,
pub retry: Option<ClientRetry>,
pub proxies: Option<FalseOr<Vec<Proxy>>>,
#[serde_as(as = "Option<HumanTime>")]
pub timeout: Option<Duration>,
#[serde_as(as = "Option<HumanTime>")]
pub read_timeout: Option<Duration>,
#[serde_as(as = "Option<HumanTime>")]
pub connect_timeout: Option<Duration>,
pub connection_verbose: Option<bool>,
#[serde_as(as = "Option<FalseOrWith<HumanTime, Duration>>")]
pub pool_idle_timeout: Option<FalseOr<Duration>>,
pub pool_max_idle_per_host: Option<usize>,
pub http1_title_case_headers: Option<bool>,
pub http1_allow_obsolete_multiline_headers_in_responses: Option<bool>,
pub http1_ignore_invalid_headers_in_responses: Option<bool>,
pub http1_allow_spaces_after_header_name_in_responses: Option<bool>,
pub http1_only: Option<bool>,
pub http09_responses: Option<bool>,
pub http2_prior_knowledge: Option<bool>,
pub http2_initial_stream_window_size: Option<FalseOr<u32>>,
pub http2_initial_connection_window_size: Option<FalseOr<u32>>,
pub http2_adaptive_window: Option<bool>,
pub http2_max_frame_size: Option<FalseOr<u32>>,
pub http2_max_list_size: Option<u32>,
#[serde_as(as = "Option<FalseOrWith<HumanTime, Duration>>")]
pub http2_keep_alive_interval: Option<FalseOr<Duration>>,
#[serde_as(as = "Option<HumanTime>")]
pub http2_keep_alive_timeout: Option<Duration>,
pub http2_keep_alive_while_idle: Option<bool>,
pub tcp_nodelay: Option<bool>,
pub local_address: Option<FalseOr<IpAddr>>,
pub interface: Option<String>,
#[serde_as(as = "Option<FalseOrWith<HumanTime, Duration>>")]
pub tcp_keepalive: Option<FalseOr<Duration>>,
#[serde_as(as = "Option<FalseOrWith<HumanTime, Duration>>")]
pub tcp_keepalive_internal: Option<FalseOr<Duration>>,
pub tcp_keepalive_retries: Option<FalseOr<u32>>,
#[serde_as(as = "Option<FalseOrWith<HumanTime, Duration>>")]
pub tcp_user_timeout: Option<FalseOr<Duration>>,
pub unix_socket: Option<String>,
pub tls_certs: Option<TlsCerts>,
pub tls_crls: Option<TlsCrls>,
pub identity: Option<String>,
pub tls_danger_accept_invalid_hostnames: Option<bool>,
pub tls_danger_accept_invalid_certs: Option<bool>,
pub tls_sni: Option<bool>,
#[serde_as(as = "Option<TryFromInto<_Version>>")]
pub tls_version_min: Option<reqwest::tls::Version>,
#[serde_as(as = "Option<TryFromInto<_Version>>")]
pub tls_version_max: Option<reqwest::tls::Version>,
pub tls_info: Option<bool>,
pub https_only: Option<bool>,
pub hickory_dns: Option<bool>,
pub resolve: Option<BTreeMap<String, Vec<SocketAddr>>>,
pub tls_backend: Option<TlsBackend>
}
pub enum TlsCerts {
Merge(Vec<String>),
Only(Vec<String>),
}
pub enum TlsCrls {
Only(Vec<String>),
}
pub enum TlsBackend {
Native,
Rustls
}
enum _Version {
#[serde(rename = "1.0")]
V1_0,
#[serde(rename = "1.1")]
V1_1,
#[serde(rename = "1.2")]
V1_2,
#[serde(rename = "1.3")]
V1_3,
}
}
impl Client {
pub fn builder(self) -> Result<reqwest::ClientBuilder, BoxError> {
let Self {
default_headers,
cookie_store,
gzip,
brotli,
zstd,
deflate,
redirect,
referer,
retry,
proxies,
timeout,
read_timeout,
connect_timeout,
connection_verbose,
pool_idle_timeout,
pool_max_idle_per_host,
http1_title_case_headers,
http1_allow_obsolete_multiline_headers_in_responses,
http1_ignore_invalid_headers_in_responses,
http1_allow_spaces_after_header_name_in_responses,
http1_only,
http09_responses,
http2_prior_knowledge,
http2_initial_stream_window_size,
http2_initial_connection_window_size,
http2_adaptive_window,
http2_max_frame_size,
http2_max_list_size,
http2_keep_alive_interval,
http2_keep_alive_timeout,
http2_keep_alive_while_idle,
tcp_nodelay,
local_address,
interface,
tcp_keepalive,
tcp_keepalive_internal,
tcp_keepalive_retries,
tcp_user_timeout,
unix_socket,
tls_certs,
tls_crls,
identity,
tls_danger_accept_invalid_hostnames,
tls_danger_accept_invalid_certs,
tls_sni,
tls_version_min,
tls_version_max,
tls_info,
https_only,
hickory_dns,
resolve,
tls_backend,
} = self;
reqwest::ClientBuilder::new()
.tap_opt(default_headers, |b, v| b.default_headers(v))
.tap_opt(cookie_store, |b, v| b.cookie_store(v))
.tap_opt(gzip, |b, v| b.gzip(v))
.tap_opt(brotli, |b, v| b.brotli(v))
.try_tap_opt(zstd, |b, v| match v {
#[cfg(feature = "reqwest-zstd")]
true => Ok(b.zstd(true)),
#[cfg(not(feature = "reqwest-zstd"))]
true => Err(BoxError::from("enabling zstd requires building `konfig` with `reqwest-zstd`")),
false => Ok::<_, BoxError>(b.no_zstd()),
})?
.tap_opt(deflate, |b, v| b.deflate(v))
.tap_opt(redirect, |b, v| match v {
0 => b.redirect(reqwest::redirect::Policy::none()),
v => b.redirect(reqwest::redirect::Policy::limited(v)),
})
.tap_opt(referer, |b, v| b.referer(v))
.try_tap_opt(retry, |b, v| v.builder().map(|v| b.retry(v)))?
.try_tap_opt(proxies, |b, v| match v {
FalseOr::False => Ok(b.no_proxy()),
FalseOr::Or(proxs) => proxs
.into_iter()
.try_fold(b, |b, prox| prox.build().map(|v| b.proxy(v))),
})?
.tap_opt(timeout, |b, v| b.timeout(v))
.tap_opt(read_timeout, |b, v| b.read_timeout(v))
.tap_opt(connect_timeout, |b, v| b.connect_timeout(v))
.tap_opt(connection_verbose, |b, v| b.connection_verbose(v))
.tap_opt(pool_idle_timeout, |b, v| {
b.pool_idle_timeout(v.into_option())
})
.tap_opt(pool_max_idle_per_host, |b, v| b.pool_max_idle_per_host(v))
.tap_opt(http1_title_case_headers, |b, v| {
if v { b.http1_title_case_headers() } else { b }
})
.tap_opt(
http1_allow_obsolete_multiline_headers_in_responses,
|b, v| b.http1_allow_obsolete_multiline_headers_in_responses(v),
)
.tap_opt(http1_ignore_invalid_headers_in_responses, |b, v| {
b.http1_ignore_invalid_headers_in_responses(v)
})
.tap_opt(http1_allow_spaces_after_header_name_in_responses, |b, v| {
b.http1_allow_spaces_after_header_name_in_responses(v)
})
.tap_opt(http1_only, |b, v| if v { b.http1_only() } else { b })
.tap_opt(
http09_responses,
|b, v| {
if v { b.http09_responses() } else { b }
},
)
.tap_opt(tcp_nodelay, |b, v| b.tcp_nodelay(v))
.tap_opt(local_address, |b, v| b.local_address(v.into_option()))
.tap_opt(interface, |b, v| b.interface(&v))
.tap_opt(tcp_keepalive, |b, v| b.tcp_keepalive(v.into_option()))
.tap_opt(tcp_keepalive_internal, |b, v| {
b.tcp_keepalive_interval(v.into_option())
})
.tap_opt(tcp_keepalive_retries, |b, v| {
b.tcp_keepalive_retries(v.into_option())
})
.try_tap_opt::<_, BoxError>(tcp_user_timeout, |b, v| cfg_select! {
any(target_os = "android", target_os = "fuchsia", target_os = "linux") => Ok(b.tcp_user_timeout(v.into_option())),
_ => match v {
FalseOr::False => Ok(b),
FalseOr::Or(_) => Err(BoxError::from("cannot enable tcp_user_timeout on this platform"))
},
}
)?
.try_tap_opt::<_, BoxError>(unix_socket, |b, v| cfg_select! {
unix => Ok(b.unix_socket(v)),
_ => Err(BoxError::from("cannot enable unix_socket on this platform"))
})?
.try_tap_opt(tls_certs, |b, v| match v {
TlsCerts::Merge(v) => v
.into_iter()
.map(|s| reqwest::tls::Certificate::from_pem(s.as_bytes()))
.collect::<Result<Vec<_>, _>>()
.map(|v| b.tls_certs_merge(v)),
TlsCerts::Only(v) => v
.into_iter()
.map(|s| reqwest::tls::Certificate::from_pem(s.as_bytes()))
.collect::<Result<Vec<_>, _>>()
.map(|v| b.tls_certs_only(v)),
})?
.try_tap_opt(tls_crls, |b, v| match v {
TlsCrls::Only(v) => v
.into_iter()
.map(|s| reqwest::tls::CertificateRevocationList::from_pem(s.as_bytes()))
.collect::<Result<Vec<_>, _>>()
.map(|v| b.tls_crls_only(v)),
})?
.try_tap_opt(identity, |b, v| {
reqwest::tls::Identity::from_pem(v.as_bytes()).map(|v| b.identity(v))
})?
.tap_opt(tls_danger_accept_invalid_hostnames, |b, v| {
b.tls_danger_accept_invalid_hostnames(v)
})
.tap_opt(tls_danger_accept_invalid_certs, |b, v| {
b.tls_danger_accept_invalid_certs(v)
})
.tap_opt(tls_sni, |b, v| b.tls_sni(v))
.tap_opt(tls_version_min, |b, v| b.tls_version_min(v))
.tap_opt(tls_version_max, |b, v| b.tls_version_max(v))
.tap_opt(tls_info, |b, v| b.tls_info(v))
.tap_opt(https_only, |b, v| b.https_only(v))
.tap_opt(http2_prior_knowledge, |b, v| {
if v { b.http2_prior_knowledge() } else { b }
})
.tap_opt(http2_initial_stream_window_size, |b, v| {
b.http2_initial_stream_window_size(v.into_option())
})
.tap_opt(http2_initial_connection_window_size, |b, v| {
b.http2_initial_connection_window_size(v.into_option())
})
.tap_opt(http2_adaptive_window, |b, v| b.http2_adaptive_window(v))
.tap_opt(http2_max_frame_size, |b, v| {
b.http2_max_frame_size(v.into_option())
})
.tap_opt(http2_max_list_size, |b, v| b.http2_max_header_list_size(v))
.tap_opt(http2_keep_alive_interval, |b, v| {
b.http2_keep_alive_interval(v.into_option())
})
.tap_opt(http2_keep_alive_timeout, |b, v| {
b.http2_keep_alive_timeout(v)
})
.tap_opt(http2_keep_alive_while_idle, |b, v| {
b.http2_keep_alive_while_idle(v)
})
.tap_opt(hickory_dns, |b, v| b.hickory_dns(v))
.tap_opt(resolve, |b, v| {
v.into_iter()
.fold(b, |b, (domain, addrs)| b.resolve_to_addrs(&domain, &addrs))
})
.try_tap_opt(tls_backend, |b, v| match v {
#[cfg(feature = "reqwest-native-tls")]
TlsBackend::Native => Ok(b.tls_backend_native()),
#[cfg(not(feature = "reqwest-native-tls"))]
TlsBackend::Native => Err(BoxError::from(
"enabling the native backend requires building `konfig` with `reqwest-native-tls`",
)),
TlsBackend::Rustls => Ok::<_, BoxError>(b.tls_backend_rustls()),
})
}
}
impl From<_Version> for reqwest::tls::Version {
fn from(value: _Version) -> Self {
match value {
_Version::V1_0 => Self::TLS_1_0,
_Version::V1_1 => Self::TLS_1_1,
_Version::V1_2 => Self::TLS_1_2,
_Version::V1_3 => Self::TLS_1_3,
}
}
}
impl TryFrom<reqwest::tls::Version> for _Version {
type Error = BoxError;
fn try_from(value: reqwest::tls::Version) -> Result<Self, Self::Error> {
Ok(match value {
reqwest::tls::Version::TLS_1_0 => _Version::V1_0,
reqwest::tls::Version::TLS_1_1 => _Version::V1_1,
reqwest::tls::Version::TLS_1_2 => _Version::V1_2,
reqwest::tls::Version::TLS_1_3 => _Version::V1_3,
other => return Err(BoxError::from(format!("uncovered version {other:?}"))),
})
}
}
serde! {
pub struct ClientRetry {
pub host: RetryHost,
pub unlimited: Option<bool>,
#[serde_as(as = "Option<BoundedFloat<0, 1000>>")]
pub max_extra_load: Option<f32>,
pub max_retries_per_request: Option<u32>,
}
#[serde(untagged)]
pub enum RetryHost {
Host(String),
Regex { regex: regex::Regex },
#[serde(rename_all = "kebab-case")]
RegexSet { regex_set: regex::RegexSet },
Hosts(HashSet<String>),
}
}
impl ClientRetry {
pub fn builder(self) -> Result<reqwest::retry::Builder, BoxError> {
let Self {
host,
unlimited,
max_extra_load,
max_retries_per_request,
} = self;
let b = match host {
RetryHost::Host(s) => reqwest::retry::for_host(s),
RetryHost::Regex { regex } => {
struct RegexIsMatch(::regex::Regex);
impl PartialEq<&str> for RegexIsMatch {
fn eq(&self, other: &&str) -> bool {
self.0.is_match(other)
}
}
reqwest::retry::for_host(RegexIsMatch(regex.builder().build()?))
}
RetryHost::RegexSet { regex_set } => {
struct RegexSetIsMatch(::regex::RegexSet);
impl PartialEq<&str> for RegexSetIsMatch {
fn eq(&self, other: &&str) -> bool {
self.0.is_match(other)
}
}
reqwest::retry::for_host(RegexSetIsMatch(regex_set.builder().build()?))
}
RetryHost::Hosts(items) => {
struct SetStringIsMatch(HashSet<String>);
impl PartialEq<&str> for SetStringIsMatch {
fn eq(&self, other: &&str) -> bool {
self.0.contains(*other)
}
}
reqwest::retry::for_host(SetStringIsMatch(items))
}
};
Ok(b.tap_opt(max_extra_load, |b, v| b.max_extra_load(v))
.tap_opt(max_retries_per_request, |b, v| b.max_retries_per_request(v))
.tap_opt(unlimited, |b, v| match v {
true => b.no_budget(),
false => b,
}))
}
}
serde! {
pub struct Proxy {
pub url: Url,
pub protocol: ProxyProtocol,
#[serde_as(as = "Option<HeaderMap>")]
pub headers: Option<http::HeaderMap>,
pub no_proxy: Option<NoProxy>,
}
pub enum ProxyProtocol {
Http,
Https,
Either,
}
#[serde(from = "Untagged<String, FromEnv>", into = "Untagged<String, FromEnv>")]
#[schemars(with = "Untagged<String, FromEnv>")]
pub enum NoProxy {
String(String),
FromEnv,
}
struct FromEnv { from_env: True }
}
convert_enum! {
NoProxy = Untagged<String, FromEnv> {
[NoProxy::String(s)] = [Untagged::Left(s)]
[NoProxy::FromEnv] = [Untagged::Right(FromEnv { from_env: True })]
}
}
impl Proxy {
pub fn build(self) -> reqwest::Result<reqwest::Proxy> {
let Self {
url,
protocol,
headers,
no_proxy,
} = self;
Ok(match protocol {
ProxyProtocol::Http => reqwest::Proxy::http(url),
ProxyProtocol::Https => reqwest::Proxy::https(url),
ProxyProtocol::Either => reqwest::Proxy::all(url),
}?
.tap_opt(headers, |b, v| b.headers(v))
.tap_opt(no_proxy, |b, v| match v {
NoProxy::FromEnv => b.no_proxy(reqwest::NoProxy::from_env()),
NoProxy::String(s) => b.no_proxy(reqwest::NoProxy::from_string(&s)),
}))
}
}
#[test]
fn reqwest_client_no_proxy() {
assert_round_trip::<NoProxy>(
json!("hello"),
expect![[r#"
String(
"hello",
)
"#]],
);
assert_round_trip::<NoProxy>(
json!({"from-env": true}),
expect![[r#"
FromEnv
"#]],
);
}