Nice cleanup of the http_client lib

This commit is contained in:
Daniel Flanagan 2024-08-05 16:48:12 -05:00
parent 871c239ef2
commit a9e8f2e9cf
15 changed files with 3822 additions and 405 deletions

View file

@ -1,5 +0,0 @@
[env]
RUST_BACKTRACE = "1"
# [profile.release]
# RUSTFLAGS="-Zlocation-detail=none"

932
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,35 +1,9 @@
[package] [workspace]
name = "yourcloud" resolver = "2"
version = "0.1.0" members = ["apps/yourcloud"]
edition = "2021"
[dependencies] [workspace.dependencies]
axum = "0.7.5" http_client = { path = "libs/http_client" }
color-eyre = "0.6.3"
config = "0.14.0"
discord = { git = "https://github.com/SpaceManiac/discord-rs" }
redact = { version = "0.1.9", features = ["serde"] }
reqwest = { version = "0.12.3", features = ["json", "socks"] }
reqwest-middleware = "0.3.0"
reqwest-retry = "0.5.0"
reqwest-tracing = "0.5.0"
serde = { version = "1.0.197", features = ["derive"] }
serde_json = "1.0.115"
serde_with = "3.7.0"
tokio = { version = "1.37.0", features = ["macros", "rt-multi-thread"] }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
urlencoding = "2.1.3"
[profile.dev] [profile.dev.package.backtrace]
opt-level = 1
[profile.dev.package."*"]
opt-level = 3 opt-level = 3
[profile.release]
strip = true
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"

2916
apps/yourcloud/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

36
apps/yourcloud/Cargo.toml Normal file
View file

@ -0,0 +1,36 @@
[package]
name = "yourcloud"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7.5"
color-eyre = "0.6.3"
config = "0.14.0"
discord = { git = "https://github.com/SpaceManiac/discord-rs" }
redact = { version = "0.1.9", features = ["serde"] }
reqwest = { version = "0.12.3", features = ["json", "socks"] }
reqwest-middleware = "0.3.0"
reqwest-retry = "0.5.0"
reqwest-tracing = "0.5.0"
serde = { version = "1.0.197", features = ["derive"] }
serde_json = "1.0.115"
serde_with = "3.7.0"
tokio = { version = "1.37.0", features = ["macros", "rt-multi-thread"] }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
urlencoding = "2.1.3"
http_client = { workspace = true }
[profile.dev]
opt-level = 1
[profile.dev.package."*"]
opt-level = 3
[profile.release]
strip = true
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"

View file

@ -5,7 +5,6 @@ mod config;
mod minecraft_server_status; mod minecraft_server_status;
mod observe; mod observe;
mod prelude; mod prelude;
mod webclient;
mod webserver; mod webserver;
use crate::prelude::*; use crate::prelude::*;

View file

@ -1,8 +1,7 @@
use crate::prelude::*; use crate::prelude::*;
use crate::webclient::Client; use http_client::{Client, HeaderMap, HeaderValue, Url};
use serde_with::skip_serializing_none; use serde_with::skip_serializing_none;
use reqwest::header::{HeaderMap, HeaderValue};
use std::collections::HashMap; use std::collections::HashMap;
pub struct MinecraftServerStatus { pub struct MinecraftServerStatus {
@ -96,14 +95,19 @@ impl MinecraftServerStatus {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert("content-type", HeaderValue::from_str("application/json")?); headers.insert("content-type", HeaderValue::from_str("application/json")?);
headers.insert("accepts", HeaderValue::from_str("application/json")?); headers.insert("accepts", HeaderValue::from_str("application/json")?);
let base_client = Client::builder().default_headers(headers).build()?;
let client = Client::try_new(base_client, "https://api.mcsrvstat.us/3")?; let client = Client::named("http_client@api.mcsrvstat.us")
.with_default_headers(headers)
.with_url(Url::parse("https://api.mcsrvstat.us/3")?)
.build()?;
Ok(Self { client }) Ok(Self { client })
} }
pub async fn status(&self, endpoint: &str) -> Result<ServerStatus> { pub async fn status(&self, endpoint: &str) -> Result<ServerStatus> {
self.client Ok(self
.get(&format!("/{}", urlencoding::encode(endpoint))) .client
.await .get_json(&format!("/{}", urlencoding::encode(endpoint)))
.await?)
} }
} }

View file

