Skip to main content

zammad_api/
client.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use reqwest::{Client as Http, Method, RequestBuilder};
5use serde::de::DeserializeOwned;
6use tracing::debug;
7use url::Url;
8
9use crate::api::{
10    ArticlesApi, GroupsApi, KnowledgeBaseApi, OrganizationsApi, PrioritiesApi, StatesApi, TagsApi,
11    TicketsApi, UsersApi,
12};
13use crate::auth::Auth;
14use crate::error::{Error, Result};
15
16/// Async, cheap-to-clone client for the Zammad REST API.
17///
18/// Use [`Client::builder`] to construct one. All resource handles
19/// ([`Client::tickets`], [`Client::users`], ...) borrow `&self` so cloning is
20/// rarely needed, but cloning is cheap (one `Arc::clone`) when you do want
21/// to move the client into a long-lived task.
22#[derive(Clone, Debug)]
23pub struct Client {
24    inner: Arc<Inner>,
25}
26
27#[derive(Debug)]
28struct Inner {
29    base: String,
30    http: Http,
31    auth: Auth,
32}
33
34impl Client {
35    /// Start a new [`ClientBuilder`].
36    pub fn builder() -> ClientBuilder {
37        ClientBuilder::default()
38    }
39
40    /// Shorthand for `Client::builder().url(...).auth(...).build()`.
41    pub fn new(url: impl Into<String>, auth: Auth) -> Result<Self> {
42        Self::builder().url(url).auth(auth).build()
43    }
44
45    /// Base URL the client was configured with, without a trailing slash.
46    pub fn base_url(&self) -> &str {
47        &self.inner.base
48    }
49
50    /// Handle for ticket endpoints.
51    pub fn tickets(&self) -> TicketsApi<'_> {
52        TicketsApi::new(self)
53    }
54
55    /// Handle for ticket-article (reply / note / email) endpoints.
56    pub fn articles(&self) -> ArticlesApi<'_> {
57        ArticlesApi::new(self)
58    }
59
60    /// Handle for user endpoints.
61    pub fn users(&self) -> UsersApi<'_> {
62        UsersApi::new(self)
63    }
64
65    /// Handle for organization endpoints.
66    pub fn organizations(&self) -> OrganizationsApi<'_> {
67        OrganizationsApi::new(self)
68    }
69
70    /// Handle for group (queue) endpoints.
71    pub fn groups(&self) -> GroupsApi<'_> {
72        GroupsApi::new(self)
73    }
74
75    /// Handle for tag endpoints.
76    pub fn tags(&self) -> TagsApi<'_> {
77        TagsApi::new(self)
78    }
79
80    /// Handle for ticket-state endpoints.
81    pub fn states(&self) -> StatesApi<'_> {
82        StatesApi::new(self)
83    }
84
85    /// Handle for ticket-priority endpoints.
86    pub fn priorities(&self) -> PrioritiesApi<'_> {
87        PrioritiesApi::new(self)
88    }
89
90    /// Handle for Knowledge Base (FAQ) endpoints.
91    pub fn knowledge_base(&self) -> KnowledgeBaseApi<'_> {
92        KnowledgeBaseApi::new(self)
93    }
94
95    pub(crate) fn request(&self, method: Method, path: &str) -> RequestBuilder {
96        let path = path.trim_start_matches('/');
97        let url = format!("{}/{}", self.inner.base, path);
98        self.inner
99            .auth
100            .apply(self.inner.http.request(method, url))
101    }
102
103    pub(crate) async fn send_json<T: DeserializeOwned>(&self, req: RequestBuilder) -> Result<T> {
104        let bytes = self.send_bytes(req).await?;
105        serde_json::from_slice(&bytes).map_err(Error::from)
106    }
107
108    pub(crate) async fn send_unit(&self, req: RequestBuilder) -> Result<()> {
109        let _ = self.send_bytes(req).await?;
110        Ok(())
111    }
112
113    pub(crate) async fn send_bytes(&self, req: RequestBuilder) -> Result<bytes::Bytes> {
114        let resp = req.send().await?;
115        let status = resp.status();
116        let bytes = resp.bytes().await?;
117        debug!(status = status.as_u16(), bytes = bytes.len(), "zammad response");
118        if status.is_success() {
119            return Ok(bytes);
120        }
121        if status == reqwest::StatusCode::NOT_FOUND {
122            return Err(Error::NotFound);
123        }
124        let message = extract_error_message(&bytes);
125        Err(Error::Api {
126            status: status.as_u16(),
127            message,
128        })
129    }
130}
131
132fn extract_error_message(bytes: &[u8]) -> String {
133    if let Ok(v) = serde_json::from_slice::<serde_json::Value>(bytes) {
134        if let Some(s) = v.get("error_human").and_then(|v| v.as_str()) {
135            return s.to_owned();
136        }
137        if let Some(s) = v.get("error").and_then(|v| v.as_str()) {
138            return s.to_owned();
139        }
140    }
141    String::from_utf8_lossy(bytes).into_owned()
142}
143
144/// Builder for [`Client`].
145#[derive(Default, Debug)]
146pub struct ClientBuilder {
147    url: Option<String>,
148    auth: Option<Auth>,
149    timeout: Option<Duration>,
150    user_agent: Option<String>,
151    http: Option<Http>,
152}
153
154impl ClientBuilder {
155    /// Set the base URL of the Zammad instance, e.g. `https://help.example.com`.
156    ///
157    /// Trailing slashes are tolerated and trimmed.
158    pub fn url(mut self, u: impl Into<String>) -> Self {
159        self.url = Some(u.into());
160        self
161    }
162
163    /// Set the authentication method.
164    pub fn auth(mut self, a: Auth) -> Self {
165        self.auth = Some(a);
166        self
167    }
168
169    /// Set the per-request timeout. Defaults to 60 seconds when [`Self::http_client`]
170    /// is not supplied; ignored otherwise.
171    pub fn timeout(mut self, d: Duration) -> Self {
172        self.timeout = Some(d);
173        self
174    }
175
176    /// Override the `User-Agent` header. Defaults to `zammad-api/<version>`.
177    pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
178        self.user_agent = Some(ua.into());
179        self
180    }
181
182    /// Supply a preconfigured [`reqwest::Client`] (proxies, custom TLS,
183    /// connection pooling, middleware, ...).
184    ///
185    /// When set, [`Self::timeout`] and [`Self::user_agent`] are ignored - they
186    /// belong to the underlying HTTP client.
187    pub fn http_client(mut self, http: Http) -> Self {
188        self.http = Some(http);
189        self
190    }
191
192    /// Finalize the builder.
193    ///
194    /// # Errors
195    ///
196    /// Returns [`Error::Config`] when the URL or auth is missing, and
197    /// [`Error::InvalidUrl`] / [`Error::Http`] for parse or HTTP-stack
198    /// initialisation failures.
199    pub fn build(self) -> Result<Client> {
200        let raw_url = self
201            .url
202            .ok_or_else(|| Error::Config("base URL is required".into()))?;
203        let auth = self
204            .auth
205            .ok_or_else(|| Error::Config("auth is required".into()))?;
206        let parsed = Url::parse(&raw_url)?;
207        if parsed.cannot_be_a_base() {
208            return Err(Error::Config(format!("not a base URL: {raw_url}")));
209        }
210        let base = raw_url.trim_end_matches('/').to_string();
211
212        let http = match self.http {
213            Some(h) => h,
214            None => {
215                let ua = self
216                    .user_agent
217                    .unwrap_or_else(|| concat!("zammad-api/", env!("CARGO_PKG_VERSION")).to_string());
218                Http::builder()
219                    .timeout(self.timeout.unwrap_or(Duration::from_secs(60)))
220                    .user_agent(ua)
221                    .build()?
222            }
223        };
224
225        Ok(Client {
226            inner: Arc::new(Inner { base, http, auth }),
227        })
228    }
229}