1use super::ApiResult;
3use crate::util::Label;
4use async_trait::async_trait;
5use axum::http::HeaderMap;
6use chrono::Utc;
7use color_eyre::eyre::eyre;
8use core::fmt;
9use reqwest::header::USER_AGENT;
10use serde::{Deserialize, Serialize};
11use tower::{service_fn, ServiceExt};
12use tracing::{debug, warn};
13
14pub mod policy;
15
16#[cfg(feature = "std")]
18const ACORN_USER_AGENT: &str = concat!("ACORN/", env!("CARGO_PKG_VERSION"), " (https://acorn.ornl.gov; mailto:research@ornl.gov)");
19#[cfg(not(feature = "std"))]
20const ACORN_USER_AGENT: &str = "ACORN (https://acorn.ornl.gov; mailto:research@ornl.gov)";
21#[async_trait]
23pub trait HttpService {
24 async fn execute(&self, request: HttpRequest) -> ApiResult<HttpResponse>;
26}
27#[derive(Clone, Debug, Default, Deserialize, Serialize)]
29#[serde(rename_all = "lowercase")]
30pub enum HttpMethod {
31 #[default]
33 Get,
34 Delete,
36 Patch,
38 Post,
40 Put,
42}
43#[derive(Clone, Debug)]
45pub struct HttpRequest {
46 pub headers: HeaderMap,
48 pub json_body: Option<serde_json::Value>,
50 pub method: HttpMethod,
52 pub url: String,
54}
55#[derive(Clone, Debug)]
57pub struct HttpRequestBuilder {
58 request: HttpRequest,
59 service: ReqwestHttpService,
60}
61#[derive(Clone, Debug)]
63pub struct HttpResponse {
64 pub body: Vec<u8>,
66 pub headers: HeaderMap,
68 pub status_code: u16,
70}
71#[derive(Clone, Debug)]
73pub struct ReqwestHttpService {
74 client: reqwest::Client,
75}
76impl Default for ReqwestHttpService {
77 fn default() -> Self {
78 let policy = policy::shared_http_policy();
79 let client = reqwest::Client::builder()
80 .timeout(policy.timeout)
81 .connect_timeout(policy.connect_timeout)
82 .build()
83 .unwrap_or_else(|_| reqwest::Client::new());
84 Self { client }
85 }
86}
87impl From<&str> for HttpMethod {
88 fn from(value: &str) -> Self {
89 match value.to_uppercase().as_str() {
90 | "DELETE" => HttpMethod::Delete,
91 | "GET" => HttpMethod::Get,
92 | "PATCH" => HttpMethod::Patch,
93 | "POST" => HttpMethod::Post,
94 | "PUT" => HttpMethod::Put,
95 | _ => HttpMethod::Get,
96 }
97 }
98}
99impl From<HttpMethod> for reqwest::Method {
100 fn from(value: HttpMethod) -> Self {
101 match value {
102 | HttpMethod::Delete => reqwest::Method::DELETE,
103 | HttpMethod::Get => reqwest::Method::GET,
104 | HttpMethod::Patch => reqwest::Method::PATCH,
105 | HttpMethod::Post => reqwest::Method::POST,
106 | HttpMethod::Put => reqwest::Method::PUT,
107 }
108 }
109}
110impl fmt::Display for HttpRequestBuilder {
111 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112 write!(f, "{} {}", self.request.method, self.request.url)
113 }
114}
115impl HttpRequestBuilder {
116 pub fn headers(mut self, headers: HeaderMap) -> Self {
118 self.request.headers.extend(headers);
119 self
120 }
121 pub fn json(mut self, value: &serde_json::Value) -> Self {
123 self.request.json_body = Some(value.clone());
124 self
125 }
126 fn new(method: HttpMethod, url: impl Into<String>) -> Self {
127 Self {
128 request: HttpRequest {
129 headers: HeaderMap::new(),
130 json_body: None,
131 method,
132 url: url.into(),
133 },
134 service: ReqwestHttpService::default(),
135 }
136 }
137 pub async fn send(self) -> ApiResult<HttpResponse> {
139 self.service.execute(self.request).await
140 }
141}
142impl HttpResponse {
143 pub async fn bytes(self) -> ApiResult<Vec<u8>> {
145 Ok(self.body)
146 }
147 pub async fn text(self) -> ApiResult<String> {
149 match String::from_utf8(self.body) {
150 | Ok(value) => Ok(value),
151 | Err(why) => Err(eyre!("HTTP response body is not valid UTF-8 — {why}")),
152 }
153 }
154}
155#[async_trait]
156impl HttpService for ReqwestHttpService {
157 async fn execute(&self, request: HttpRequest) -> ApiResult<HttpResponse> {
158 execute_with_policy(self.client.clone(), request).await
159 }
160}
161async fn execute_with_policy(client: reqwest::Client, request: HttpRequest) -> ApiResult<HttpResponse> {
162 let policy = policy::shared_http_policy();
163 let method = request.method.clone();
164 let url = request.url.clone();
165 let max_attempts = policy.max_attempts();
166 for attempt in 1..=max_attempts {
167 let started = Utc::now();
168 let result = execute_with_timeout(client.clone(), request.clone()).await;
169 #[allow(clippy::arithmetic_side_effects)]
170 let elapsed_ms = (Utc::now() - started).num_milliseconds();
171 match result {
172 | Ok(response) => {
173 let retry = should_retry(&method, Some(response.status_code));
174 if retry && attempt < max_attempts {
175 warn!(
176 attempt,
177 status_code = response.status_code,
178 elapsed_ms,
179 url,
180 "=> {} Retrying HTTP request",
181 Label::using()
182 );
183 } else {
184 debug!(
185 attempt,
186 status_code = response.status_code,
187 elapsed_ms,
188 url,
189 "=> {} HTTP request",
190 Label::using()
191 );
192 return Ok(response);
193 }
194 }
195 | Err(why) => {
196 let retry = should_retry(&method, None);
197 if retry && attempt < max_attempts {
198 warn!(attempt, elapsed_ms, url, "=> {} Retrying HTTP request — {why}", Label::using());
199 } else {
200 warn!(attempt, elapsed_ms, url, "=> {} HTTP request failed — {why}", Label::fail());
201 return Err(why);
202 }
203 }
204 }
205 }
206 Err(eyre!("HTTP request failed after retry attempts"))
207}
208async fn execute_with_timeout(client: reqwest::Client, request: HttpRequest) -> ApiResult<HttpResponse> {
209 let service = policy::http_service_builder().service(service_fn(move |value: HttpRequest| {
210 let client = client.clone();
211 async move { invoke_request(client, value).await }
212 }));
213 service
214 .oneshot(request)
215 .await
216 .map_err(|why| eyre!("HTTP service timeout or middleware error — {why}"))
217}
218pub fn get(url: impl Into<String>) -> HttpRequestBuilder {
220 HttpRequestBuilder::new(HttpMethod::Get, url)
221}
222pub fn delete(url: impl Into<String>) -> HttpRequestBuilder {
224 HttpRequestBuilder::new(HttpMethod::Delete, url)
225}
226pub fn patch(url: impl Into<String>) -> HttpRequestBuilder {
228 HttpRequestBuilder::new(HttpMethod::Patch, url)
229}
230pub fn post(url: impl Into<String>) -> HttpRequestBuilder {
232 HttpRequestBuilder::new(HttpMethod::Post, url)
233}
234pub fn put(url: impl Into<String>) -> HttpRequestBuilder {
236 HttpRequestBuilder::new(HttpMethod::Put, url)
237}
238async fn invoke_request(client: reqwest::Client, request: HttpRequest) -> ApiResult<HttpResponse> {
239 let HttpRequest {
240 headers,
241 json_body,
242 method,
243 url,
244 } = request;
245 let builder = client.request(method.into(), url).header(USER_AGENT, ACORN_USER_AGENT).headers(headers);
246 let builder = match json_body {
247 | Some(value) => builder.json(&value),
248 | None => builder,
249 };
250 match builder.send().await {
251 | Ok(response) => {
252 let status_code = response.status().as_u16();
253 let headers = response.headers().clone();
254 match response.bytes().await {
255 | Ok(body) => Ok(HttpResponse {
256 body: body.to_vec(),
257 headers,
258 status_code,
259 }),
260 | Err(why) => Err(eyre!(why)),
261 }
262 }
263 | Err(why) => Err(eyre!(why)),
264 }
265}
266pub async fn response_body_bytes(response: ApiResult<HttpResponse>, error_message: &str) -> ApiResult<Vec<u8>> {
268 match response {
269 | Ok(value) => match value.status_code {
270 | 200..=299 => value.bytes().await.map_err(|why| eyre!("{error_message} — {why}")),
271 | status => Err(eyre!("{error_message} — HTTP {status}")),
272 },
273 | Err(why) => Err(eyre!("{error_message} — {why}")),
274 }
275}
276pub(crate) fn should_retry(method: &HttpMethod, status_code: Option<u16>) -> bool {
277 policy::should_retry(method, status_code)
278}