Skip to main content

sdp_request_client/
lib.rs

1//! Async Rust client for the ManageEngine ServiceDesk Plus REST API v3.
2//!
3//! # Quick Start
4//!
5//! ```no_run
6//! use sdp_request_client::{ServiceDesk, ServiceDeskOptions, Credentials};
7//! use reqwest::Url;
8//!
9//! # async fn example() -> Result<(), sdp_request_client::Error> {
10//! let client = ServiceDesk::new(
11//!     Url::parse("https://sdp.example.com")?,
12//!     Credentials::Token { token: "your-token".into() },
13//!     ServiceDeskOptions::default(),
14//! )?;
15//!
16//! // Search tickets
17//! let tickets = client.tickets().search().open().limit(10).fetch().await?;
18//!
19//! // Create a ticket
20//! let response = client.tickets()
21//!     .create()
22//!     .subject("Server issue")
23//!     .requester("John Doe")
24//!     .send()
25//!     .await?;
26//!
27//! // Ticket operations
28//! client.ticket(12345).add_note("Investigating...").await?;
29//! client.ticket(12345).close("Resolved").await?;
30//! # Ok(())
31//! # }
32//! ```
33//!
34//! See [`ServiceDesk`] for the main entry point.
35
36use std::time::Duration;
37
38use reqwest::{
39    Url,
40    header::{HeaderMap, HeaderName, HeaderValue},
41};
42use serde::{Deserialize, Serialize};
43
44mod auth;
45mod builders;
46mod client;
47mod error;
48
49pub use crate::auth::Credentials;
50pub use builders::{
51    NoteBuilder, TicketClient, TicketCreateBuilder, TicketSearchBuilder, TicketStatus,
52    TicketsClient,
53};
54pub use client::{
55    Account, Attachment, Condition, CreateTicketData, Criteria, DetailedTicket, EditTicketData,
56    LogicalOp, Note, NoteData, Priority, Resolution, Status, TemplateInfo, TicketData, TimeEntry,
57    UserInfo,
58};
59pub use error::Error;
60
61/// Type-safe wrapper for User ID in SDP
62#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq, Hash, Default)]
63pub struct UserID(pub String);
64
65/// Type-safe wrapper for Ticket ID in SDP
66///
67/// Deserializes from both numbers (`123`) and strings (`"123"`),
68/// since the SDP API returns IDs as strings.
69#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
70pub struct TicketID(pub u64);
71
72/// Type-safe wrapper for Note ID in SDP
73///
74/// Deserializes from both numbers (`123`) and strings (`"123"`),
75/// since the SDP API returns IDs as strings.
76#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
77pub struct NoteID(pub u64);
78
79/// Visitor that accepts either a number or a string and parses to u64.
80struct StringOrNumberU64Visitor;
81
82impl<'de> serde::de::Visitor<'de> for StringOrNumberU64Visitor {
83    type Value = u64;
84
85    fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
86        formatter.write_str("a u64 or a string containing a u64")
87    }
88
89    fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<u64, E> {
90        Ok(v)
91    }
92
93    fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<u64, E> {
94        u64::try_from(v).map_err(|_| E::custom(format!("negative id: {v}")))
95    }
96
97    fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<u64, E> {
98        v.parse::<u64>().map_err(serde::de::Error::custom)
99    }
100}
101
102impl<'de> Deserialize<'de> for TicketID {
103    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
104        deserializer
105            .deserialize_any(StringOrNumberU64Visitor)
106            .map(TicketID)
107    }
108}
109
110impl<'de> Deserialize<'de> for NoteID {
111    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
112        deserializer
113            .deserialize_any(StringOrNumberU64Visitor)
114            .map(NoteID)
115    }
116}
117
118impl From<u64> for NoteID {
119    fn from(value: u64) -> Self {
120        NoteID(value)
121    }
122}
123
124impl From<NoteID> for u64 {
125    fn from(value: NoteID) -> Self {
126        value.0
127    }
128}
129
130impl std::fmt::Display for NoteID {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        write!(f, "{}", self.0)
133    }
134}
135
136impl From<u64> for TicketID {
137    fn from(value: u64) -> Self {
138        TicketID(value)
139    }
140}
141
142impl From<TicketID> for u64 {
143    fn from(value: TicketID) -> Self {
144        value.0
145    }
146}
147
148impl From<&TicketID> for u64 {
149    fn from(value: &TicketID) -> Self {
150        value.0
151    }
152}
153
154impl From<&UserID> for String {
155    fn from(value: &UserID) -> Self {
156        value.0.clone()
157    }
158}
159
160impl From<String> for UserID {
161    fn from(value: String) -> Self {
162        UserID(value)
163    }
164}
165
166impl From<&str> for UserID {
167    fn from(value: &str) -> Self {
168        UserID(value.to_string())
169    }
170}
171
172impl From<u32> for UserID {
173    fn from(value: u32) -> Self {
174        UserID(value.to_string())
175    }
176}
177
178impl From<UserID> for u32 {
179    fn from(value: UserID) -> Self {
180        value.0.parse().unwrap_or_default()
181    }
182}
183
184impl std::fmt::Display for TicketID {
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        write!(f, "{}", self.0)
187    }
188}
189
190impl std::fmt::Display for UserID {
191    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192        write!(f, "{}", self.0)
193    }
194}
195
196/// Main client for interacting with ServiceDesk Plus API.
197///
198/// Use [`tickets()`](Self::tickets) for search/create operations,
199/// or [`ticket(id)`](Self::ticket) for single-ticket operations.
200#[derive(Clone)]
201pub struct ServiceDesk {
202    base_url: Url,
203    inner: reqwest::Client,
204}
205
206/// Security options for the ServiceDesk client
207///
208/// Not finished yet!!
209#[derive(Clone, Debug, PartialEq, Eq)]
210pub enum Security {
211    Unsafe,
212    NativeTLS,
213}
214
215/// Configuration options for the ServiceDesk client
216#[derive(Clone, Debug)]
217pub struct ServiceDeskOptions {
218    pub user_agent: Option<String>,
219    /// Request timeout duration
220    pub timeout: Option<Duration>,
221    pub security: Option<Security>,
222    pub default_headers: Option<HeaderMap>,
223}
224
225static SDP_HEADER: (HeaderName, HeaderValue) = (
226    HeaderName::from_static("accept"),
227    HeaderValue::from_static("application/vnd.manageengine.sdp.v3+json"),
228);
229
230impl Default for ServiceDeskOptions {
231    fn default() -> Self {
232        ServiceDeskOptions {
233            user_agent: Some(String::from("servicedesk-rs/0.1.0")),
234            timeout: Some(Duration::from_secs(5)),
235            security: Some(Security::Unsafe),
236            default_headers: Some(HeaderMap::from_iter(vec![SDP_HEADER.clone()])),
237        }
238    }
239}
240
241impl ServiceDesk {
242    /// Create a new ServiceDesk client instance.
243    ///
244    /// # Errors
245    ///
246    /// Returns an error if the auth token contains invalid header characters
247    /// or if the underlying HTTP client fails to build.
248    pub fn new(
249        base_url: Url,
250        credentials: Credentials,
251        options: ServiceDeskOptions,
252    ) -> Result<Self, Error> {
253        let mut headers = options.default_headers.unwrap_or_default();
254
255        if let Credentials::Token { ref token } = credentials {
256            let value = HeaderValue::from_str(token)
257                .map_err(|e| Error::Other(format!("invalid auth token header value: {e}")))?;
258            headers.insert("authtoken", value);
259        }
260
261        let mut builder = reqwest::ClientBuilder::new()
262            .default_headers(headers)
263            .user_agent(options.user_agent.unwrap_or_default())
264            .timeout(options.timeout.unwrap_or_else(|| Duration::from_secs(5)));
265
266        if let Some(security) = options.security {
267            match security {
268                Security::Unsafe => {
269                    builder = builder.danger_accept_invalid_certs(true);
270                }
271                Security::NativeTLS => {}
272            }
273        }
274
275        let inner = builder
276            .build()
277            .map_err(|e| Error::Other(format!("failed to build HTTP client: {e}")))?;
278
279        Ok(ServiceDesk { base_url, inner })
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn service_desk_options_default() {
289        let opts = ServiceDeskOptions::default();
290        assert_eq!(opts.user_agent, Some("servicedesk-rs/0.1.0".to_string()));
291        assert_eq!(opts.timeout, Some(Duration::from_secs(5)));
292        assert!(matches!(opts.security, Some(Security::Unsafe)));
293        assert!(opts.default_headers.is_some());
294    }
295}