use std::sync::Arc;
use std::time::Duration;
use reqwest::{Client as Http, Method, RequestBuilder};
use serde::de::DeserializeOwned;
use tracing::debug;
use url::Url;
use crate::api::{
ArticlesApi, GroupsApi, KnowledgeBaseApi, OrganizationsApi, PrioritiesApi, StatesApi, TagsApi,
TicketsApi, UsersApi,
};
use crate::auth::Auth;
use crate::error::{Error, Result};
#[derive(Clone, Debug)]
pub struct Client {
inner: Arc<Inner>,
}
#[derive(Debug)]
struct Inner {
base: String,
http: Http,
auth: Auth,
}
impl Client {
pub fn builder() -> ClientBuilder {
ClientBuilder::default()
}
pub fn new(url: impl Into<String>, auth: Auth) -> Result<Self> {
Self::builder().url(url).auth(auth).build()
}
pub fn base_url(&self) -> &str {
&self.inner.base
}
pub fn tickets(&self) -> TicketsApi<'_> {
TicketsApi::new(self)
}
pub fn articles(&self) -> ArticlesApi<'_> {
ArticlesApi::new(self)
}
pub fn users(&self) -> UsersApi<'_> {
UsersApi::new(self)
}
pub fn organizations(&self) -> OrganizationsApi<'_> {
OrganizationsApi::new(self)
}
pub fn groups(&self) -> GroupsApi<'_> {
GroupsApi::new(self)
}
pub fn tags(&self) -> TagsApi<'_> {
TagsApi::new(self)
}
pub fn states(&self) -> StatesApi<'_> {
StatesApi::new(self)
}
pub fn priorities(&self) -> PrioritiesApi<'_> {
PrioritiesApi::new(self)
}
pub fn knowledge_base(&self) -> KnowledgeBaseApi<'_> {
KnowledgeBaseApi::new(self)
}
pub(crate) fn request(&self, method: Method, path: &str) -> RequestBuilder {
let path = path.trim_start_matches('/');
let url = format!("{}/{}", self.inner.base, path);
self.inner
.auth
.apply(self.inner.http.request(method, url))
}
pub(crate) async fn send_json<T: DeserializeOwned>(&self, req: RequestBuilder) -> Result<T> {
let bytes = self.send_bytes(req).await?;
serde_json::from_slice(&bytes).map_err(Error::from)
}
pub(crate) async fn send_unit(&self, req: RequestBuilder) -> Result<()> {
let _ = self.send_bytes(req).await?;
Ok(())
}
pub(crate) async fn send_bytes(&self, req: RequestBuilder) -> Result<bytes::Bytes> {
let resp = req.send().await?;
let status = resp.status();
let bytes = resp.bytes().await?;
debug!(status = status.as_u16(), bytes = bytes.len(), "zammad response");
if status.is_success() {
return Ok(bytes);
}
if status == reqwest::StatusCode::NOT_FOUND {
return Err(Error::NotFound);
}
let message = extract_error_message(&bytes);
Err(Error::Api {
status: status.as_u16(),
message,
})
}
}
fn extract_error_message(bytes: &[u8]) -> String {
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(bytes) {
if let Some(s) = v.get("error_human").and_then(|v| v.as_str()) {
return s.to_owned();
}
if let Some(s) = v.get("error").and_then(|v| v.as_str()) {
return s.to_owned();
}
}
String::from_utf8_lossy(bytes).into_owned()
}
#[derive(Default, Debug)]
pub struct ClientBuilder {
url: Option<String>,
auth: Option<Auth>,
timeout: Option<Duration>,
user_agent: Option<String>,
http: Option<Http>,
}
impl ClientBuilder {
pub fn url(mut self, u: impl Into<String>) -> Self {
self.url = Some(u.into());
self
}
pub fn auth(mut self, a: Auth) -> Self {
self.auth = Some(a);
self
}
pub fn timeout(mut self, d: Duration) -> Self {
self.timeout = Some(d);
self
}
pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
self.user_agent = Some(ua.into());
self
}
pub fn http_client(mut self, http: Http) -> Self {
self.http = Some(http);
self
}
pub fn build(self) -> Result<Client> {
let raw_url = self
.url
.ok_or_else(|| Error::Config("base URL is required".into()))?;
let auth = self
.auth
.ok_or_else(|| Error::Config("auth is required".into()))?;
let parsed = Url::parse(&raw_url)?;
if parsed.cannot_be_a_base() {
return Err(Error::Config(format!("not a base URL: {raw_url}")));
}
let base = raw_url.trim_end_matches('/').to_string();
let http = match self.http {
Some(h) => h,
None => {
let ua = self
.user_agent
.unwrap_or_else(|| concat!("zammad-api/", env!("CARGO_PKG_VERSION")).to_string());
Http::builder()
.timeout(self.timeout.unwrap_or(Duration::from_secs(60)))
.user_agent(ua)
.build()?
}
};
Ok(Client {
inner: Arc::new(Inner { base, http, auth }),
})
}
}