zammad-api 0.4.0

Async, idiomatic Rust client for the Zammad Service Platform
Documentation
use std::marker::PhantomData;

use async_stream::try_stream;
use futures_core::stream::BoxStream;
use reqwest::Method;
use serde::de::DeserializeOwned;
use serde::Serialize;
use serde_json::{Map, Value};

use crate::client::Client;
use crate::error::{Error, Result};
use crate::models::{NewArticle, Ticket, TicketExpanded};

const PATH: &str = "api/v1/tickets";

/// Marker trait selecting which ticket shape (compact vs. expanded) a
/// builder returns and whether `expand=true` is sent to Zammad.
///
/// Implemented for [`Ticket`] and [`TicketExpanded`]; you should not need to
/// implement it yourself.
pub trait TicketShape: DeserializeOwned + Send + 'static {
    /// Value sent as the `expand` query parameter.
    const EXPAND: bool;
}

impl TicketShape for Ticket {
    const EXPAND: bool = false;
}

impl TicketShape for TicketExpanded {
    const EXPAND: bool = true;
}

/// Ticket endpoints.
#[derive(Debug)]
pub struct TicketsApi<'a> {
    client: &'a Client,
}

impl<'a> TicketsApi<'a> {
    pub(crate) fn new(client: &'a Client) -> Self {
        Self { client }
    }

    /// `GET /api/v1/tickets/:id` - return the compact [`Ticket`] shape.
    pub async fn get(&self, id: u64) -> Result<Ticket> {
        let req = self.client.request(Method::GET, &format!("{PATH}/{id}"));
        self.client.send_json(req).await
    }

    /// `GET /api/v1/tickets/:id?expand=true` - return the [`TicketExpanded`]
    /// shape with human-readable group/state/priority/customer/owner/organization names.
    pub async fn get_expanded(&self, id: u64) -> Result<TicketExpanded> {
        let req = self
            .client
            .request(Method::GET, &format!("{PATH}/{id}"))
            .query(&[("expand", "true")]);
        self.client.send_json(req).await
    }

    /// `GET /api/v1/tickets` - paginated list of compact tickets.
    pub fn list(&self) -> ListTickets<'a, Ticket> {
        ListTickets::new(self.client)
    }

    /// `GET /api/v1/tickets?expand=true` - paginated list of expanded tickets.
    pub fn list_expanded(&self) -> ListTickets<'a, TicketExpanded> {
        ListTickets::new(self.client)
    }

    /// `GET /api/v1/tickets/search?query=…` - Zammad query DSL, compact shape.
    pub fn search(&self, query: impl Into<String>) -> SearchTickets<'a, Ticket> {
        SearchTickets::new(self.client, query.into())
    }

    /// `GET /api/v1/tickets/search?query=…&expand=true` - expanded shape.
    pub fn search_expanded(&self, query: impl Into<String>) -> SearchTickets<'a, TicketExpanded> {
        SearchTickets::new(self.client, query.into())
    }

    /// `POST /api/v1/tickets` - build the ticket via the returned builder.
    pub fn create(&self) -> CreateTicket<'a> {
        CreateTicket {
            client: self.client,
            body: Map::new(),
        }
    }

    /// `PUT /api/v1/tickets/:id` - accepts any [`Serialize`] patch.
    ///
    /// The whole request body is replaced by the serialized `patch`, so pass
    /// either a `&serde_json::Value` for ad-hoc updates or a typed struct
    /// you've defined yourself.
    pub async fn update<P: Serialize + ?Sized>(&self, id: u64, patch: &P) -> Result<Ticket> {
        let req = self
            .client
            .request(Method::PUT, &format!("{PATH}/{id}"))
            .json(patch);
        self.client.send_json(req).await
    }

    /// Set a single field (typically a custom field) and verify the value was
    /// actually persisted server-side.
    ///
    /// Zammad's API will happily return `200 OK` with the unchanged ticket if
    /// you write to a field that doesn't exist on the Ticket object. This
    /// helper re-reads the field from the response body and surfaces
    /// [`Error::WriteVerificationFailed`] when the round-trip didn't stick.
    ///
    /// `value` accepts anything convertible to a [`Value`] - strings,
    /// numbers, booleans, arrays, ...
    pub async fn set_field(
        &self,
        id: u64,
        field: &str,
        value: impl Into<Value>,
    ) -> Result<Ticket> {
        let value: Value = value.into();
        let mut body = Map::new();
        body.insert(field.to_string(), value.clone());
        let updated = self.update(id, &Value::Object(body)).await?;
        let actual = updated.extra.get(field).cloned();
        if actual.as_ref() != Some(&value) {
            return Err(Error::WriteVerificationFailed {
                field: field.into(),
                actual,
            });
        }
        Ok(updated)
    }

    /// `DELETE /api/v1/tickets/:id`.
    pub async fn delete(&self, id: u64) -> Result<()> {
        let req = self.client.request(Method::DELETE, &format!("{PATH}/{id}"));
        self.client.send_unit(req).await
    }
}

/// Builder + iterator for `GET /api/v1/tickets`.
///
/// Generic over the response shape ([`Ticket`] or [`TicketExpanded`]); the
/// shape is fixed at the call site by [`TicketsApi::list`] /
/// [`TicketsApi::list_expanded`].
#[derive(Debug)]
pub struct ListTickets<'a, T: TicketShape = Ticket> {
    client: &'a Client,
    per_page: u32,
    _t: PhantomData<fn() -> T>,
}

impl<'a, T: TicketShape> ListTickets<'a, T> {
    fn new(client: &'a Client) -> Self {
        Self {
            client,
            per_page: 100,
            _t: PhantomData,
        }
    }

