use super::ApiResult;
use crate::util::Label;
use async_trait::async_trait;
use axum::http::HeaderMap;
use chrono::Utc;
use color_eyre::eyre::eyre;
use core::fmt;
use reqwest::header::USER_AGENT;
use serde::{Deserialize, Serialize};
use tower::{service_fn, ServiceExt};
use tracing::{debug, warn};
pub mod policy;
#[cfg(feature = "std")]
const ACORN_USER_AGENT: &str = concat!("ACORN/", env!("CARGO_PKG_VERSION"), " (https://acorn.ornl.gov; mailto:research@ornl.gov)");
#[cfg(not(feature = "std"))]
const ACORN_USER_AGENT: &str = "ACORN (https://acorn.ornl.gov; mailto:research@ornl.gov)";
#[async_trait]
pub trait HttpService {
async fn execute(&self, request: HttpRequest) -> ApiResult<HttpResponse>;
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum HttpMethod {
#[default]
Get,
Delete,
Patch,
Post,
Put,
}
#[derive(Clone, Debug)]
pub struct HttpRequest {
pub headers: HeaderMap,
pub json_body: Option<serde_json::Value>,
pub method: HttpMethod,
pub url: String,
}
#[derive(Clone, Debug)]
pub struct HttpRequestBuilder {
request: HttpRequest,
service: ReqwestHttpService,
}
#[derive(Clone, Debug)]
pub struct HttpResponse {
pub body: Vec<u8>,
pub headers: HeaderMap,
pub status_code: u16,
}
#[derive(Clone, Debug)]
pub struct ReqwestHttpService {
client: reqwest::Client,
}
impl Default for ReqwestHttpService {
fn default() -> Self {
let policy = policy::shared_http_policy();
let client = reqwest::Client::builder()
.timeout(policy.timeout)
.connect_timeout(policy.connect_timeout)
.build()
.unwrap_or_else(|_| reqwest::Client::new());
Self { client }
}
}
impl From<&str> for HttpMethod {
fn from(value: &str) -> Self {
match value.to_uppercase().as_str() {
| "DELETE" => HttpMethod::Delete,
| "GET" => HttpMethod::Get,
| "PATCH" => HttpMethod::Patch,
| "POST" => HttpMethod::Post,
| "PUT" => HttpMethod::Put,
| _ => HttpMethod::Get,
}
}
}
impl From<HttpMethod> for reqwest::Method {
fn from(value: HttpMethod) -> Self {
match value {
| HttpMethod::Delete => reqwest::Method::DELETE,
| HttpMethod::Get => reqwest::Method::GET,
| HttpMethod::Patch => reqwest::Method::PATCH,
| HttpMethod::Post => reqwest::Method::POST,
| HttpMethod::Put => reqwest::Method::PUT,
}
}
}
impl fmt::Display for HttpRequestBuilder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.request.method, self.request.url)
}
}
impl HttpRequestBuilder {
pub fn headers(mut self, headers: HeaderMap) -> Self {
self.request.headers.extend(headers);
self
}
pub fn json(mut self, value: &serde_json::Value) -> Self {
self.request.json_body = Some(value.clone());
self
}
fn new(method: HttpMethod, url: impl Into<String>) -> Self {
Self {
request: HttpRequest {
headers: HeaderMap::new(),
json_body: None,
method,
url: url.into(),
},
service: ReqwestHttpService::default(),
}
}
pub async fn send(self) -> ApiResult<HttpResponse> {
self.service.execute(self.request).await
}
}
impl HttpResponse {
pub async fn bytes(self) -> ApiResult<Vec<u8>> {
Ok(self.body)
}
pub async fn text(self) -> ApiResult<String> {
match String::from_utf8(self.body) {
| Ok(value) => Ok(value),
| Err(why) => Err(eyre!("HTTP response body is not valid UTF-8 — {why}")),
}
}
}
#[async_trait]
impl HttpService for ReqwestHttpService {
async fn execute(&self, request: HttpRequest) -> ApiResult<HttpResponse> {
execute_with_policy(self.client.clone(), request).await
}
}
async fn execute_with_policy(client: reqwest::Client, request: HttpRequest) -> ApiResult<HttpResponse> {
let policy = policy::shared_http_policy();
let method = request.method.clone();
let url = request.url.clone();
let max_attempts = policy.max_attempts();
for attempt in 1..=max_attempts {
let started = Utc::now();
let result = execute_with_timeout(client.clone(), request.clone()).await;
#[allow(clippy::arithmetic_side_effects)]
let elapsed_ms = (Utc::now() - started).num_milliseconds();
match result {
| Ok(response) => {
let retry = should_retry(&method, Some(response.status_code));
if retry && attempt < max_attempts {
warn!(
attempt,
status_code = response.status_code,
elapsed_ms,
url,
"=> {} Retrying HTTP request",
Label::using()
);
} else {
debug!(
attempt,
status_code = response.status_code,
elapsed_ms,
url,
"=> {} HTTP request",
Label::using()
);
return Ok(response);
}
}
| Err(why) => {
let retry = should_retry(&method, None);
if retry && attempt < max_attempts {
warn!(attempt, elapsed_ms, url, "=> {} Retrying HTTP request — {why}", Label::using());
} else {
warn!(attempt, elapsed_ms, url, "=> {} HTTP request failed — {why}", Label::fail());
return Err(why);
}
}
}
}
Err(eyre!("HTTP request failed after retry attempts"))
}
async fn execute_with_timeout(client: reqwest::Client, request: HttpRequest) -> ApiResult<HttpResponse> {
let service = policy::http_service_builder().service(service_fn(move |value: HttpRequest| {
let client = client.clone();
async move { invoke_request(client, value).await }
}));
service
.oneshot(request)
.await
.map_err(|why| eyre!("HTTP service timeout or middleware error — {why}"))
}
pub fn get(url: impl Into<String>) -> HttpRequestBuilder {
HttpRequestBuilder::new(HttpMethod::Get, url)
}
pub fn delete(url: impl Into<String>) -> HttpRequestBuilder {
HttpRequestBuilder::new(HttpMethod::Delete, url)
}
pub fn patch(url: impl Into<String>) -> HttpRequestBuilder {
HttpRequestBuilder::new(HttpMethod::Patch, url)
}
pub fn post(url: impl Into<String>) -> HttpRequestBuilder {
HttpRequestBuilder::new(HttpMethod::Post, url)
}
pub fn put(url: impl Into<String>) -> HttpRequestBuilder {
HttpRequestBuilder::new(HttpMethod::Put, url)
}
async fn invoke_request(client: reqwest::Client, request: HttpRequest) -> ApiResult<HttpResponse> {
let HttpRequest {
headers,
json_body,
method,
url,
} = request;
let builder = client.request(method.into(), url).header(USER_AGENT, ACORN_USER_AGENT).headers(headers);
let builder = match json_body {
| Some(value) => builder.json(&value),
| None => builder,
};
match builder.send().await {
| Ok(response) => {
let status_code = response.status().as_u16();
let headers = response.headers().clone();
match response.bytes().await {
| Ok(body) => Ok(HttpResponse {
body: body.to_vec(),
headers,
status_code,
}),
| Err(why) => Err(eyre!(why)),
}
}
| Err(why) => Err(eyre!(why)),
}
}
pub async fn response_body_bytes(response: ApiResult<HttpResponse>, error_message: &str) -> ApiResult<Vec<u8>> {
match response {
| Ok(value) => match value.status_code {
| 200..=299 => value.bytes().await.map_err(|why| eyre!("{error_message} — {why}")),
| status => Err(eyre!("{error_message} — HTTP {status}")),
},
| Err(why) => Err(eyre!("{error_message} — {why}")),
}
}
pub(crate) fn should_retry(method: &HttpMethod, status_code: Option<u16>) -> bool {
policy::should_retry(method, status_code)
}