zammad-api 0.4.0

Async, idiomatic Rust client for the Zammad Service Platform
Documentation
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};

/// Async, cheap-to-clone client for the Zammad REST API.
///
/// Use [`Client::builder`] to construct one. All resource handles
/// ([`Client::tickets`], [`Client::users`], ...) borrow `&self` so cloning is
/// rarely needed, but cloning is cheap (one `Arc::clone`) when you do want
/// to move the client into a long-lived task.
#[derive(Clone, Debug)]
pub struct Client {
    inner: Arc<Inner>,
}

#[derive(Debug)]
struct Inner {
    base: String,
    http: Http,
    auth: Auth,
}

impl Client {
    /// Start a new [`ClientBuilder`].
    pub fn builder() -> ClientBuilder {
        ClientBuilder::default()
    }

    /// Shorthand for `Client::builder().url(...).auth(...).build()`.
    pub fn new(url: impl Into<String>, auth: Auth) -> Result<Self> {
        Self::builder().url(url).auth(auth).build()
    }

    /// Base URL the client was configured with, without a trailing slash.
    pub fn base_url(&self) -> &str {
        &self.inner.base
    }

    /// Handle for ticket endpoints.
    pub fn tickets(&self) -> TicketsApi<'_> {
        TicketsApi::new(self)
    }

    /// Handle for ticket-article (reply / note / email) endpoints.
    pub fn articles(&self) -> ArticlesApi<'_> {
        ArticlesApi::new(self)
    }

    /// Handle for user endpoints.
    pub fn users(&self) -> UsersApi<'_> {
        UsersApi::new(self)
    }

    /// Handle for organization endpoints.
    pub fn organizations(&self) -> OrganizationsApi<'_> {
        OrganizationsApi::new(self)
    }

    /// Handle for group (queue) endpoints.
    pub fn groups(&self) -> GroupsApi<'_> {
        GroupsApi::new(self)
    }

    /// Handle for tag endpoints.
    pub fn tags(&self) -> TagsApi<'_> {
        TagsApi::new(self)
    }

    /// Handle for ticket-state endpoints.
    pub fn states(&self) -> StatesApi<'_> {
        StatesApi::new(self)
    }

    /// Handle for ticket-priority endpoints.
    pub fn priorities(&self) -> PrioritiesApi<'_> {
        PrioritiesApi::new(self)
    }

    /// Handle for Knowledge Base (FAQ) endpoints.
    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()
}

/// Builder for [`Client`].
#[derive(Default, Debug)]
pub struct ClientBuilder {
    url: Option<String>,
    auth: Option<Auth>,
    timeout: Option<Duration>,
    user_agent: Option<String>,
    http: Option<Http>,
}

impl ClientBuilder {
    /// Set the base URL of the Zammad instance, e.g. `https://help.example.com`.
    ///
    /// Trailing slashes are tolerated and trimmed.
    pub fn url(mut self, u: impl Into<String>) -> Self {
        self.url = Some(u.into());
        self
    }

    /// Set the authentication method.
    pub fn auth(mut self, a: Auth) -> Self {
        self.auth = Some(a);
        self
    }

    /// Set the per-request timeout. Defaults to 60 seconds when [`Self::http_client`]
    /// is not supplied; ignored otherwise.
    pub fn timeout(mut self, d: Duration) -> Self {
        self.timeout = Some(d);
        self
    }

    /// Override the `User-Agent` header. Defaults to `zammad-api/<version>`.
    pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
        self.user_agent = Some(ua.into());
        self
    }

    /// Supply a preconfigured [`reqwest::Client`] (proxies, custom TLS,
    /// connection pooling, middleware, ...).
    ///
    /// When set, [`Self::timeout`] and [`Self::user_agent`] are ignored - they
    /// belong to the underlying HTTP client.
    pub fn http_client(mut self, http: Http) -> Self {
        self.http = Some(http);
        self
    }

    /// Finalize the builder.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Config`] when the URL or auth is missing, and
    /// [`Error::InvalidUrl`] / [`Error::Http`] for parse or HTTP-stack
    /// initialisation failures.
    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 }),
        })
    }
}