use std::{future::Future, ops::Deref, time::Duration};
use progenitor_client::{ClientInfo, ResponseValue};
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue, USER_AGENT};
use crate::{
api,
error::{Error, Result},
transport::{Inner, RetryPolicy},
};
pub const DEFAULT_BASE_URL: &str = "https://api.nolgia.ai/v1";
pub const STAGING_BASE_URL: &str = "https://api.stg.nolgia.ai/v1";
pub const API_KEY_ENV: &str = "NOLGIA_API_KEY";
pub const TOKEN_ENV: &str = "NOLGIA_TOKEN";
pub const BASE_URL_ENV: &str = "NOLGIA_BASE_URL";
const SURFACE_HEADER: HeaderName = HeaderName::from_static("x-nolgia-surface");
#[derive(Debug, Clone)]
pub struct Client {
pub(crate) api: api::Client,
pub(crate) anon: reqwest::Client,
}
impl Deref for Client {
type Target = api::Client;
fn deref(&self) -> &api::Client {
&self.api
}
}
impl Client {
pub fn new(api_key: impl Into<String>) -> Result<Self> {
Self::builder().api_key(api_key).build()
}
pub fn from_env() -> Result<Self> {
ClientBuilder::from_env()?.build()
}
pub fn builder() -> ClientBuilder {
ClientBuilder::default()
}
pub fn api(&self) -> &api::Client {
&self.api
}
pub fn base_url(&self) -> &str {
self.api.baseurl()
}
pub fn with_idempotency_key(&self, key: impl AsRef<str>) -> Result<Self> {
let value = HeaderValue::from_str(key.as_ref()).map_err(|_| {
Error::Config(format!(
"idempotency key {:?} is not a valid header value",
key.as_ref()
))
})?;
let mut inner = self.api.inner().clone();
inner.idempotency_key = Some(value);
Ok(self.rebuild(inner))
}
pub fn with_retry(&self, retry: RetryPolicy) -> Self {
let mut inner = self.api.inner().clone();
inner.retry = retry;
self.rebuild(inner)
}
fn rebuild(&self, inner: Inner) -> Self {
Self {
api: api::Client::new_with_client(self.api.baseurl(), self.api.client().clone(), inner),
anon: self.anon.clone(),
}
}
pub(crate) fn url(&self, path: &str) -> String {
format!("{}{path}", self.api.baseurl())
}
pub(crate) fn http(&self) -> &reqwest::Client {
self.api.client()
}
pub(crate) fn inner(&self) -> &Inner {
self.api.inner()
}
pub(crate) async fn send(
&self,
request: reqwest::RequestBuilder,
operation: &str,
) -> Result<reqwest::Response> {
let request = request.build().map_err(Error::transport)?;
let response =
crate::transport::execute(self.api.client(), self.inner(), request, operation)
.await
.map_err(Error::transport)?;
if response.status().is_success() {
Ok(response)
} else {
Err(Error::from_response(response).await)
}
}
pub(crate) async fn send_json<T: serde::de::DeserializeOwned>(
&self,
request: reqwest::RequestBuilder,
operation: &str,
) -> Result<T> {
let response = self.send(request, operation).await?;
decode(response).await
}
}
pub(crate) async fn decode<T: serde::de::DeserializeOwned>(
response: reqwest::Response,
) -> Result<T> {
let status = response.status().as_u16();
let bytes = response.bytes().await.map_err(Error::transport)?;
serde_json::from_slice(&bytes).map_err(|err| Error::decode(Some(status), err))
}
#[derive(Debug, Clone)]
pub struct ClientBuilder {
api_key: Option<String>,
base_url: String,
timeout: Duration,
connect_timeout: Duration,
retry: RetryPolicy,
surface: Option<String>,
idempotency_key: Option<String>,
user_agent: Option<String>,
headers: HeaderMap,
invalid_header: Option<String>,
}
impl Default for ClientBuilder {
fn default() -> Self {
Self {
api_key: None,
base_url: DEFAULT_BASE_URL.to_owned(),
timeout: Duration::from_secs(120),
connect_timeout: Duration::from_secs(15),
retry: RetryPolicy::default(),
surface: None,
idempotency_key: None,
user_agent: None,
headers: HeaderMap::new(),
invalid_header: None,
}
}
}
impl ClientBuilder {
pub fn from_env() -> Result<Self> {
let non_empty = |name: &str| std::env::var(name).ok().filter(|v| !v.trim().is_empty());
let key = non_empty(API_KEY_ENV)
.or_else(|| non_empty(TOKEN_ENV))
.ok_or_else(|| {
Error::Config(format!(
"{API_KEY_ENV} is not set: create an API key at \
https://nolgia.com/settings/api-tokens and export it, or build the \
client with nolgia::Client::new(key)"
))
})?;
let mut builder = Self::default().api_key(key);
if let Some(url) = non_empty(BASE_URL_ENV) {
builder = builder.base_url(url);
}
Ok(builder)
}
pub fn api_key(mut self, key: impl Into<String>) -> Self {
self.api_key = Some(key.into());
self
}
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = url.into();
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn connect_timeout(mut self, timeout: Duration) -> Self {
self.connect_timeout = timeout;
self
}
pub fn retry(mut self, retry: RetryPolicy) -> Self {
self.retry = retry;
self
}
pub fn max_retries(mut self, retries: u32) -> Self {
self.retry.max_retries = retries;
self
}
pub fn surface(mut self, surface: impl Into<String>) -> Self {
self.surface = Some(surface.into());
self
}
pub fn idempotency_key(mut self, key: impl Into<String>) -> Self {
self.idempotency_key = Some(key.into());
self
}
pub fn header(mut self, name: &str, value: &str) -> Self {
match (
HeaderName::from_bytes(name.as_bytes()),
HeaderValue::from_str(value),
) {
(Ok(name), Ok(value)) => {
self.headers.insert(name, value);
}
_ => self.invalid_header = Some(name.to_owned()),
}
self
}
pub fn user_agent(mut self, product: impl Into<String>) -> Self {
self.user_agent = Some(product.into());
self
}
pub fn build(self) -> Result<Client> {
if let Some(name) = &self.invalid_header {
return Err(Error::Config(format!(
"header {name:?} is not a valid header"
)));
}
let key = self
.api_key
.filter(|k| !k.trim().is_empty())
.ok_or_else(|| {
Error::Config("an API key is required: ClientBuilder::api_key".into())
})?;
let mut auth = HeaderValue::from_str(&format!("Bearer {}", key.trim()))
.map_err(|_| Error::Config("the API key is not a valid header value".into()))?;
auth.set_sensitive(true);
let agent = match &self.user_agent {
Some(extra) => format!("nolgia-rust/{} {extra}", env!("CARGO_PKG_VERSION")),
None => format!("nolgia-rust/{}", env!("CARGO_PKG_VERSION")),
};
let agent = HeaderValue::from_str(&agent)
.map_err(|_| Error::Config("the user agent is not a valid header value".into()))?;
let mut headers = self.headers.clone();
headers.insert(AUTHORIZATION, auth);
headers.insert(USER_AGENT, agent.clone());
if let Some(surface) = &self.surface {
let value = HeaderValue::from_str(surface)
.map_err(|_| Error::Config("the surface is not a valid header value".into()))?;
headers.insert(SURFACE_HEADER, value);
}
let idempotency_key = self
.idempotency_key
.map(|key| {
HeaderValue::from_str(&key).map_err(|_| {
Error::Config(format!(
"idempotency key {key:?} is not a valid header value"
))
})
})
.transpose()?;
let http = reqwest::Client::builder()
.default_headers(headers)
.timeout(self.timeout)
.connect_timeout(self.connect_timeout)
.build()
.map_err(|err| Error::Config(format!("could not build the HTTP client: {err}")))?;
let anon = reqwest::Client::builder()
.user_agent(agent)
.connect_timeout(self.connect_timeout)
.build()
.map_err(|err| Error::Config(format!("could not build the HTTP client: {err}")))?;
let inner = Inner {
retry: self.retry,
idempotency_key,
};
Ok(Client {
api: api::Client::new_with_client(&normalize_base_url(&self.base_url), http, inner),
anon,
})
}
}
pub(crate) fn normalize_base_url(base_url: &str) -> String {
let trimmed = base_url.trim().trim_end_matches('/');
if trimmed.ends_with("/v1") {
trimmed.to_owned()
} else {
format!("{trimmed}/v1")
}
}
pub trait Typed<T> {
fn typed(self) -> impl Future<Output = Result<T>> + Send;
}
impl<F, T> Typed<T> for F
where
F: Future<Output = std::result::Result<ResponseValue<T>, progenitor_client::Error<()>>> + Send,
T: Send,
{
async fn typed(self) -> Result<T> {
match self.await {
Ok(value) => Ok(value.into_inner()),
Err(err) => Err(Error::from_api(err).await),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base_url_gets_v1_once() {
assert_eq!(
normalize_base_url("https://api.nolgia.ai"),
DEFAULT_BASE_URL
);
assert_eq!(
normalize_base_url("https://api.nolgia.ai/v1/"),
DEFAULT_BASE_URL
);
assert_eq!(
normalize_base_url("http://localhost:8080"),
"http://localhost:8080/v1"
);
}
#[test]
fn builder_requires_a_key() {
let err = Client::builder().build().unwrap_err();
assert!(err.to_string().contains("API key"));
assert!(Client::new("nol_x").is_ok());
}
#[test]
fn custom_headers_are_validated() {
assert!(
Client::builder()
.api_key("k")
.header("x-request-id", "r1")
.build()
.is_ok()
);
assert!(
Client::builder()
.api_key("k")
.header("bad name", "v")
.build()
.is_err()
);
}
#[test]
fn idempotency_clone_keeps_the_original_untouched() {
let client = Client::new("nol_x").unwrap();
let keyed = client.with_idempotency_key("abc").unwrap();
assert!(client.inner().idempotency_key.is_none());
assert_eq!(keyed.inner().idempotency_key.as_ref().unwrap(), "abc");
assert!(client.with_idempotency_key("bad\nkey").is_err());
}
}