@ -6,7 +6,7 @@ pub fn setup_logging() {
let filter = EnvFilter::builder() let filter = EnvFilter::builder()
.with_default_directive(LevelFilter::TRACE.into()) .with_default_directive(LevelFilter::TRACE.into())
.parse_lossy("info,yourcloud=trace"); .parse_lossy("trace,yourcloud=trace");
tracing_subscriber::fmt().with_env_filter(filter).init(); tracing_subscriber::fmt().with_env_filter(filter).init();
} }

View file

@ -49,7 +49,7 @@ async fn minecraft_server_status() -> WebserverResult<impl IntoResponse> {
let mut results = Vec::with_capacity(reqs.len()); let mut results = Vec::with_capacity(reqs.len());
for r in reqs { for r in reqs {
let client = c.clone(); let client = c.clone();
debug!("Fetching server status for jql {}", r); debug!("Fetching minecraft server status for {}", r);
futures.push(tokio::spawn(async move { client.status(r).await })); futures.push(tokio::spawn(async move { client.status(r).await }));
} }

View file

@ -0,0 +1,15 @@
[package]
name = "http_client"
version = "0.1.0"
edition = "2021"
[dependencies]
reqwest = { version = "0.11.26", features = ["json", "socks"] }
reqwest-middleware = "0.2.5"
reqwest-retry = "0.4.0"
reqwest-tracing = "0.4.8"
serde = { version = "1.0.197", features = ["derive"] }
serde_json = "1.0.114"
thiserror = "1.0.63"
tracing = "0.1.40"
url = "2.5.2"

192
libs/http_client/src/lib.rs Normal file
View file

@ -0,0 +1,192 @@
pub use reqwest::header::{HeaderMap, HeaderValue};
pub use reqwest::{Body, ClientBuilder, Method, Response, Url};
pub use reqwest_middleware::ClientBuilder as MiddlewareClientBuilder;
pub use reqwest_middleware::Middleware;
pub use reqwest_middleware::{ClientWithMiddleware, Extension, RequestBuilder};
pub use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
pub use reqwest_tracing::{OtelName, TracingMiddleware};
use serde::de::DeserializeOwned;
use std::sync::Arc;
use tracing::instrument;
#[derive(Debug)]
pub struct Client {
client: ClientWithMiddleware,
base_url: Option<Url>,
}
pub struct Builder {
middleware: Option<Box<Vec<Arc<dyn Middleware>>>>,
default_headers: Option<HeaderMap>,
base_url: Option<Url>,
name: Option<String>,
}
impl Default for Builder {
fn default() -> Self {
let mut middleware: Box<Vec<Arc<dyn Middleware>>> = Box::new(vec![]);
let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3);
let retry_middleware = RetryTransientMiddleware::new_with_policy(retry_policy);
middleware.push(Arc::new(retry_middleware));
middleware.push(Arc::new(TracingMiddleware::default()));
Builder {
name: None,
base_url: None,
default_headers: None,
middleware: Some(middleware),
}
}
}
impl Builder {
pub fn new() -> Builder {
Self::default()
}
fn named(name: &str) -> Self {
Self::default().with_name(name)
}
pub fn with_name(mut self, name: &str) -> Self {
self.name = Some(name.to_string());
self
}
pub fn with_url(mut self, base_url: Url) -> Self {
self.base_url = Some(base_url);
self
}
pub fn try_with_url(self, base_url: &str) -> Result<Self> {
Ok(self.with_url(Url::parse(base_url)?))
}
pub fn with_default_headers(mut self, default_headers: HeaderMap) -> Self {
self.default_headers = Some(default_headers);
self
}
pub fn build(mut self) -> Result<Client> {
let mut builder = reqwest::Client::builder();
if let Some(headers) = self.default_headers {
builder = builder.default_headers(headers);
}
let mut middleware_builder = reqwest_middleware::ClientBuilder::new(builder.build()?);
if let Some(middleware) = self.middleware {
for middleware in middleware.into_iter() {
middleware_builder = middleware_builder.with_arc(middleware);
}
}
if let Some(otel_name) = self.name {
middleware_builder =
middleware_builder.with_init(Extension(OtelName(otel_name.into())));
}
let client = middleware_builder.build();
if let Some(ref mut base_url) = self.base_url {
base_url.set_path(&format!("{}{}", base_url.path().trim_end_matches('/'), "/"));
}
Ok(Client {
client,
base_url: self.base_url,
})
}
}
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("reqwest error: {0}")]
Reqwest(#[from] reqwest::Error),
#[error("reqwest_middleware error: {0}")]
ReqwestMiddleware(#[from] reqwest_middleware::Error),
#[error("reqwest_middleware error: {0}")]
UrlParse(#[from] url::ParseError),
}
pub type Result<T> = std::result::Result<T, Error>;
/// A good-enough-out-of-the-box, batteries-included async HTTP client with
/// enough escape hatches in case you need to get wild. Includes retries and
/// tracing instrumentation.
///
/// The intent of HttpClient is to be able to painlessly build out wrappers
/// around HTTP API endpoints.
impl Client {
pub fn builder() -> Builder {
Builder::default()
}
pub fn named(name: &str) -> Builder {
Builder::named(name)
}
/// Takes a path segment and properly joins it relative to `base_url`
/// If the client has None base_url, then the path will be parsed as a Url
/// and the result returned.
#[instrument]
pub fn build_url(&self, path: &str) -> Result<Url> {
if let Some(url) = &self.base_url {
url.join(path.trim_start_matches('/')).map_err(|e| e.into())
} else {
Ok(Url::parse(path)?)
}
}
#[instrument]
pub fn build_request(&self, method: Method, path: &str) -> Result<RequestBuilder> {
Ok(self.client.request(method, self.build_url(path)?))
}
#[instrument]
pub async fn send_with_body<S: Into<Body> + std::fmt::Debug>(
&self,
method: Method,
path: &str,
body: S,
) -> Result<Response> {
Ok(self.build_request(method, path)?.body(body).send().await?)
}
#[instrument]
pub async fn post<S: Into<Body> + std::fmt::Debug>(
&self,
path: &str,
body: S,
) -> Result<Response> {
Ok(self.send_with_body(Method::POST, path, body).await?)
}
#[instrument]
pub async fn put<S: Into<Body> + std::fmt::Debug>(
&self,
path: &str,
body: S,
) -> Result<Response> {
Ok(self.send_with_body(Method::PUT, path, body).await?)
}
#[instrument]
pub async fn get(&self, rel_url_path: &str) -> Result<Response> {
Ok(self
.build_request(Method::GET, rel_url_path)?
.send()
.await?)
}
#[instrument]
pub async fn get_json<T: DeserializeOwned>(&self, rel_url_path: &str) -> Result<T> {
Ok(self.get(rel_url_path).await?.json().await?)
}
// this is a lib, so add new stuff sparingly!
// prefer building atop this instead of adding stuff here
}

View file

@ -1,70 +0,0 @@
use crate::prelude::*;
use reqwest::{Client as RClient, Method, Url};
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware, RequestBuilder};
use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
use reqwest_tracing::TracingMiddleware;
use serde::de;
pub struct Client {
client: ClientWithMiddleware,
base_url: Url,
}
pub trait ResourceRequest {
async fn res<T: de::DeserializeOwned>(self) -> Result<T>;
}
impl ResourceRequest for RequestBuilder {
async fn res<T: de::DeserializeOwned>(self) -> Result<T> {
let body = self.send().await?.text().await?;
debug!(body);
serde_json::from_str(&body).map_err(|e| e.into())
// self.send().await?.json().await.map_err(|e| e.into())
}
}
impl Client {
pub fn builder() -> reqwest::ClientBuilder {
RClient::builder()
}
pub fn try_new(client: RClient, base_url: &str) -> Result<Self> {
let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3);
let client = ClientBuilder::new(client)
.with(TracingMiddleware::default())
.with(RetryTransientMiddleware::new_with_policy(retry_policy))
.build();
// force trailing slash
let mut base_url = Url::parse(base_url)?;
base_url.set_path(&format!("{}{}", base_url.path().trim_end_matches('/'), "/"));
Ok(Self { client, base_url })
}
/// Takes a path segment and properly joins it relative to `base_url`
pub fn url(&self, path: &str) -> Result<Url> {
self.base_url
.join(path.trim_start_matches('/'))
.map_err(|e| e.into())
}
pub fn build(&self, method: Method, rel_url_path: &str) -> Result<RequestBuilder> {
Ok(self.client.request(method, self.url(rel_url_path)?))
}
pub async fn request<T: de::DeserializeOwned>(
&self,
method: Method,
rel_url_path: &str,
) -> Result<T> {
self.build(method, rel_url_path)?.res().await
}
pub async fn get<T: de::DeserializeOwned>(&self, rel_url_path: &str) -> Result<T> {
self.request::<T>(Method::GET, rel_url_path).await
}
}