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, OrganizationsApi, PrioritiesApi, StatesApi, TagsApi, TicketsApi,
11 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(crate) fn request(&self, method: Method, path: &str) -> RequestBuilder {
91 let path = path.trim_start_matches('/');
92 let url = format!("{}/{}", self.inner.base, path);
93 self.inner
94 .auth
95 .apply(self.inner.http.request(method, url))
96 }
97
98 pub(crate) async fn send_json<T: DeserializeOwned>(&self, req: RequestBuilder) -> Result<T> {
99 let bytes = self.send_bytes(req).await?;
100 serde_json::from_slice(&bytes).map_err(Error::from)
101 }
102
103 pub(crate) async fn send_unit(&self, req: RequestBuilder) -> Result<()> {
104 let _ = self.send_bytes(req).await?;
105 Ok(())
106 }
107
108 pub(crate) async fn send_bytes(&self, req: RequestBuilder) -> Result<bytes::Bytes> {
109 let resp = req.send().await?;
110 let status = resp.status();
111 let bytes = resp.bytes().await?;
112 debug!(status = status.as_u16(), bytes = bytes.len(), "zammad response");
113 if status.is_success() {
114 return Ok(bytes);
115 }
116 if status == reqwest::StatusCode::NOT_FOUND {
117 return Err(Error::NotFound);
118 }
119 let message = extract_error_message(&bytes);
120 Err(Error::Api {
121 status: status.as_u16(),
122 message,
123 })
124 }
125}
126
127fn extract_error_message(bytes: &[u8]) -> String {
128 if let Ok(v) = serde_json::from_slice::<serde_json::Value>(bytes) {
129 if let Some(s) = v.get("error_human").and_then(|v| v.as_str()) {
130 return s.to_owned();
131 }
132 if let Some(s) = v.get("error").and_then(|v| v.as_str()) {
133 return s.to_owned();
134 }
135 }
136 String::from_utf8_lossy(bytes).into_owned()
137}
138
139#[derive(Default, Debug)]
141pub struct ClientBuilder {
142 url: Option<String>,
143 auth: Option<Auth>,
144 timeout: Option<Duration>,
145 user_agent: Option<String>,
146 http: Option<Http>,
147}
148
149impl ClientBuilder {
150 pub fn url(mut self, u: impl Into<String>) -> Self {
154 self.url = Some(u.into());
155 self
156 }
157
158 pub fn auth(mut self, a: Auth) -> Self {
160 self.auth = Some(a);
161 self
162 }
163
164 pub fn timeout(mut self, d: Duration) -> Self {
167 self.timeout = Some(d);
168 self
169 }
170
171 pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
173 self.user_agent = Some(ua.into());
174 self
175 }
176
177 pub fn http_client(mut self, http: Http) -> Self {
183 self.http = Some(http);
184 self
185 }
186
187 pub fn build(self) -> Result<Client> {
195 let raw_url = self
196 .url
197 .ok_or_else(|| Error::Config("base URL is required".into()))?;
198 let auth = self
199 .auth
200 .ok_or_else(|| Error::Config("auth is required".into()))?;
201 let parsed = Url::parse(&raw_url)?;
202 if parsed.cannot_be_a_base() {
203 return Err(Error::Config(format!("not a base URL: {raw_url}")));
204 }
205 let base = raw_url.trim_end_matches('/').to_string();
206
207 let http = match self.http {
208 Some(h) => h,
209 None => {
210 let ua = self
211 .user_agent
212 .unwrap_or_else(|| concat!("zammad-api/", env!("CARGO_PKG_VERSION")).to_string());
213 Http::builder()
214 .timeout(self.timeout.unwrap_or(Duration::from_secs(60)))
215 .user_agent(ua)
216 .build()?
217 }
218 };
219
220 Ok(Client {
221 inner: Arc::new(Inner { base, http, auth }),
222 })
223 }
224}