mod builder;
mod endpoint;
mod headers;
mod reply;
pub mod multipart;
use std::borrow::Cow;
use std::future::Future;
use std::sync::Arc;
use std::time::{Duration, Instant};
use reqwest::Response as ReqwestResponse;
use reqwest_middleware::{
ClientBuilder as MiddlewareBuilder, ClientWithMiddleware, RequestBuilder,
};
use reqwest_retry::RetryTransientMiddleware;
use reqwest_retry::policies::ExponentialBackoff;
use url::Url;
pub use self::builder::ClientBuilder;
pub use self::endpoint::Endpoint;
pub(crate) use self::endpoint::EndpointBase;
pub(crate) use self::headers::Headers;
pub use self::reply::EndpointReply;
use crate::error::{Error, Result};
#[derive(Debug, Clone)]
pub struct Client {
inner: Arc<ClientImpl>,
}
#[derive(Debug)]
struct ClientImpl {
http: ClientWithMiddleware,
base_url: Url,
}
impl Client {
pub fn builder() -> ClientBuilder {
ClientBuilder::default()
}
pub fn base_url(&self) -> &Url {
&self.inner.base_url
}
#[doc(hidden)]
pub fn as_client(&self) -> &reqwest::Client {
self.inner.http.as_ref()
}
#[doc(hidden)]
pub fn as_client_with_middleware(&self) -> &reqwest_middleware::ClientWithMiddleware {
&self.inner.http
}
pub fn endpoint(&self, route: impl Into<Cow<'static, str>>) -> Endpoint {
Endpoint::new(self.clone(), route.into())
}
pub fn task(&self, route: impl Into<Cow<'static, str>>) -> crate::task::TaskEndpoint {
crate::task::TaskEndpoint::new(self.clone(), route.into())
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), err))]
pub async fn is_ready(&self) -> Result<bool> {
self.health("readyz").await
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), err))]
pub async fn is_live(&self) -> Result<bool> {
self.health("livez").await
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self, sleep), err))]
pub async fn wait_until_ready<S, F>(
&self,
timeout: Duration,
interval: Duration,
mut sleep: S,
) -> Result<()>
where
S: FnMut(Duration) -> F + Send,
F: Future<Output = ()> + Send,
{
let deadline = Instant::now() + timeout;
loop {
if matches!(self.is_ready().await, Ok(true)) {
return Ok(());
}
if Instant::now() >= deadline {
return Err(Error::Timeout { timeout });
}
sleep(interval).await;
}
}
pub(crate) fn route_url(&self, route: &str) -> Result<Url> {
let route = route.trim_start_matches('/');
Ok(self.inner.base_url.join(route)?)
}
pub(crate) fn endpoint_query(&self, route: &str, key: &str, value: &str) -> Result<Url> {
let mut url = self.route_url(route)?;
url.query_pairs_mut().append_pair(key, value);
Ok(url)
}
pub(crate) fn post(&self, route: &str) -> Result<RequestBuilder> {
Ok(self.post_url(self.route_url(route)?))
}
pub(crate) fn get(&self, route: &str) -> Result<RequestBuilder> {
Ok(self.get_url(self.route_url(route)?))
}
pub(crate) fn post_url(&self, url: Url) -> RequestBuilder {
self.inner.http.post(url)
}
pub(crate) fn get_url(&self, url: Url) -> RequestBuilder {
self.inner.http.get(url)
}
pub(crate) fn put_url(&self, url: Url) -> RequestBuilder {
self.inner.http.put(url)
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip_all, err))]
pub(crate) async fn send(&self, req: RequestBuilder) -> Result<ReqwestResponse> {
let resp = req.send().await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(Error::service(status.as_u16(), &body));
}
Ok(resp)
}
pub(crate) async fn health(&self, route: &str) -> Result<bool> {
let resp = self.get(route)?.send().await?;
Ok(resp.status().is_success())
}
pub(crate) fn assemble(
base_url: String,
token: Option<String>,
timeout: Option<Duration>,
max_retries: u32,
mut headers: reqwest::header::HeaderMap,
) -> Result<Self> {
if let Some(token) = token {
let mut value: reqwest::header::HeaderValue = format!("Bearer {token}")
.parse()
.map_err(|e| Error::invalid_request("invalid bearer token", e))?;
value.set_sensitive(true);
headers.insert(reqwest::header::AUTHORIZATION, value);
}
let mut http = reqwest::Client::builder().default_headers(headers);
if let Some(timeout) = timeout {
http = http.timeout(timeout);
}
let inner = http.build()?;
let retry_policy = ExponentialBackoff::builder().build_with_max_retries(max_retries);
let http = MiddlewareBuilder::new(inner)
.with(RetryTransientMiddleware::new_with_policy(retry_policy))
.build();
let mut base_url = Url::parse(&base_url)?;
if !base_url.path().ends_with('/') {
let path = format!("{}/", base_url.path());
base_url.set_path(&path);
}
Ok(Self {
inner: Arc::new(ClientImpl { http, base_url }),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn client(base_url: &str) -> Client {
Client::builder().with_base_url(base_url).build().unwrap()
}
#[test]
fn base_url_gets_a_trailing_slash() {
assert_eq!(
client("http://localhost:3000").base_url().as_str(),
"http://localhost:3000/"
);
assert_eq!(
client("http://localhost:3000/").base_url().as_str(),
"http://localhost:3000/"
);
}
#[test]
fn endpoint_joins_route_tolerating_leading_slash() {
let c = client("http://localhost:3000");
assert_eq!(
c.route_url("summarize").unwrap().as_str(),
"http://localhost:3000/summarize"
);
assert_eq!(
c.route_url("/summarize").unwrap().as_str(),
"http://localhost:3000/summarize"
);
}
#[test]
fn endpoint_preserves_a_path_prefix() {
let c = client("http://localhost:3000/v1");
assert_eq!(
c.route_url("summarize").unwrap().as_str(),
"http://localhost:3000/v1/summarize"
);
}
}