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 chrono::Duration;
37use reqwest::{
38    Url,
39    header::{HeaderMap, HeaderName, HeaderValue},
40};
41use serde::{Deserialize, Serialize};
42
43mod auth;
44mod builders;
45mod client;
46mod error;
47
48pub use crate::auth::Credentials;
49pub use builders::{
50    NoteBuilder, TicketClient, TicketCreateBuilder, TicketSearchBuilder, TicketStatus,
51    TicketsClient,
52};
53pub use client::{
54    Attachment, Condition, CreateTicketData, Criteria, DetailedTicket, EditTicketData, LogicalOp,
55    NameWrapper, Note, NoteData, NoteResponse, Priority, Resolution, SizeInfo, Status, TicketData,
56    TicketResponse, TimeEntry, UserInfo,
57};
58pub use error::{Error, SdpErrorCode};
59
60/// Type-safe wrapper for User ID in SDP
61#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq, Hash, Default)]
62pub struct UserID(pub String);
63
64/// Type-safe wrapper for Ticket ID in SDP
65#[derive(Clone, Debug)]
66pub struct TicketID(pub u64);
67
68/// Type-safe wrapper for Note ID in SDP
69#[derive(Clone, Debug)]
70pub struct NoteID(pub u64);
71
72impl From<u64> for NoteID {
73    fn from(value: u64) -> Self {
74        NoteID(value)
75    }
76}
77
78impl From<NoteID> for u64 {
79    fn from(value: NoteID) -> Self {
80        value.0
81    }
82}
83
84impl std::fmt::Display for NoteID {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        write!(f, "{}", self.0)
87    }
88}
89
90impl From<u64> for TicketID {
91    fn from(value: u64) -> Self {
92        TicketID(value)
93    }
94}
95
96impl From<TicketID> for u64 {
97    fn from(value: TicketID) -> Self {
98        value.0
99    }
100}
101
102impl From<&TicketID> for u64 {
103    fn from(value: &TicketID) -> Self {
104        value.0
105    }
106}
107
108impl From<&UserID> for String {
109    fn from(value: &UserID) -> Self {
110        value.0.clone()
111    }
112}
113
114impl From<String> for UserID {
115    fn from(value: String) -> Self {
116        UserID(value)
117    }
118}
119
120impl From<&str> for UserID {
121    fn from(value: &str) -> Self {
122        UserID(value.to_string())
123    }
124}
125
126impl From<u32> for UserID {
127    fn from(value: u32) -> Self {
128        UserID(value.to_string())
129    }
130}
131
132impl From<UserID> for u32 {
133    fn from(value: UserID) -> Self {
134        value.0.parse().unwrap_or_default()
135    }
136}
137
138impl std::fmt::Display for TicketID {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        write!(f, "{}", self.0)
141    }
142}
143
144impl std::fmt::Display for UserID {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        write!(f, "{}", self.0)
147    }
148}
149
150/// Main client for interacting with ServiceDesk Plus API.
151///
152/// Use [`tickets()`](Self::tickets) for search/create operations,
153/// or [`ticket(id)`](Self::ticket) for single-ticket operations.
154#[derive(Clone)]
155pub struct ServiceDesk {
156    pub base_url: Url,
157    pub credentials: Credentials,
158    inner: reqwest::Client,
159}
160
161/// Security options for the ServiceDesk client
162/// Not finished yet!!
163#[derive(Clone, Debug)]
164pub enum Security {
165    Unsafe,
166    NativeTlS,
167}
168
169/// Configuration options for the ServiceDesk client
170#[derive(Clone, Debug)]
171pub struct ServiceDeskOptions {
172    user_agent: Option<String>,
173    /// Request timeout duration
174    timeout: Option<Duration>,
175    security: Option<Security>,
176    default_headers: Option<HeaderMap>,
177}
178
179static SDP_HEADER: (HeaderName, HeaderValue) = (
180    HeaderName::from_static("accept"),
181    HeaderValue::from_static("application/vnd.manageengine.sdp.v3+json"),
182);
183
184impl Default for ServiceDeskOptions {
185    fn default() -> Self {
186        ServiceDeskOptions {
187            user_agent: Some(String::from("servicedesk-rs/0.1.0")),
188            timeout: Some(Duration::seconds(5)),
189            security: Some(Security::Unsafe),
190            default_headers: Some(HeaderMap::from_iter(vec![SDP_HEADER.clone()])),
191        }
192    }
193}
194
195impl ServiceDesk {
196    /// Create a new ServiceDesk client instance
197    pub fn new(base_url: Url, credentials: Credentials, options: ServiceDeskOptions) -> Self {
198        let mut headers = options.default_headers.unwrap_or_default();
199
200        #[allow(clippy::single_match)]
201        match credentials {
202            Credentials::Token { ref token } => {
203                headers.insert("authtoken", HeaderValue::from_str(token).unwrap());
204            }
205            _ => {}
206        }
207        let mut inner = reqwest::ClientBuilder::new()
208            .default_headers(headers)
209            .user_agent(options.user_agent.unwrap_or_default())
210            .timeout(options.timeout.unwrap_or_default().to_std().unwrap());
211
212        if let Some(security) = options.security {
213            match security {
214                Security::Unsafe => {
215                    inner = inner.danger_accept_invalid_certs(true);
216                }
217                Security::NativeTlS => {
218                    // Default behavior, do nothing
219                }
220            }
221        };
222
223        let inner = inner.build().expect("failed to build sdp client");
224
225        ServiceDesk {
226            base_url,
227            credentials,
228            inner,
229        }
230    }
231}
232
233#[cfg(test)]
234mod test {
235    use super::*;
236    use crate::client::{EditTicketData, NameWrapper};
237
238    // Fork it and test your setup by setting SDP_TEST_TOKEN and SDP_TEST_URL in a .env file
239    pub fn setup() -> ServiceDesk {
240        dotenv::dotenv().ok();
241        let token = std::env::var("SDP_TEST_TOKEN").expect("SDP_TEST_TOKEN must be set");
242        let url = std::env::var("SDP_TEST_URL").expect("SDP_TEST_URL must be set");
243
244        let creds = Credentials::Token { token };
245
246        ServiceDesk::new(
247            Url::parse(&url).unwrap(),
248            creds,
249            ServiceDeskOptions::default(),
250        )
251    }
252
253    #[tokio::test]
254    async fn builder_ticket_get() {
255        let sdp = setup();
256        let result = sdp.ticket(65997).get().await;
257        assert!(result.is_ok());
258        let ticket = result.unwrap();
259        assert_eq!(ticket.id, "65997");
260    }
261
262    #[tokio::test]
263    async fn builder_search_open_tickets() {
264        let sdp = setup();
265        let result = sdp
266            .tickets()
267            .search()
268            .open()
269            .subject_contains("First")
270            .limit(10)
271            .fetch()
272            .await;
273        assert!(result.is_ok());
274    }
275
276    #[tokio::test]
277    async fn builder_search_by_alert_id() {
278        let sdp = setup();
279        let result = sdp
280            .tickets()
281            .search()
282            .field_equals(
283                "udf_fields.udf_mline_1202",
284                "23433465d4e0ee849a49b994a27a8bbdad726686b73623aebedeef5b69ec1fb2",
285            )
286            .first()
287            .await;
288        assert!(result.is_ok());
289        let result = result.unwrap();
290        assert!(result.is_some());
291    }
292
293    #[tokio::test]
294    async fn builder_create_ticket() {
295        let sdp = setup();
296        let result = sdp
297            .tickets()
298            .create()
299            .subject("[TEST] Test Builder API")
300            .description("Created via builder pattern")
301            .requester("NETXP")
302            .priority("Low")
303            .account("SOC - NETXP")
304            .template("SOC-with-alert-id")
305            .send()
306            .await;
307        assert!(result.is_ok());
308    }
309
310    #[tokio::test]
311    async fn builder_add_note() {
312        let sdp = setup();
313        let result = sdp
314            .ticket(65997)
315            .add_note("Note added via builder API")
316            .await;
317        assert!(result.is_ok());
318    }
319
320    #[tokio::test]
321    async fn builder_note_with_options() {
322        let sdp = setup();
323        let result = sdp
324            .ticket(65997)
325            .note()
326            .description("Note with options via builder")
327            .show_to_requester()
328            .send()
329            .await;
330        assert!(result.is_ok());
331    }
332
333    #[tokio::test]
334    async fn builder_assign_ticket() {
335        let sdp = setup();
336        let result = sdp.ticket(250225).assign("Szymon Głuch").await;
337        assert!(result.is_ok());
338    }
339
340    #[tokio::test]
341    async fn builder_edit_ticket() {
342        let sdp = setup();
343        let editdata = EditTicketData {
344            subject: "Updated via builder".to_string(),
345            description: None,
346            requester: Some(NameWrapper {
347                name: "GALLUP".to_string(),
348            }),
349            priority: Some(NameWrapper {
350                name: "High".to_string(),
351            }),
352            udf_fields: None,
353        };
354
355        let result = sdp.ticket(250225).edit(&editdata).await;
356        assert!(result.is_ok());
357    }
358
359    #[tokio::test]
360    async fn builder_list_notes() {
361        let sdp = setup();
362        let result = sdp.list_notes(250225, None, None).await;
363        assert!(result.is_ok());
364        let notes = result.unwrap();
365        assert_eq!(
366            (notes[0].id.clone(), notes[1].id.clone()),
367            ("279486".to_string(), "279666".to_string())
368        )
369    }
370
371    #[tokio::test]
372    async fn builder_get_note() {
373        let sdp = setup();
374        let result = sdp.get_note(250225, 279486).await;
375        assert!(result.is_ok());
376        let note = result.unwrap();
377        assert_eq!(note.description, "<div>test note<br></div>");
378    }
379
380    #[tokio::test]
381    async fn builder_create_delete_note() {
382        let sdp = setup();
383        let create_result = sdp
384            .ticket(250225)
385            .note()
386            .description("Note to be deleted")
387            .send()
388            .await;
389        assert!(create_result.is_ok());
390        let created_note = create_result.unwrap();
391
392        let delete_result = sdp
393            .delete_note(250225, created_note.id.parse::<u64>().unwrap())
394            .await;
395        assert!(delete_result.is_ok());
396    }
397}