    /// Page size (Zammad caps at 200).
    pub fn per_page(mut self, n: u32) -> Self {
        self.per_page = n;
        self
    }

    /// Fetch a single page.
    pub async fn page(&self, page: u32) -> Result<Vec<T>> {
        let req = self.client.request(Method::GET, PATH).query(&[
            ("page", page.to_string()),
            ("per_page", self.per_page.to_string()),
            ("expand", T::EXPAND.to_string()),
        ]);
        self.client.send_json(req).await
    }

    /// Yield every ticket, fetching pages lazily.
    pub fn stream(self) -> BoxStream<'a, Result<T>> {
        let client = self.client;
        let per_page = self.per_page;
        Box::pin(try_stream! {
            let mut page = 1u32;
            loop {
                let req = client.request(Method::GET, PATH).query(&[
                    ("page", page.to_string()),
                    ("per_page", per_page.to_string()),
                    ("expand", T::EXPAND.to_string()),
                ]);
                let chunk: Vec<T> = client.send_json(req).await?;
                let len = chunk.len() as u32;
                for t in chunk { yield t; }
                if len < per_page { break; }
                page += 1;
            }
        })
    }
}

/// Builder + iterator for `GET /api/v1/tickets/search`.
#[derive(Debug)]
pub struct SearchTickets<'a, T: TicketShape = Ticket> {
    client: &'a Client,
    query: String,
    per_page: u32,
    sort_by: Option<String>,
    order_by: Option<String>,
    _t: PhantomData<fn() -> T>,
}

impl<'a, T: TicketShape> SearchTickets<'a, T> {
    fn new(client: &'a Client, query: String) -> Self {
        Self {
            client,
            query,
            per_page: 100,
            sort_by: None,
            order_by: None,
            _t: PhantomData,
        }
    }

    /// Page size (Zammad caps at 200).
    pub fn per_page(mut self, n: u32) -> Self {
        self.per_page = n;
        self
    }

    /// Field to sort by, e.g. `created_at`.
    pub fn sort_by(mut self, s: impl Into<String>) -> Self {
        self.sort_by = Some(s.into());
        self
    }

    /// Sort direction: `"asc"` or `"desc"`.
    pub fn order_by(mut self, s: impl Into<String>) -> Self {
        self.order_by = Some(s.into());
        self
    }

    fn build_query(&self, page: u32) -> Vec<(&'static str, String)> {
        let mut q = vec![
            ("query", self.query.clone()),
            ("page", page.to_string()),
            ("per_page", self.per_page.to_string()),
            ("expand", T::EXPAND.to_string()),
        ];
        if let Some(s) = &self.sort_by {
            q.push(("sort_by", s.clone()));
        }
        if let Some(s) = &self.order_by {
            q.push(("order_by", s.clone()));
        }
        q
    }

    /// Fetch a single page of search results.
    pub async fn page(&self, page: u32) -> Result<Vec<T>> {
        let req = self
            .client
            .request(Method::GET, &format!("{PATH}/search"))
            .query(&self.build_query(page));
        self.client.send_json(req).await
    }

    /// Yield every search hit, fetching pages lazily.
    pub fn stream(self) -> BoxStream<'a, Result<T>> {
        let client = self.client;
        let per_page = self.per_page;
        Box::pin(try_stream! {
            let mut page = 1u32;
            loop {
                let req = client
                    .request(Method::GET, &format!("{PATH}/search"))
                    .query(&self.build_query(page));
                let chunk: Vec<T> = client.send_json(req).await?;
                let len = chunk.len() as u32;
                for t in chunk { yield t; }
                if len < per_page { break; }
                page += 1;
            }
        })
    }
}

/// Builder for `POST /api/v1/tickets`.
#[derive(Debug)]
pub struct CreateTicket<'a> {
    client: &'a Client,
    body: Map<String, Value>,
}

impl<'a> CreateTicket<'a> {
    /// Subject / title (required).
    pub fn title(mut self, s: impl Into<String>) -> Self {
        self.body.insert("title".into(), s.into().into());
        self
    }
    /// Group name or ID (required).
    pub fn group(mut self, s: impl Into<String>) -> Self {
        self.body.insert("group".into(), s.into().into());
        self
    }
    /// Customer email or login (required).
    pub fn customer(mut self, s: impl Into<String>) -> Self {
        self.body.insert("customer".into(), s.into().into());
        self
    }
    /// State name or ID.
    pub fn state(mut self, s: impl Into<String>) -> Self {
        self.body.insert("state".into(), s.into().into());
        self
    }
    /// Priority name or ID.
    pub fn priority(mut self, s: impl Into<String>) -> Self {
        self.body.insert("priority".into(), s.into().into());
        self
    }
    /// Owner login or ID.
    pub fn owner(mut self, s: impl Into<String>) -> Self {
        self.body.insert("owner".into(), s.into().into());
        self
    }
    /// First article - almost always required by Zammad.
    pub fn article(mut self, a: NewArticle) -> Self {
        self.body
            .insert("article".into(), serde_json::to_value(a).expect("serialize article"));
        self
    }
    /// Set an arbitrary field on the new ticket (custom fields, expanded
    /// references, etc.).
    pub fn field(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
        self.body.insert(key.into(), value.into());
        self
    }

    /// Submit the request.
    pub async fn send(self) -> Result<Ticket> {
        let req = self
            .client
            .request(Method::POST, PATH)
            .json(&Value::Object(self.body));
        self.client.send_json(req).await
    }
}