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#[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 pub fn builder() -> ClientBuilder {
37 ClientBuilder::default()
38 }
39
40 pub fn new(url: impl Into<String>, auth: Auth) -> Result<Self> {
42 Self::builder().url(url).auth(auth).build()
43 }
44
45 pub fn base_url(&self) -> &str {
47 &self.inner.base
48 }
49
50 pub fn tickets(&self) -> TicketsApi<'_> {
52 TicketsApi::new(self)
53 }
54
55 pub fn articles(&self) -> ArticlesApi<'_> {
57 ArticlesApi::new(self)
58 }
59
60 pub fn users(&self) -> UsersApi<'_> {
62 UsersApi::new(self)
63 }
64
65 pub fn organizations(&self) -> OrganizationsApi<'_> {
67 OrganizationsApi::new(self)
68 }
69
70 pub fn groups(&self) -> GroupsApi<'_> {
72 GroupsApi::new(self)
73 }
74
75 pub fn tags(&self) -> TagsApi<'_> {
77 TagsApi::new(self)
78 }
79
80 pub fn states(&self) -> StatesApi<'_> {
82 StatesApi::new(self)
83 }
84
85 pub fn priorities(&self) -> PrioritiesApi<'_> {
87 PrioritiesApi::new(self)
88 }
89
90 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#[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 pub fn url(mut self, u: impl Into<String>) -> Self {
159 self.url = Some(u.into());
160 self
161 }
162
163 pub fn auth(mut self, a: Auth) -> Self {
165 self.auth = Some(a);
166 self
167 }
168
169 pub fn timeout(mut self, d: Duration) -> Self {
172 self.timeout = Some(d);
173 self
174 }
175
176 pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
178 self.user_agent = Some(ua.into());
179 self
180 }
181
182 pub fn http_client(mut self, http: Http) -> Self {
188 self.http = Some(http);
189 self
190 }
191
192 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}