use crate::Error;
use bytes::Bytes;
use http::{
Method,
header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue},
};
use http_body_util::{BodyExt, Full};
use hyper_rustls::HttpsConnector;
use hyper_util::{
client::legacy::{Client, connect::HttpConnector},
rt::TokioExecutor,
};
use serde::{Serialize, de::DeserializeOwned};
use std::time::Duration;
pub use http::Method as HttpMethod;
pub use http::header::{HeaderMap as HttpHeaderMap, HeaderValue as HttpHeaderValue};
pub type HyperClient = Client<HttpsConnector<HttpConnector>, Full<Bytes>>;
#[derive(Debug, Clone)]
pub struct HttpClientBuilder {
timeout: Duration,
headers: HeaderMap,
}
#[derive(Clone)]
pub struct HttpClient {
headers: HeaderMap,
client: HyperClient,
timeout: Duration,
}
#[derive(Clone)]
pub struct HttpRequest {
method: Method,
url: String,
headers: HeaderMap,
body: Option<String>,
client: HyperClient,
timeout: Duration,
}
impl std::fmt::Debug for HttpClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HttpClient")
.field("headers", &self.headers)
.field("timeout", &self.timeout)
.finish_non_exhaustive()
}
}
impl std::fmt::Debug for HttpRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HttpRequest")
.field("method", &self.method)
.field("url", &self.url)
.field("headers", &self.headers)
.field("body", &self.body)
.finish_non_exhaustive()
}
}
impl Default for HttpClientBuilder {
fn default() -> Self {
Self::new()
}
}
impl HttpClientBuilder {
pub fn new() -> Self {
let mut headers = HeaderMap::new();
headers.append(CONTENT_TYPE, HeaderValue::from_static("application/json"));
Self {
timeout: Duration::from_secs(30),
headers,
}
}
pub fn with_header(mut self, name: &'static str, value: impl AsRef<str>) -> Self {
if let (Ok(val), Ok(n)) = (
HeaderValue::from_str(value.as_ref()),
name.parse::<HeaderName>(),
) {
self.headers.append(n, val);
}
self
}
pub fn set_header(mut self, name: &'static str, value: impl AsRef<str>) -> Self {
if let (Ok(val), Ok(n)) = (
HeaderValue::from_str(value.as_ref()),
name.parse::<HeaderName>(),
) {
self.headers.insert(n, val);
}
self
}
pub fn without_header(mut self, name: &'static str) -> Self {
if let Ok(n) = name.parse::<HeaderName>() {
self.headers.remove(n);
} else {
self.headers.remove(name);
}
self
}
pub fn with_timeout(mut self, timeout: Option<Duration>) -> Self {
if let Some(timeout) = timeout {
self.timeout = timeout;
}
self
}
pub fn build(self) -> HttpClient {
let connector = build_https_connector();
let client = Client::builder(TokioExecutor::new()).build(connector);
HttpClient {
headers: self.headers,
client,
timeout: self.timeout,
}
}
}
impl HttpClient {
pub fn request(&self, method: Method, url: impl Into<String>) -> HttpRequest {
HttpRequest {
method,
url: url.into(),
headers: self.headers.clone(),
body: None,
client: self.client.clone(),
timeout: self.timeout,
}
}
pub fn get(&self, url: impl Into<String>) -> HttpRequest {
self.request(Method::GET, url)
}
pub fn post(&self, url: impl Into<String>) -> HttpRequest {
self.request(Method::POST, url)
}
pub fn put(&self, url: impl Into<String>) -> HttpRequest {
self.request(Method::PUT, url)
}
pub fn delete(&self, url: impl Into<String>) -> HttpRequest {
self.request(Method::DELETE, url)
}
pub fn patch(&self, url: impl Into<String>) -> HttpRequest {
self.request(Method::PATCH, url)
}
}
impl HttpRequest {
pub fn with_header(mut self, name: &'static str, value: impl AsRef<str>) -> Self {
if let (Ok(val), Ok(n)) = (
HeaderValue::from_str(value.as_ref()),
name.parse::<HeaderName>(),
) {
self.headers.append(n, val);
}
self
}
pub fn set_header(mut self, name: &'static str, value: impl AsRef<str>) -> Self {
if let (Ok(val), Ok(n)) = (
HeaderValue::from_str(value.as_ref()),
name.parse::<HeaderName>(),
) {
self.headers.insert(n, val);
}
self
}
pub fn with_body<B: Serialize>(mut self, body: B) -> crate::Result<Self> {
match serde_json::to_string(&body) {
Ok(body) => {
self.body = Some(body);
Ok(self)
}
Err(err) => Err(Error::Serialize(format!(
"Failed to serialize request: {err}"
))),
}
}
pub fn with_raw_body(mut self, body: String) -> Self {
self.body = Some(body);
self
}
pub async fn send<T>(self) -> crate::Result<T>
where
T: DeserializeOwned,
{
let response = self.send_raw().await?;
serde_json::from_slice::<T>(response.as_bytes()).map_err(|err| {
Error::Serialize(format!(
"Failed to deserialize response: {err} (body: {})",
body_snippet(&response)
))
})
}
pub async fn send_raw(self) -> crate::Result<String> {
self.send_raw_with_headers().await.map(|(body, _)| body)
}
pub async fn send_raw_with_headers(self) -> crate::Result<(String, HeaderMap)> {
let url = self.url.clone();
let timeout = self.timeout;
let body_opt = self.body.clone();
let method = self.method.clone();
let headers = self.headers.clone();
let client = self.client.clone();
let body_bytes = body_opt.map(Bytes::from).unwrap_or_default();
let full = Full::new(body_bytes);
let mut builder = http::Request::builder().method(method).uri(url.as_str());
for (k, v) in headers.iter() {
builder = builder.header(k, v);
}
let req = builder
.body(full)
.map_err(|e| Error::Api(format!("Failed to build request to {url}: {e}")))?;
let resp = tokio::time::timeout(timeout, client.request(req))
.await
.map_err(|_| Error::Api(format!("Request to {url} timed out after {timeout:?}")))?
.map_err(|e| Error::Api(format!("Failed to send request to {url}: {e}")))?;
let status = resp.status();
let resp_headers = resp.headers().clone();
let collected = resp
.collect()
.await
.map_err(|e| Error::Api(format!("Failed to read response from {url}: {e}")))?;
let bytes = collected.to_bytes();
let body_str = String::from_utf8_lossy(&bytes).to_string();
let code = status.as_u16();
match code {
204 => Ok((String::new(), resp_headers)),
200..=299 => Ok((body_str, resp_headers)),
401 => Err(Error::Unauthorized),
404 => Err(Error::NotFound),
_ => Err(Error::Api(http_status_message(code, &body_str))),
}
}
pub async fn send_with_retry<T>(self, max_retries: u32) -> crate::Result<T>
where
T: DeserializeOwned,
{
let mut attempts: u32 = 0;
let Self {
method,
url,
headers,
body,
client,
timeout,
} = self;
loop {
let body_bytes = body.clone().map(Bytes::from).unwrap_or_default();
let full = Full::new(body_bytes);
let mut builder = http::Request::builder()
.method(method.clone())
.uri(url.as_str());
for (k, v) in headers.iter() {
builder = builder.header(k, v);
}
let req = builder
.body(full)
.map_err(|e| Error::Api(format!("Failed to build request to {url}: {e}")))?;
let resp = tokio::time::timeout(timeout, client.request(req))
.await
.map_err(|_| Error::Api(format!("Request to {url} timed out after {timeout:?}")))?
.map_err(|e| Error::Api(format!("Failed to send request to {url}: {e}")))?;
let status = resp.status();
let resp_headers = resp.headers().clone();
let collected = resp
.collect()
.await
.map_err(|e| Error::Api(format!("Failed to read response from {url}: {e}")))?;
let bytes = collected.to_bytes();
let text = String::from_utf8_lossy(&bytes).to_string();
let code = status.as_u16();
match code {
204 => {
return serde_json::from_str("{}").map_err(|err| {
Error::Serialize(format!("Failed to create empty response: {err}"))
});
}
200..=299 => {
let parse_target = if text.trim().is_empty() { "{}" } else { &text };
return serde_json::from_str(parse_target).map_err(|err| {
Error::Serialize(format!(
"Failed to deserialize response from {}: {err} (body: {})",
url,
body_snippet(&text)
))
});
}
429 | 503 if attempts < max_retries => {
let delay = retry_after(&resp_headers)
.unwrap_or_else(|| Duration::from_secs(1u64 << attempts.min(6)));
tokio::time::sleep(delay.min(MAX_RETRY_DELAY)).await;
attempts += 1;
continue;
}
401 => return Err(Error::Unauthorized),
404 => return Err(Error::NotFound),
_ => {
return Err(Error::Api(http_status_message(code, &text)));
}
}
}
}
}
pub(crate) fn build_https_connector() -> HttpsConnector<HttpConnector> {
install_crypto_provider();
#[cfg(feature = "rustls-platform-verifier")]
{
if let Ok(connector) = try_build_with_platform_verifier() {
return connector;
}
}
#[cfg(feature = "native-tokio")]
{
if let Ok(connector) = try_build_with_native_roots() {
return connector;
}
}
#[cfg(feature = "webpki-tokio")]
{
return build_with_webpki_roots();
}
#[cfg(not(any(
feature = "webpki-tokio",
feature = "native-tokio",
feature = "rustls-platform-verifier"
)))]
{
compile_error!(
"At least one verifier feature must be enabled: webpki-tokio, native-tokio, or rustls-platform-verifier"
);
}
#[allow(unreachable_code)]
{
panic!("Failed to build HTTPS connector: no verifier succeeded")
}
}
pub(crate) fn build_hyper_client() -> HyperClient {
let connector = build_https_connector();
Client::builder(TokioExecutor::new()).build(connector)
}
#[allow(dead_code)]
pub(crate) fn build_hyper_client_with_timeout(_timeout: Duration) -> HyperClient {
build_hyper_client()
}
fn install_crypto_provider() {
#[cfg(feature = "aws-lc-rs")]
{
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
}
#[cfg(all(feature = "ring", not(feature = "aws-lc-rs")))]
{
let _ = rustls::crypto::ring::default_provider().install_default();
}
#[cfg(not(any(feature = "aws-lc-rs", feature = "ring")))]
{
compile_error!("Either aws-lc-rs or ring feature must be enabled");
}
}
#[cfg(feature = "rustls-platform-verifier")]
fn try_build_with_platform_verifier() -> Result<HttpsConnector<HttpConnector>, rustls::Error> {
let builder = hyper_rustls::HttpsConnectorBuilder::new().try_with_platform_verifier()?;
#[cfg(feature = "http2")]
{
Ok(builder
.https_or_http()
.enable_http1()
.enable_http2()
.build())
}
#[cfg(not(feature = "http2"))]
{
Ok(builder.https_or_http().enable_http1().build())
}
}
#[cfg(feature = "native-tokio")]
fn try_build_with_native_roots() -> std::io::Result<HttpsConnector<HttpConnector>> {
let builder = hyper_rustls::HttpsConnectorBuilder::new().with_native_roots()?;
#[cfg(feature = "http2")]
{
Ok(builder
.https_or_http()
.enable_http1()
.enable_http2()
.build())
}
#[cfg(not(feature = "http2"))]
{
Ok(builder.https_or_http().enable_http1().build())
}
}
#[cfg(feature = "webpki-tokio")]
fn build_with_webpki_roots() -> HttpsConnector<HttpConnector> {
let builder = hyper_rustls::HttpsConnectorBuilder::new().with_webpki_roots();
#[cfg(feature = "http2")]
{
builder
.https_or_http()
.enable_http1()
.enable_http2()
.build()
}
#[cfg(not(feature = "http2"))]
{
builder.https_or_http().enable_http1().build()
}
}
const MAX_RETRY_DELAY: Duration = Duration::from_secs(60);
const MAX_BODY_SNIPPET: usize = 512;
fn body_snippet(body: &str) -> &str {
let trimmed = body.trim();
if trimmed.len() <= MAX_BODY_SNIPPET {
trimmed
} else {
&trimmed[..trimmed.ceil_char_boundary(MAX_BODY_SNIPPET)]
}
}
fn retry_after(headers: &HeaderMap) -> Option<Duration> {
headers
.get("retry-after")?
.to_str()
.ok()?
.parse::<u64>()
.ok()
.map(Duration::from_secs)
}
fn http_status_message(code: u16, body: &str) -> String {
let trimmed = body.trim();
if code == 400 {
if trimmed.is_empty() {
"BadRequest".to_string()
} else {
format!("BadRequest {trimmed}")
}
} else if trimmed.is_empty() {
format!("HTTP {code}")
} else {
format!("HTTP {code}: {trimmed}")
}
}