Skip to main content

zammad_api/api/
tickets.rs

1use std::marker::PhantomData;
2
3use async_stream::try_stream;
4use futures_core::stream::BoxStream;
5use reqwest::Method;
6use serde::de::DeserializeOwned;
7use serde::Serialize;
8use serde_json::{Map, Value};
9
10use crate::client::Client;
11use crate::error::{Error, Result};
12use crate::models::{NewArticle, Ticket, TicketExpanded};
13
14const PATH: &str = "api/v1/tickets";
15
16/// Marker trait selecting which ticket shape (compact vs. expanded) a
17/// builder returns and whether `expand=true` is sent to Zammad.
18///
19/// Implemented for [`Ticket`] and [`TicketExpanded`]; you should not need to
20/// implement it yourself.
21pub trait TicketShape: DeserializeOwned + Send + 'static {
22    /// Value sent as the `expand` query parameter.
23    const EXPAND: bool;
24}
25
26impl TicketShape for Ticket {
27    const EXPAND: bool = false;
28}
29
30impl TicketShape for TicketExpanded {
31    const EXPAND: bool = true;
32}
33
34/// Ticket endpoints.
35#[derive(Debug)]
36pub struct TicketsApi<'a> {
37    client: &'a Client,
38}
39
40impl<'a> TicketsApi<'a> {
41    pub(crate) fn new(client: &'a Client) -> Self {
42        Self { client }
43    }
44
45    /// `GET /api/v1/tickets/:id` - return the compact [`Ticket`] shape.
46    pub async fn get(&self, id: u64) -> Result<Ticket> {
47        let req = self.client.request(Method::GET, &format!("{PATH}/{id}"));
48        self.client.send_json(req).await
49    }
50
51    /// `GET /api/v1/tickets/:id?expand=true` - return the [`TicketExpanded`]
52    /// shape with human-readable group/state/priority/customer/owner/organization names.
53    pub async fn get_expanded(&self, id: u64) -> Result<TicketExpanded> {
54        let req = self
55            .client
56            .request(Method::GET, &format!("{PATH}/{id}"))
57            .query(&[("expand", "true")]);
58        self.client.send_json(req).await
59    }
60
61    /// `GET /api/v1/tickets` - paginated list of compact tickets.
62    pub fn list(&self) -> ListTickets<'a, Ticket> {
63        ListTickets::new(self.client)
64    }
65
66    /// `GET /api/v1/tickets?expand=true` - paginated list of expanded tickets.
67    pub fn list_expanded(&self) -> ListTickets<'a, TicketExpanded> {
68        ListTickets::new(self.client)
69    }
70
71    /// `GET /api/v1/tickets/search?query=…` - Zammad query DSL, compact shape.
72    pub fn search(&self, query: impl Into<String>) -> SearchTickets<'a, Ticket> {
73        SearchTickets::new(self.client, query.into())
74    }
75
76    /// `GET /api/v1/tickets/search?query=…&expand=true` - expanded shape.
77    pub fn search_expanded(&self, query: impl Into<String>) -> SearchTickets<'a, TicketExpanded> {
78        SearchTickets::new(self.client, query.into())
79    }
80
81    /// `POST /api/v1/tickets` - build the ticket via the returned builder.
82    pub fn create(&self) -> CreateTicket<'a> {
83        CreateTicket {
84            client: self.client,
85            body: Map::new(),
86        }
87    }
88
89    /// `PUT /api/v1/tickets/:id` - accepts any [`Serialize`] patch.
90    ///
91    /// The whole request body is replaced by the serialized `patch`, so pass
92    /// either a `&serde_json::Value` for ad-hoc updates or a typed struct
93    /// you've defined yourself.
94    pub async fn update<P: Serialize + ?Sized>(&self, id: u64, patch: &P) -> Result<Ticket> {
95        let req = self
96            .client
97            .request(Method::PUT, &format!("{PATH}/{id}"))
98            .json(patch);
99        self.client.send_json(req).await
100    }
101
102    /// Set a single field (typically a custom field) and verify the value was
103    /// actually persisted server-side.
104    ///
105    /// Zammad's API will happily return `200 OK` with the unchanged ticket if
106    /// you write to a field that doesn't exist on the Ticket object. This
107    /// helper re-reads the field from the response body and surfaces
108    /// [`Error::WriteVerificationFailed`] when the round-trip didn't stick.
109    ///
110    /// `value` accepts anything convertible to a [`Value`] - strings,
111    /// numbers, booleans, arrays, ...
112    pub async fn set_field(
113        &self,
114        id: u64,
115        field: &str,
116        value: impl Into<Value>,
117    ) -> Result<Ticket> {
118        let value: Value = value.into();
119        let mut body = Map::new();
120        body.insert(field.to_string(), value.clone());
121        let updated = self.update(id, &Value::Object(body)).await?;
122        let actual = updated.extra.get(field).cloned();
123        if actual.as_ref() != Some(&value) {
124            return Err(Error::WriteVerificationFailed {
125                field: field.into(),
126                actual,
127            });
128        }
129        Ok(updated)
130    }
131
132    /// `DELETE /api/v1/tickets/:id`.
133    pub async fn delete(&self, id: u64) -> Result<()> {
134        let req = self.client.request(Method::DELETE, &format!("{PATH}/{id}"));
135        self.client.send_unit(req).await
136    }
137}
138
139/// Builder + iterator for `GET /api/v1/tickets`.
140///
141/// Generic over the response shape ([`Ticket`] or [`TicketExpanded`]); the
142/// shape is fixed at the call site by [`TicketsApi::list`] /
143/// [`TicketsApi::list_expanded`].
144#[derive(Debug)]
145pub struct ListTickets<'a, T: TicketShape = Ticket> {
146    client: &'a Client,
147    per_page: u32,
148    _t: PhantomData<fn() -> T>,
149}
150
151impl<'a, T: TicketShape> ListTickets<'a, T> {
152    fn new(client: &'a Client) -> Self {
153        Self {
154            client,
155            per_page: 100,
156            _t: PhantomData,
157        }
158    }
159
160    /// Page size (Zammad caps at 200).
161    pub fn per_page(mut self, n: u32) -> Self {
162        self.per_page = n;
163        self
164    }
165
166    /// Fetch a single page.
167    pub async fn page(&self, page: u32) -> Result<Vec<T>> {
168        let req = self.client.request(Method::GET, PATH).query(&[
169            ("page", page.to_string()),
170            ("per_page", self.per_page.to_string()),
171            ("expand", T::EXPAND.to_string()),
172        ]);
173        self.client.send_json(req).await
174    }
175
176    /// Yield every ticket, fetching pages lazily.
177    pub fn stream(self) -> BoxStream<'a, Result<T>> {
178        let client = self.client;
179        let per_page = self.per_page;
180        Box::pin(try_stream! {
181            let mut page = 1u32;
182            loop {
183                let req = client.request(Method::GET, PATH).query(&[
184                    ("page", page.to_string()),
185                    ("per_page", per_page.to_string()),
186                    ("expand", T::EXPAND.to_string()),
187                ]);
188                let chunk: Vec<T> = client.send_json(req).await?;
189                let len = chunk.len() as u32;
190                for t in chunk { yield t; }
191                if len < per_page { break; }
192                page += 1;
193            }
194        })
195    }
196}
197
198/// Builder + iterator for `GET /api/v1/tickets/search`.
199#[derive(Debug)]
200pub struct SearchTickets<'a, T: TicketShape = Ticket> {
201    client: &'a Client,
202    query: String,
203    per_page: u32,
204    sort_by: Option<String>,
205    order_by: Option<String>,
206    _t: PhantomData<fn() -> T>,
207}
208
209impl<'a, T: TicketShape> SearchTickets<'a, T> {
210    fn new(client: &'a Client, query: String) -> Self {
211        Self {
212            client,
213            query,
214            per_page: 100,
215            sort_by: None,
216            order_by: None,
217            _t: PhantomData,
218        }
219    }
220
221    /// Page size (Zammad caps at 200).
222    pub fn per_page(mut self, n: u32) -> Self {
223        self.per_page = n;
224        self
225    }
226
227    /// Field to sort by, e.g. `created_at`.
228    pub fn sort_by(mut self, s: impl Into<String>) -> Self {
229        self.sort_by = Some(s.into());
230        self
231    }
232
233    /// Sort direction: `"asc"` or `"desc"`.
234    pub fn order_by(mut self, s: impl Into<String>) -> Self {
235        self.order_by = Some(s.into());
236        self
237    }
238
239    fn build_query(&self, page: u32) -> Vec<(&'static str, String)> {
240        let mut q = vec![
241            ("query", self.query.clone()),
242            ("page", page.to_string()),
243            ("per_page", self.per_page.to_string()),
244            ("expand", T::EXPAND.to_string()),
245        ];
246        if let Some(s) = &self.sort_by {
247            q.push(("sort_by", s.clone()));
248        }
249        if let Some(s) = &self.order_by {
250            q.push(("order_by", s.clone()));
251        }
252        q
253    }
254
255    /// Fetch a single page of search results.
256    pub async fn page(&self, page: u32) -> Result<Vec<T>> {
257        let req = self
258            .client
259            .request(Method::GET, &format!("{PATH}/search"))
260            .query(&self.build_query(page));
261        self.client.send_json(req).await
262    }
263
264    /// Yield every search hit, fetching pages lazily.
265    pub fn stream(self) -> BoxStream<'a, Result<T>> {
266        let client = self.client;
267        let per_page = self.per_page;
268        Box::pin(try_stream! {
269            let mut page = 1u32;
270            loop {
271                let req = client
272                    .request(Method::GET, &format!("{PATH}/search"))
273                    .query(&self.build_query(page));
274                let chunk: Vec<T> = client.send_json(req).await?;
275                let len = chunk.len() as u32;
276                for t in chunk { yield t; }
277                if len < per_page { break; }
278                page += 1;
279            }
280        })
281    }
282}
283
284/// Builder for `POST /api/v1/tickets`.
285#[derive(Debug)]
286pub struct CreateTicket<'a> {
287    client: &'a Client,
288    body: Map<String, Value>,
289}
290
291impl<'a> CreateTicket<'a> {
292    /// Subject / title (required).
293    pub fn title(mut self, s: impl Into<String>) -> Self {
294        self.body.insert("title".into(), s.into().into());
295        self
296    }
297    /// Group name or ID (required).
298    pub fn group(mut self, s: impl Into<String>) -> Self {
299        self.body.insert("group".into(), s.into().into());
300        self
301    }
302    /// Customer email or login (required).
303    pub fn customer(mut self, s: impl Into<String>) -> Self {
304        self.body.insert("customer".into(), s.into().into());
305        self
306    }
307    /// State name or ID.
308    pub fn state(mut self, s: impl Into<String>) -> Self {
309        self.body.insert("state".into(), s.into().into());
310        self
311    }
312    /// Priority name or ID.
313    pub fn priority(mut self, s: impl Into<String>) -> Self {
314        self.body.insert("priority".into(), s.into().into());
315        self
316    }
317    /// Owner login or ID.
318    pub fn owner(mut self, s: impl Into<String>) -> Self {
319        self.body.insert("owner".into(), s.into().into());
320        self
321    }
322    /// First article - almost always required by Zammad.
323    pub fn article(mut self, a: NewArticle) -> Self {
324        self.body
325            .insert("article".into(), serde_json::to_value(a).expect("serialize article"));
326        self
327    }
328    /// Set an arbitrary field on the new ticket (custom fields, expanded
329    /// references, etc.).
330    pub fn field(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
331        self.body.insert(key.into(), value.into());
332        self
333    }
334
335    /// Submit the request.
336    pub async fn send(self) -> Result<Ticket> {
337        let req = self
338            .client
339            .request(Method::POST, PATH)
340            .json(&Value::Object(self.body));
341        self.client.send_json(req).await
342    }
343}