Skip to main content

sdp_request_client/
client.rs

1use std::collections::HashSet;
2
3use reqwest::Method;
4use serde::{Deserializer, Serialize, Serializer, de::DeserializeOwned, ser::SerializeStruct};
5
6#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
7pub struct InnerResponseMessage {
8    status_code: u32,
9    #[serde(rename = "type")]
10    type_field: String,
11    message: String,
12}
13
14/// Generic SDP response status structure
15/// Used to parse error responses from the SDP API since SDP uses a non-standard error response format
16/// including weird status codes. Partially they are converted to proper HTTP status codes by Error
17/// conversion.
18#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
19pub struct SdpResponseStatus {
20    pub status_code: u32,
21    pub messages: Option<Vec<InnerResponseMessage>>,
22    pub status: String,
23}
24
25impl SdpResponseStatus {
26    /// Convert SDP response status to an Error
27    pub fn into_error(self) -> Error {
28        // Try to get the most specific error code and message from messages array
29        if let Some(messages) = &self.messages
30            && let Some(msg) = messages.first()
31        {
32            return Error::from_sdp(msg.status_code, msg.message.clone(), None);
33        }
34        // Fallback to top-level status code
35        Error::from_sdp(self.status_code, self.status, None)
36    }
37}
38
39#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
40struct SdpGenericResponse {
41    response_status: SdpResponseStatus,
42}
43
44impl ServiceDesk {
45    pub(crate) async fn request_json<T, R>(
46        &self,
47        method: Method,
48        path: &str,
49        body: &T,
50    ) -> Result<R, Error>
51    where
52        T: Serialize + ?Sized + std::fmt::Debug,
53        R: DeserializeOwned,
54    {
55        let url = self.base_url.join(path)?;
56        let request_builder = self.inner.request(method, url).json(body);
57
58        let response = self.inner.execute(request_builder.build()?).await?;
59        if response.error_for_status_ref().is_err() {
60            let error = response.json::<SdpGenericResponse>().await?;
61            tracing::error!(error = ?error, "SDP Error Response");
62            return Err(error.response_status.into_error());
63        }
64
65        let parsed = response.json::<R>().await?;
66        tracing::debug!("completed sdp request");
67        Ok(parsed)
68    }
69
70    pub(crate) async fn request_form<T, R>(
71        &self,
72        method: Method,
73        path: &str,
74        body: &T,
75    ) -> Result<R, Error>
76    where
77        T: Serialize + ?Sized + std::fmt::Debug,
78        R: DeserializeOwned,
79    {
80        let url = self.base_url.join(path)?;
81
82        let request_builder = self
83            .inner
84            .request(method, url)
85            .form(&[("input_data", serde_json::to_string(body)?)]);
86
87        let response = self.inner.execute(request_builder.build()?).await?;
88        if response.error_for_status_ref().is_err() {
89            let error = response.json::<SdpGenericResponse>().await?;
90            tracing::error!(error = ?error, "SDP Error Response");
91            return Err(error.response_status.into_error());
92        }
93
94        let parsed = response.json::<R>().await?;
95        tracing::debug!("completed sdp request");
96        Ok(parsed)
97    }
98
99    pub(crate) async fn request_input_data<T, R>(
100        &self,
101        method: Method,
102        path: &str,
103        body: &T,
104    ) -> Result<R, Error>
105    where
106        T: Serialize + ?Sized + std::fmt::Debug,
107        R: DeserializeOwned,
108    {
109        let url = self.base_url.join(path)?;
110
111        let request_builder = self
112            .inner
113            .request(method, url)
114            .header("Content-Type", "application/x-www-form-urlencoded")
115            .query(&[("input_data", serde_json::to_string(body)?)]);
116
117        let response = self.inner.execute(request_builder.build()?).await?;
118        if response.error_for_status_ref().is_err() {
119            let error = response.json::<SdpGenericResponse>().await?;
120            tracing::error!(error = ?error, "SDP Error Response");
121            return Err(error.response_status.into_error());
122        }
123        let result = response.json::<R>().await?;
124        tracing::debug!("completed sdp request");
125        Ok(result)
126    }
127
128    async fn request<T, R>(
129        &self,
130        method: Method,
131        path: &str,
132        path_parameter: &T,
133    ) -> Result<R, Error>
134    where
135        T: std::fmt::Display,
136        R: DeserializeOwned,
137    {
138        let url = self
139            .base_url
140            .join(path)?
141            .join(&path_parameter.to_string())?;
142
143        let request_builder = self.inner.request(method, url);
144        let response = self.inner.execute(request_builder.build()?).await?;
145        if response.error_for_status_ref().is_err() {
146            let error = response.json::<SdpGenericResponse>().await.map_err(|e| {
147                tracing::error!(error = ?e, "Failed to parse SDP error response");
148                Error::from_sdp(
149                    500,
150                    "Failed to parse SDP error response".to_string(),
151                    Some(e.to_string()),
152                )
153            })?;
154            tracing::error!(error = ?error, "SDP Error Response");
155            return Err(error.response_status.into_error());
156        }
157
158        let response = response.json::<R>().await.map_err(|e| {
159            tracing::error!(error = ?e, "Failed to parse SDP response");
160            Error::from_sdp(
161                500,
162                "Failed to parse SDP response".to_string(),
163                Some(e.to_string()),
164            )
165        })?;
166
167        tracing::debug!("completed sdp request");
168        Ok(response)
169    }
170
171    async fn request_with_path<R>(&self, method: Method, path: &str) -> Result<R, Error>
172    where
173        R: DeserializeOwned,
174    {
175        let url = self.base_url.join(path)?;
176
177        let request_builder = self.inner.request(method, url);
178        let response = self.inner.execute(request_builder.build()?).await?;
179        if response.error_for_status_ref().is_err() {
180            let error = response.json::<SdpGenericResponse>().await.map_err(|e| {
181                tracing::error!(error = ?e, "Failed to parse SDP error response");
182                Error::from_sdp(
183                    500,
184                    "Failed to parse SDP error response".to_string(),
185                    Some(e.to_string()),
186                )
187            })?;
188            tracing::error!(error = ?error, "SDP Error Response");
189            return Err(error.response_status.into_error());
190        }
191
192        let parsed = response.json::<R>().await?;
193        tracing::debug!("completed sdp request");
194        Ok(parsed)
195    }
196
197    pub async fn ticket_details(
198        &self,
199        ticket_id: impl Into<TicketID>,
200    ) -> Result<DetailedTicket, Error> {
201        let ticket_id = ticket_id.into();
202        tracing::info!(ticket_id = %ticket_id, "fetching ticket details");
203        let resp: DetailedTicketResponse = self
204            .request(Method::GET, "/api/v3/requests/", &ticket_id)
205            .await?;
206        Ok(resp.request)
207    }
208
209    pub async fn get_conversations(&self, ticket_id: impl Into<TicketID>) -> Result<Value, Error> {
210        let ticket_id = ticket_id.into();
211        tracing::info!(ticket_id = %ticket_id, "fetching ticket details");
212        let path = format!("/api/v3/requests/{}/conversations", &ticket_id);
213        let resp: Value = self.request_with_path(Method::GET, &path).await?;
214        Ok(resp)
215    }
216
217    async fn get_conversations_typed(
218        &self,
219        ticket_id: impl Into<TicketID>,
220    ) -> Result<ConversationsResponse, Error> {
221        let ticket_id = ticket_id.into();
222        tracing::info!(ticket_id = %ticket_id, "fetching ticket conversations");
223        let path = format!("/api/v3/requests/{}/conversations", &ticket_id);
224        self.request_with_path(Method::GET, &path).await
225    }
226
227    pub async fn get_conversation_content(&self, content_url: &str) -> Result<Value, Error> {
228        tracing::info!(content_url = %content_url, "fetching conversation content");
229        let resp: Value = self.request_with_path(Method::GET, content_url).await?;
230        Ok(resp)
231    }
232
233    async fn get_conversation_attachments(
234        &self,
235        content_url: &str,
236    ) -> Result<Vec<Attachment>, Error> {
237        tracing::info!(content_url = %content_url, "fetching conversation attachments");
238        let resp: Value = self.request_with_path(Method::GET, content_url).await?;
239        let attachment: Vec<Attachment> = serde_json::from_value(
240            resp.get("notification")
241                .unwrap_or(&Value::Null)
242                .get("attachments")
243                .cloned()
244                .unwrap_or_default(),
245        )?;
246        Ok(attachment)
247    }
248
249    /// List ticket IDs that were merged into the given parent ticket.
250    ///
251    /// SDP exposes merge events only as MERGE entries in the parent's conversation
252    /// history, there is no dedicated read endpoint. Each MERGE entry carries a
253    /// `merged_request_id` that identifies the absorbed child.
254    pub async fn merged_ticket_ids(
255        &self,
256        ticket_id: impl Into<TicketID>,
257    ) -> Result<Vec<TicketID>, Error> {
258        let ticket_id = ticket_id.into();
259        let conversations = self.get_conversations_typed(ticket_id).await?;
260        let mut merged = Vec::new();
261        for conversation in conversations.conversations {
262            let Some(content_url) = conversation.content_url.as_deref() else {
263                continue;
264            };
265            let body: Value = self.request_with_path(Method::GET, content_url).await?;
266            let notification = &body["notification"];
267            if notification["notification_history"]["operation"] != "MERGE" {
268                continue;
269            }
270            if let Some(id) = notification["merged_request_id"].as_str()
271                && let Ok(id) = id.parse::<u64>()
272            {
273                merged.push(TicketID(id));
274            }
275        }
276        Ok(merged)
277    }
278
279    pub async fn get_conversation_attachment_urls(
280        &self,
281        ticket_id: impl Into<TicketID>,
282    ) -> Result<Vec<String>, Error> {
283        let conversations = self.get_conversations_typed(ticket_id).await?;
284        let mut links = HashSet::new();
285
286        for conversation in conversations.conversations {
287            if !conversation.has_attachments {
288                continue;
289            }
290
291            let Some(content_url) = conversation.content_url.as_deref() else {
292                continue;
293            };
294
295            let attachments = self.get_conversation_attachments(content_url).await?;
296            for attachment in attachments {
297                links.insert(normalize_attachment_url(
298                    &self.base_url,
299                    &attachment.content_url,
300                )?);
301            }
302        }
303
304        let mut links: Vec<String> = links.into_iter().collect();
305        links.sort();
306        Ok(links)
307    }
308
309    pub async fn download_attachment(&self, attachment_url: &str) -> Result<Vec<u8>, Error> {
310        tracing::info!(attachment_url = %attachment_url, "downloading attachment");
311        let url = self.base_url.join(attachment_url)?;
312        let response = self.inner.get(url).send().await?;
313        if response.error_for_status_ref().is_err() {
314            let error = response.json::<SdpGenericResponse>().await.map_err(|e| {
315                tracing::error!(error = ?e, "Failed to parse SDP error response");
316                Error::from_sdp(
317                    500,
318                    "Failed to parse SDP error response".to_string(),
319                    Some(e.to_string()),
320                )
321            })?;
322            tracing::error!(error = ?error, "SDP Error Response");
323            return Err(error.response_status.into_error());
324        }
325        let bytes = response.bytes().await?;
326        Ok(bytes.to_vec())
327    }
328
329    /// Edit an existing ticket.
330    ///
331    /// # Important
332    /// Read `EditTicketData` documentation for details on how the editing works and how to use it.
333    pub async fn edit(
334        &self,
335        ticket_id: impl Into<TicketID>,
336        data: &EditTicketData,
337    ) -> Result<(), Error> {
338        let ticket_id = ticket_id.into();
339        tracing::info!(ticket_id = %ticket_id, "editing ticket");
340        let _: SdpGenericResponse = self
341            .request_input_data(
342                Method::PUT,
343                &format!("/api/v3/requests/{}", ticket_id),
344                &EditTicketRequest { request: data },
345            )
346            .await?;
347        Ok(())
348    }
349
350    /// Add a note to a ticket (creates a new note).
351    pub async fn add_note(
352        &self,
353        ticket_id: impl Into<TicketID>,
354        note: &NoteData,
355    ) -> Result<Note, Error> {
356        let ticket_id = ticket_id.into();
357        tracing::info!(ticket_id = %ticket_id, "adding note");
358        let resp: NoteResponse = self
359            .request_input_data(
360                Method::POST,
361                &format!("/api/v3/requests/{}/notes", ticket_id),
362                &AddNoteRequest { note },
363            )
364            .await?;
365        Ok(resp.note)
366    }
367
368    pub async fn add_worklog(
369        &self,
370        ticket_id: impl Into<TicketID>,
371        worklog: &WorklogData,
372    ) -> Result<Value, Error> {
373        let ticket_id = ticket_id.into();
374        tracing::info!(ticket_id = %ticket_id, "adding worklog");
375        let resp: Value = self
376            .request_input_data(
377                Method::POST,
378                &format!("/api/v3/requests/{}/worklogs", ticket_id),
379                &AddWorklogRequest { worklog },
380            )
381            .await?;
382        Ok(resp)
383    }
384
385    /// Get a specific note from a ticket.
386    pub async fn get_note(
387        &self,
388        ticket_id: impl Into<TicketID>,
389        note_id: impl Into<NoteID>,
390    ) -> Result<Note, Error> {
391        let ticket_id = ticket_id.into();
392        let note_id = note_id.into();
393        tracing::info!(ticket_id = %ticket_id, note_id = %note_id, "fetching note");
394        let url = format!("/api/v3/requests/{}/notes/{}", ticket_id, note_id);
395        let resp: NoteResponse = self.request(Method::GET, &url, &"").await?;
396        Ok(resp.note)
397    }
398
399    /// List all notes for a ticket.
400    pub async fn list_notes(
401        &self,
402        ticket_id: impl Into<TicketID>,
403        row_count: Option<u32>,
404        start_index: Option<u32>,
405    ) -> Result<Vec<Note>, Error> {
406        let ticket_id = ticket_id.into();
407        tracing::info!(ticket_id = %ticket_id, "listing notes");
408        let body = ListNotesRequest {
409            list_info: NotesListInfo {
410                row_count: row_count.unwrap_or(100),
411                start_index: start_index.unwrap_or(1),
412            },
413        };
414        let resp: Value = self
415            .request_input_data(
416                Method::GET,
417                &format!("/api/v3/requests/{}/notes", ticket_id),
418                &body,
419            )
420            .await?;
421        let resp: NotesListResponse = serde_json::from_value(resp)?;
422        Ok(resp.notes)
423    }
424
425    /// Edit an existing note.
426    pub async fn edit_note(
427        &self,
428        ticket_id: impl Into<TicketID>,
429        note_id: impl Into<NoteID>,
430        note: &NoteData,
431    ) -> Result<Note, Error> {
432        let ticket_id = ticket_id.into();
433        let note_id = note_id.into();
434        tracing::info!(ticket_id = %ticket_id, note_id = %note_id, "editing note");
435        let resp: NoteResponse = self
436            .request_input_data(
437                Method::PUT,
438                &format!("/api/v3/requests/{}/notes/{}", ticket_id, note_id),
439                &EditNoteRequest { request_note: note },
440            )
441            .await?;
442        Ok(resp.note)
443    }
444
445    /// Delete a note from a ticket.
446    pub async fn delete_note(
447        &self,
448        ticket_id: impl Into<TicketID>,
449        note_id: impl Into<NoteID>,
450    ) -> Result<(), Error> {
451        let ticket_id = ticket_id.into();
452        let note_id = note_id.into();
453        tracing::info!(ticket_id = %ticket_id, note_id = %note_id, "deleting note");
454        let _: SdpGenericResponse = self
455            .request(
456                Method::DELETE,
457                &format!("/api/v3/requests/{}/notes/{}", ticket_id, note_id),
458                &"",
459            )
460            .await?;
461        Ok(())
462    }
463
464    /// Assign a ticket to a technician.
465    pub async fn assign_ticket(
466        &self,
467        ticket_id: impl Into<TicketID>,
468        technician_name: &str,
469    ) -> Result<(), Error> {
470        let ticket_id = ticket_id.into();
471        tracing::info!(ticket_id = %ticket_id, technician = %technician_name, "assigning ticket");
472        let _: SdpGenericResponse = self
473            .request_input_data(
474                Method::PUT,
475                &format!("/api/v3/requests/{}/assign", ticket_id),
476                &AssignTicketRequest {
477                    request: AssignTicketData {
478                        technician: technician_name.to_string(),
479                    },
480                },
481            )
482            .await?;
483        Ok(())
484    }
485
486    /// Create a new ticket.
487    pub async fn create_ticket(&self, data: &CreateTicketData) -> Result<TicketData, Error> {
488        tracing::info!(subject = %data.subject, "creating ticket");
489        let resp: TicketResponse = self
490            .request_input_data(
491                Method::POST,
492                "/api/v3/requests",
493                &CreateTicketRequest { request: data },
494            )
495            .await?;
496        Ok(resp.request)
497    }
498
499    /// Search for tickets based on specified criteria.
500    ///
501    /// The criteria can be built using the `Criteria` struct.
502    /// The default method of querying is not straightforward,
503    /// [`Criteria`] struct on the 'root' level contains a single condition, to combine multiple conditions
504    /// use the 'children' field with appropriate `LogicalOp`.
505    pub async fn search_tickets(&self, criteria: Criteria) -> Result<Vec<DetailedTicket>, Error> {
506        tracing::info!("searching tickets");
507        let resp = self
508            .request_input_data(
509                Method::GET,
510                "/api/v3/requests",
511                &SearchRequest {
512                    list_info: ListInfo {
513                        row_count: 100,
514                        search_criteria: criteria,
515                    },
516                },
517            )
518            .await?;
519
520        let ticket_response: TicketSearchResponse = serde_json::from_value(resp)?;
521
522        Ok(ticket_response.requests)
523    }
524
525    /// Close a ticket with closure comments.
526    pub async fn close_ticket(
527        &self,
528        ticket_id: impl Into<TicketID>,
529        closure_comments: &str,
530    ) -> Result<(), Error> {
531        let ticket_id = ticket_id.into();
532        tracing::info!(ticket_id = %ticket_id, "closing ticket");
533        let _: SdpGenericResponse = self
534            .request_json(
535                Method::PUT,
536                &format!("/api/v3/requests/{}/close", ticket_id),
537                &CloseTicketRequest {
538                    request: CloseTicketData {
539                        closure_info: ClosureInfo {
540                            closure_comments: closure_comments.to_string(),
541                            closure_code: "Closed".to_string(),
542                        },
543                    },
544                },
545            )
546            .await?;
547        Ok(())
548    }
549
550    /// Merge multiple tickets into a single ticket.
551    /// Key point to note is that the maximum number of tickets that can be merged at once is 49 +
552    /// 1 (the target ticket), so the `merge_ids` slice must not exceed 49 IDs.
553    pub async fn merge(
554        &self,
555        ticket_id: impl Into<TicketID>,
556        merge_ids: &[TicketID],
557    ) -> Result<(), Error> {
558        let ticket_id = ticket_id.into();
559        tracing::info!(ticket_id = %ticket_id, count = merge_ids.len(), "merging tickets");
560        if merge_ids.len() > 49 {
561            tracing::warn!("attempted to merge more than 49 tickets");
562            return Err(Error::from_sdp(
563                400,
564                "Cannot merge more than 49 tickets at once".to_string(),
565                None,
566            ));
567        }
568        let merge_requests: Vec<MergeRequestId> = merge_ids
569            .iter()
570            .map(|id| MergeRequestId {
571                id: id.0.to_string(),
572            })
573            .collect();
574
575        let _: SdpGenericResponse = self
576            .request_form(
577                Method::PUT,
578                &format!("/api/v3/requests/{}/merge_requests", ticket_id),
579                &MergeTicketsRequest { merge_requests },
580            )
581            .await?;
582        Ok(())
583    }
584}
585
586use serde::Deserialize;
587use serde_json::Value;
588
589use crate::builders::WorklogData;
590use crate::{NoteID, ServiceDesk, TicketID, UserID, error::Error};
591
592#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
593pub(crate) struct SearchRequest {
594    pub(crate) list_info: ListInfo,
595}
596
597#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
598pub struct ListInfo {
599    pub row_count: u32,
600    pub search_criteria: Criteria,
601}
602
603/// Criteria structure for building search queries.
604/// This structure allows for complex nested criteria using logical operators.
605/// The inner field, condition, and value define a single search condition.
606/// The children field allows for nesting additional criteria, combined using the specified logical operator.
607#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
608pub struct Criteria {
609    pub field: String,
610    pub condition: Condition,
611    pub value: Value,
612
613    #[serde(skip_serializing_if = "Vec::is_empty")]
614    pub children: Vec<Criteria>,
615
616    #[serde(skip_serializing_if = "Option::is_none")]
617    pub logical_operator: Option<LogicalOp>,
618}
619
620impl Default for Criteria {
621    fn default() -> Self {
622        Criteria {
623            field: String::new(),
624            condition: Condition::Is,
625            value: Value::Null,
626            children: vec![],
627            logical_operator: None,
628        }
629    }
630}
631
632/// Condition enum for specifying search conditions in criteria.
633/// Used in the Criteria struct to define how to compare field values.
634#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
635#[serde(rename_all = "snake_case")]
636pub enum Condition {
637    #[serde(rename = "is")]
638    Is,
639    #[serde(rename = "greater than")]
640    GreaterThan,
641    #[serde(rename = "lesser than")]
642    LesserThan,
643    #[serde(rename = "contains")]
644    Contains,
645}
646
647/// Logical operators for combining multiple criteria.
648#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
649pub enum LogicalOp {
650    #[serde(rename = "AND")]
651    And,
652    #[serde(rename = "OR")]
653    Or,
654}
655
656#[derive(Deserialize, Serialize, Debug, PartialEq)]
657pub struct TicketSearchResponse {
658    pub requests: Vec<DetailedTicket>,
659}
660
661#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
662pub struct Account {
663    pub id: String,
664    pub name: String,
665}
666
667#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
668struct DetailedTicketResponse {
669    request: DetailedTicket,
670    #[serde(skip_serializing)]
671    response_status: ResponseStatus,
672}
673
674#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
675#[serde(rename = "request")]
676pub struct DetailedTicket {
677    pub id: TicketID,
678    pub subject: String,
679    pub description: Option<String>,
680    pub status: Status,
681    pub priority: Option<Priority>,
682    pub requester: Option<UserInfo>,
683    pub technician: Option<UserInfo>,
684    #[serde(skip_serializing)]
685    pub created_by: UserInfo,
686    pub created_time: TimeEntry,
687    pub resolution: Option<Resolution>,
688    pub due_by_time: Option<TimeEntry>,
689    pub resolved_time: Option<TimeEntry>,
690    pub completed_time: Option<TimeEntry>,
691    pub udf_fields: Option<Value>,
692    pub attachments: Option<Vec<Attachment>>,
693    pub closure_info: Option<Value>,
694    pub site: Option<Value>,
695    pub department: Option<Value>,
696    pub account: Option<Value>,
697}
698
699#[derive(Serialize, Debug)]
700struct EditTicketRequest<'a> {
701    request: &'a EditTicketData,
702}
703
704/// Data structure for editing a ticket.
705/// Contains fields that WILL be updated on the associated ticket.
706/// For some reason SDP does not provide simple API to patch a single attribute of a ticket,
707/// instead it requires sending a PUT that will replace all of the fields even None ones,
708/// which will be treated as empty values and overwrite existing data.
709///
710/// To conveniently use this API I'd recommend to use `From<DetailedTicket>` implementation for this struct.
711#[derive(Serialize, Deserialize, Debug, PartialEq)]
712pub struct EditTicketData {
713    pub subject: String,
714    pub status: Status,
715    pub description: Option<String>,
716    pub requester: Option<UserInfo>,
717    pub priority: Option<Priority>,
718    /// Dynamically defined template fields
719    pub udf_fields: Option<Value>,
720}
721
722impl From<DetailedTicket> for EditTicketData {
723    fn from(value: DetailedTicket) -> Self {
724        Self {
725            subject: value.subject,
726            status: value.status,
727            description: value.description,
728            requester: value.requester,
729            priority: value.priority,
730            udf_fields: value.udf_fields,
731        }
732    }
733}
734
735#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
736pub(crate) struct ResponseStatus {
737    pub(crate) status: String,
738    pub(crate) status_code: i64,
739}
740
741pub const STATUS_ID_OPEN: u64 = 2;
742pub const STATUS_ID_ASSIGNED: u64 = 5;
743pub const STATUS_ID_CANCELLED: u64 = 7;
744pub const STATUS_ID_CLOSED: u64 = 1;
745pub const STATUS_ID_IN_PROGRESS: u64 = 6;
746pub const STATUS_ID_ONHOLD: u64 = 3;
747pub const STATUS_ID_RESOLVED: u64 = 4;
748
749#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
750pub struct Status {
751    pub id: String,
752    pub name: String,
753    pub color: Option<String>,
754}
755
756impl Status {
757    pub fn open() -> Self {
758        Status {
759            id: STATUS_ID_OPEN.to_string(),
760            name: "Open".to_string(),
761            color: Some("#0066ff".to_string()),
762        }
763    }
764
765    pub fn assigned() -> Self {
766        Status {
767            id: STATUS_ID_ASSIGNED.to_string(),
768            name: "Assigned".to_string(),
769            // blue
770            color: Some("#0000ff".to_string()),
771        }
772    }
773
774    pub fn cancelled() -> Self {
775        Status {
776            id: STATUS_ID_CANCELLED.to_string(),
777            name: "Cancelled".to_string(),
778            // grey
779            color: Some("#999999".to_string()),
780        }
781    }
782
783    pub fn closed() -> Self {
784        Status {
785            id: STATUS_ID_CLOSED.to_string(),
786            name: "Closed".to_string(),
787            color: Some("#006600".to_string()),
788        }
789    }
790
791    pub fn in_progress() -> Self {
792        Status {
793            id: STATUS_ID_IN_PROGRESS.to_string(),
794            name: "In Progress".to_string(),
795            color: Some("#00ffcc".to_string()),
796        }
797    }
798
799    pub fn onhold() -> Self {
800        Status {
801            id: STATUS_ID_ONHOLD.to_string(),
802            name: "On Hold".to_string(),
803            color: Some("#ff0000".to_string()),
804        }
805    }
806
807    pub fn resolved() -> Self {
808        Status {
809            id: STATUS_ID_RESOLVED.to_string(),
810            name: "Resolved".to_string(),
811            color: Some("#00ff66".to_string()),
812        }
813    }
814}
815
816/// Priority structure representing the priority of a ticket in SDP.
817/// Contains an ID, name, and an optional color for visual representation.
818///
819/// 'Not specified' priority is represented by None, which is the default value for the Priority struct.
820#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
821pub struct Priority {
822    pub id: String,
823    pub name: String,
824    pub color: Option<String>,
825}
826
827pub const PRIORITY_ID_LOW: u64 = 1;
828pub const PRIORITY_ID_MEDIUM: u64 = 3;
829pub const PRIORITY_ID_HIGH: u64 = 4;
830pub const PRIORITY_ID_CRITICAL: u64 = 301;
831
832// priority: Some(
833//     Priority {
834//         id: "1",
835//         name: "Low",
836//         color: Some(
837//             "#288251",
838//         ),
839//     },
840//
841// priority: Some(
842//     Priority {
843//         id: "3",
844//         name: "Medium",
845//         color: Some(
846//             "#efb116",
847//         ),
848//     },
849// ),
850//
851//     Priority {
852//         priority: Some(
853//         id: "4",
854//         name: "High",
855//         color: Some(
856//             "#ff5e00",
857//         ),
858//     },
859// ),
860//
861// priority: Some(
862//     Priority {
863//         id: "301",
864//         name: "Critical",
865//         color: Some(
866//             "#8b0808",
867//         ),
868//     },
869// ),
870impl Priority {
871    pub fn low() -> Self {
872        Priority {
873            id: PRIORITY_ID_LOW.to_string(),
874            name: "Low".to_string(),
875            color: Some("#288251".to_string()),
876        }
877    }
878
879    pub fn medium() -> Self {
880        Priority {
881            id: PRIORITY_ID_MEDIUM.to_string(),
882            name: "Medium".to_string(),
883            color: Some("#efb116".to_string()),
884        }
885    }
886
887    pub fn high() -> Self {
888        Priority {
889            id: PRIORITY_ID_HIGH.to_string(),
890            name: "High".to_string(),
891            color: Some("#ff5e00".to_string()),
892        }
893    }
894
895    /// Suspiciously high internal ID, might be specific to our SDP instance.
896    /// Please verify on your end if this ID is correct for the Critical priority, or if it needs to be adjusted.
897    pub fn critical() -> Self {
898        Priority {
899            id: PRIORITY_ID_CRITICAL.to_string(),
900            name: "Critical".to_string(),
901            color: Some("#8b0808".to_string()),
902        }
903    }
904}
905
906#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
907pub struct UserInfo {
908    pub id: UserID,
909    pub name: String,
910    pub email_id: Option<String>,
911    pub account: Option<Value>,
912    pub department: Option<Value>,
913    #[serde(default)]
914    pub is_vipuser: bool,
915    pub mobile: Option<String>,
916    pub org_user_status: Option<String>,
917    pub phone: Option<String>,
918    #[serde(skip_serializing)]
919    pub profile_pic: Option<Value>,
920}
921
922#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
923pub struct Resolution {
924    pub content: Option<String>,
925    pub submitted_by: Option<UserInfo>,
926    pub submitted_on: Option<TimeEntry>,
927    pub resolution_attachments: Option<Vec<Attachment>>,
928}
929
930#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
931pub struct Attachment {
932    pub id: String,
933    pub name: String,
934    pub content_url: String,
935    pub content_type: Option<String>,
936    pub description: Option<String>,
937    pub module: Option<String>,
938    pub size: Option<SizeInfo>,
939    pub attached_by: Option<UserInfo>,
940    pub attached_on: Option<TimeEntry>,
941}
942
943#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
944pub struct SizeInfo {
945    pub display_value: String,
946    pub value: u64,
947}
948
949#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
950pub struct TimeEntry {
951    pub display_value: String,
952    pub value: String,
953}
954
955#[derive(Serialize, Debug)]
956struct CreateTicketRequest<'a> {
957    request: &'a CreateTicketData,
958}
959
960#[derive(Serialize, Deserialize, Debug, PartialEq)]
961pub struct CreateTicketData {
962    pub subject: String,
963    pub description: String,
964    #[serde(
965        serialize_with = "serialize_name_object",
966        deserialize_with = "deserialize_name_object"
967    )]
968    pub requester: String,
969    #[serde(
970        serialize_with = "serialize_name_object",
971        deserialize_with = "deserialize_name_object"
972    )]
973    pub priority: String,
974    // Can't do much here, since these fields seem to be dynamically defined
975    // per template at SDP. They need to be explicitly deserialized by the user
976    // after we've converted them to plain serde_json::Value.
977    pub udf_fields: Value,
978    #[serde(
979        serialize_with = "serialize_name_object",
980        deserialize_with = "deserialize_name_object"
981    )]
982    pub account: String,
983    #[serde(
984        serialize_with = "serialize_name_object",
985        deserialize_with = "deserialize_name_object"
986    )]
987    pub template: String,
988}
989
990impl Default for CreateTicketData {
991    fn default() -> Self {
992        CreateTicketData {
993            subject: String::new(),
994            description: String::new(),
995            requester: String::new(),
996            priority: "Low".to_string(),
997            udf_fields: Value::Null,
998            account: String::new(),
999            template: String::new(),
1000        }
1001    }
1002}
1003
1004pub(crate) fn deserialize_name_object<'de, D>(deserializer: D) -> Result<String, D::Error>
1005where
1006    D: Deserializer<'de>,
1007{
1008    #[derive(Deserialize)]
1009    struct NameObject {
1010        name: String,
1011    }
1012
1013    Ok(NameObject::deserialize(deserializer)?.name)
1014}
1015
1016pub(crate) fn serialize_name_object<S>(name: &String, serializer: S) -> Result<S::Ok, S::Error>
1017where
1018    S: Serializer,
1019{
1020    let mut s = serializer.serialize_struct("NameWrapper", 1)?;
1021    s.serialize_field("name", name)?;
1022    s.end()
1023}
1024
1025#[allow(dead_code)]
1026#[derive(Serialize, Debug, PartialEq, Eq)]
1027pub(crate) struct NameWrapper {
1028    pub(crate) name: String,
1029}
1030
1031impl From<&str> for NameWrapper {
1032    fn from(name: &str) -> Self {
1033        Self {
1034            name: name.to_string(),
1035        }
1036    }
1037}
1038
1039impl From<String> for NameWrapper {
1040    fn from(name: String) -> Self {
1041        Self { name }
1042    }
1043}
1044
1045impl std::ops::Deref for NameWrapper {
1046    type Target = String;
1047
1048    fn deref(&self) -> &Self::Target {
1049        &self.name
1050    }
1051}
1052
1053impl std::ops::DerefMut for NameWrapper {
1054    fn deref_mut(&mut self) -> &mut Self::Target {
1055        &mut self.name
1056    }
1057}
1058
1059#[derive(Serialize, Debug, PartialEq, Eq)]
1060struct CloseTicketRequest {
1061    request: CloseTicketData,
1062}
1063
1064#[derive(Serialize, Debug, PartialEq, Eq)]
1065struct CloseTicketData {
1066    closure_info: ClosureInfo,
1067}
1068
1069#[derive(Serialize, Debug, PartialEq, Eq)]
1070struct ClosureInfo {
1071    closure_comments: String,
1072    closure_code: String,
1073}
1074
1075#[derive(Serialize, Debug)]
1076struct AddNoteRequest<'a> {
1077    note: &'a NoteData,
1078}
1079
1080#[derive(Serialize, Debug)]
1081struct AddWorklogRequest<'a> {
1082    worklog: &'a WorklogData,
1083}
1084
1085#[derive(Serialize, Debug, Default, PartialEq, Eq)]
1086pub struct NoteData {
1087    pub mark_first_response: bool,
1088    pub add_to_linked_requests: bool,
1089    pub notify_technician: bool,
1090    pub show_to_requester: bool,
1091    pub description: String,
1092}
1093
1094// Note response structures
1095#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1096pub(crate) struct NoteResponse {
1097    pub(crate) note: Note,
1098}
1099
1100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1101pub struct NotesListResponse {
1102    pub list_info: Option<ListInfoResponse>,
1103    pub notes: Vec<Note>,
1104    pub response_status: Vec<ResponseStatus>,
1105}
1106
1107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1108pub struct ListInfoResponse {
1109    pub has_more_rows: bool,
1110    pub page: u32,
1111    pub row_count: u32,
1112    pub sort_field: String,
1113    pub sort_order: String,
1114    pub start_index: u32,
1115}
1116
1117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1118pub struct Note {
1119    pub id: NoteID,
1120    #[serde(default)]
1121    pub description: String,
1122    #[serde(default)]
1123    pub show_to_requester: bool,
1124    #[serde(default)]
1125    pub mark_first_response: bool,
1126    #[serde(default)]
1127    pub notify_technician: bool,
1128    #[serde(default)]
1129    pub add_to_linked_requests: bool,
1130    pub created_time: Option<TimeEntry>,
1131    pub created_by: Option<UserInfo>,
1132    pub last_updated_time: Option<TimeEntry>,
1133}
1134
1135#[derive(Serialize, Debug)]
1136struct EditNoteRequest<'a> {
1137    request_note: &'a NoteData,
1138}
1139
1140#[derive(Serialize, Debug)]
1141struct ListNotesRequest {
1142    list_info: NotesListInfo,
1143}
1144
1145#[derive(Debug, PartialEq, Eq, Deserialize)]
1146struct ConversationsResponse {
1147    #[serde(default)]
1148    conversations: Vec<ConversationSummary>,
1149}
1150
1151#[derive(Debug, PartialEq, Eq, Deserialize)]
1152struct ConversationSummary {
1153    #[serde(default)]
1154    has_attachments: bool,
1155    #[serde(default)]
1156    content_url: Option<String>,
1157}
1158
1159fn normalize_attachment_url(base_url: &reqwest::Url, value: &str) -> Result<String, Error> {
1160    Ok(base_url.join(value)?.to_string())
1161}
1162
1163#[derive(Serialize, Debug, PartialEq, Eq)]
1164struct NotesListInfo {
1165    row_count: u32,
1166    start_index: u32,
1167}
1168
1169#[derive(Serialize, Debug, PartialEq, Eq)]
1170struct AssignTicketRequest {
1171    request: AssignTicketData,
1172}
1173
1174#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
1175struct AssignTicketData {
1176    #[serde(
1177        serialize_with = "serialize_name_object",
1178        deserialize_with = "deserialize_name_object"
1179    )]
1180    technician: String,
1181}
1182
1183#[derive(Serialize, Debug, PartialEq, Eq)]
1184struct MergeTicketsRequest {
1185    merge_requests: Vec<MergeRequestId>,
1186}
1187
1188#[derive(Serialize, Debug, PartialEq, Eq)]
1189struct MergeRequestId {
1190    id: String,
1191}
1192
1193#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1194pub(crate) struct TicketResponse {
1195    pub(crate) request: TicketData,
1196    pub(crate) response_status: ResponseStatus,
1197}
1198
1199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1200pub struct TicketData {
1201    pub id: TicketID,
1202    pub subject: String,
1203    pub description: Option<String>,
1204    pub status: Status,
1205    pub priority: Option<Priority>,
1206    pub created_time: TimeEntry,
1207    pub requester: Option<UserInfo>,
1208    pub account: Account,
1209    pub template: TemplateInfo,
1210    pub udf_fields: Option<Value>,
1211}
1212
1213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1214pub struct TemplateInfo {
1215    pub id: String,
1216    pub name: String,
1217}
1218
1219#[cfg(test)]
1220mod tests {
1221    use super::*;
1222    use serde_json::json;
1223
1224    #[test]
1225    fn criteria_default() {
1226        let criteria = Criteria::default();
1227        assert!(criteria.field.is_empty());
1228        assert!(matches!(criteria.condition, Condition::Is));
1229        assert!(criteria.value.is_null());
1230        assert!(criteria.children.is_empty());
1231        assert!(criteria.logical_operator.is_none());
1232    }
1233
1234    #[test]
1235    fn create_ticket_data_default() {
1236        let data = CreateTicketData::default();
1237        assert!(data.subject.is_empty());
1238        assert!(data.description.is_empty());
1239        assert!(data.requester.is_empty());
1240        assert_eq!(data.priority, "Low");
1241        assert!(data.udf_fields.is_null());
1242        assert!(data.account.is_empty());
1243        assert!(data.template.is_empty());
1244    }
1245
1246    #[test]
1247    fn create_ticket_data_serializes_name_fields_as_objects() {
1248        let data = CreateTicketData {
1249            subject: "test".to_string(),
1250            description: "body".to_string(),
1251            requester: "NETXP".to_string(),
1252            priority: "High".to_string(),
1253            udf_fields: json!({}),
1254            account: "SOC".to_string(),
1255            template: "SOC-with-alert-id".to_string(),
1256        };
1257
1258        let serialized = serde_json::to_value(&data).unwrap();
1259        println!("Serialized CreateTicketData: {}", serialized);
1260
1261        assert_eq!(serialized["requester"], json!({ "name": "NETXP" }));
1262        assert_eq!(serialized["priority"], json!({ "name": "High" }));
1263        assert_eq!(serialized["account"], json!({ "name": "SOC" }));
1264        assert_eq!(
1265            serialized["template"],
1266            json!({ "name": "SOC-with-alert-id" })
1267        );
1268    }
1269
1270    #[test]
1271    fn edit_ticket_data_serializes_optional_name_fields_as_objects() {
1272        let data = EditTicketData {
1273            subject: "test".to_string(),
1274            status: Status {
1275                id: "1".to_string(),
1276                name: "Open".to_string(),
1277                color: None,
1278            },
1279            description: None,
1280            requester: Some(UserInfo {
1281                id: UserID("123".to_string()),
1282                name: "NETXP".to_string(),
1283                ..Default::default()
1284            }),
1285            priority: Some(Priority::high()),
1286            udf_fields: None,
1287        };
1288
1289        let serialized = serde_json::to_value(&data).unwrap();
1290
1291        assert_eq!(serialized["requester"]["name"], "NETXP");
1292        assert_eq!(serialized["priority"]["name"], "High");
1293        assert!(serialized["description"].is_null());
1294        assert_eq!(serialized["status"]["name"], "Open");
1295    }
1296}