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    /// Get a specific note from a ticket.
369    pub async fn get_note(
370        &self,
371        ticket_id: impl Into<TicketID>,
372        note_id: impl Into<NoteID>,
373    ) -> Result<Note, Error> {
374        let ticket_id = ticket_id.into();
375        let note_id = note_id.into();
376        tracing::info!(ticket_id = %ticket_id, note_id = %note_id, "fetching note");
377        let url = format!("/api/v3/requests/{}/notes/{}", ticket_id, note_id);
378        let resp: NoteResponse = self.request(Method::GET, &url, &"").await?;
379        Ok(resp.note)
380    }
381
382    /// List all notes for a ticket.
383    pub async fn list_notes(
384        &self,
385        ticket_id: impl Into<TicketID>,
386        row_count: Option<u32>,
387        start_index: Option<u32>,
388    ) -> Result<Vec<Note>, Error> {
389        let ticket_id = ticket_id.into();
390        tracing::info!(ticket_id = %ticket_id, "listing notes");
391        let body = ListNotesRequest {
392            list_info: NotesListInfo {
393                row_count: row_count.unwrap_or(100),
394                start_index: start_index.unwrap_or(1),
395            },
396        };
397        let resp: Value = self
398            .request_input_data(
399                Method::GET,
400                &format!("/api/v3/requests/{}/notes", ticket_id),
401                &body,
402            )
403            .await?;
404        let resp: NotesListResponse = serde_json::from_value(resp)?;
405        Ok(resp.notes)
406    }
407
408    /// Edit an existing note.
409    pub async fn edit_note(
410        &self,
411        ticket_id: impl Into<TicketID>,
412        note_id: impl Into<NoteID>,
413        note: &NoteData,
414    ) -> Result<Note, Error> {
415        let ticket_id = ticket_id.into();
416        let note_id = note_id.into();
417        tracing::info!(ticket_id = %ticket_id, note_id = %note_id, "editing note");
418        let resp: NoteResponse = self
419            .request_input_data(
420                Method::PUT,
421                &format!("/api/v3/requests/{}/notes/{}", ticket_id, note_id),
422                &EditNoteRequest { request_note: note },
423            )
424            .await?;
425        Ok(resp.note)
426    }
427
428    /// Delete a note from a ticket.
429    pub async fn delete_note(
430        &self,
431        ticket_id: impl Into<TicketID>,
432        note_id: impl Into<NoteID>,
433    ) -> Result<(), Error> {
434        let ticket_id = ticket_id.into();
435        let note_id = note_id.into();
436        tracing::info!(ticket_id = %ticket_id, note_id = %note_id, "deleting note");
437        let _: SdpGenericResponse = self
438            .request(
439                Method::DELETE,
440                &format!("/api/v3/requests/{}/notes/{}", ticket_id, note_id),
441                &"",
442            )
443            .await?;
444        Ok(())
445    }
446
447    /// Assign a ticket to a technician.
448    pub async fn assign_ticket(
449        &self,
450        ticket_id: impl Into<TicketID>,
451        technician_name: &str,
452    ) -> Result<(), Error> {
453        let ticket_id = ticket_id.into();
454        tracing::info!(ticket_id = %ticket_id, technician = %technician_name, "assigning ticket");
455        let _: SdpGenericResponse = self
456            .request_input_data(
457                Method::PUT,
458                &format!("/api/v3/requests/{}/assign", ticket_id),
459                &AssignTicketRequest {
460                    request: AssignTicketData {
461                        technician: technician_name.to_string(),
462                    },
463                },
464            )
465            .await?;
466        Ok(())
467    }
468
469    /// Create a new ticket.
470    pub async fn create_ticket(&self, data: &CreateTicketData) -> Result<TicketData, Error> {
471        tracing::info!(subject = %data.subject, "creating ticket");
472        let resp: TicketResponse = self
473            .request_input_data(
474                Method::POST,
475                "/api/v3/requests",
476                &CreateTicketRequest { request: data },
477            )
478            .await?;
479        Ok(resp.request)
480    }
481
482    /// Search for tickets based on specified criteria.
483    ///
484    /// The criteria can be built using the `Criteria` struct.
485    /// The default method of querying is not straightforward,
486    /// [`Criteria`] struct on the 'root' level contains a single condition, to combine multiple conditions
487    /// use the 'children' field with appropriate `LogicalOp`.
488    pub async fn search_tickets(&self, criteria: Criteria) -> Result<Vec<DetailedTicket>, Error> {
489        tracing::info!("searching tickets");
490        let resp = self
491            .request_input_data(
492                Method::GET,
493                "/api/v3/requests",
494                &SearchRequest {
495                    list_info: ListInfo {
496                        row_count: 100,
497                        search_criteria: criteria,
498                    },
499                },
500            )
501            .await?;
502
503        let ticket_response: TicketSearchResponse = serde_json::from_value(resp)?;
504
505        Ok(ticket_response.requests)
506    }
507
508    /// Close a ticket with closure comments.
509    pub async fn close_ticket(
510        &self,
511        ticket_id: impl Into<TicketID>,
512        closure_comments: &str,
513    ) -> Result<(), Error> {
514        let ticket_id = ticket_id.into();
515        tracing::info!(ticket_id = %ticket_id, "closing ticket");
516        let _: SdpGenericResponse = self
517            .request_json(
518                Method::PUT,
519                &format!("/api/v3/requests/{}/close", ticket_id),
520                &CloseTicketRequest {
521                    request: CloseTicketData {
522                        closure_info: ClosureInfo {
523                            closure_comments: closure_comments.to_string(),
524                            closure_code: "Closed".to_string(),
525                        },
526                    },
527                },
528            )
529            .await?;
530        Ok(())
531    }
532
533    /// Merge multiple tickets into a single ticket.
534    /// Key point to note is that the maximum number of tickets that can be merged at once is 49 +
535    /// 1 (the target ticket), so the `merge_ids` slice must not exceed 49 IDs.
536    pub async fn merge(
537        &self,
538        ticket_id: impl Into<TicketID>,
539        merge_ids: &[TicketID],
540    ) -> Result<(), Error> {
541        let ticket_id = ticket_id.into();
542        tracing::info!(ticket_id = %ticket_id, count = merge_ids.len(), "merging tickets");
543        if merge_ids.len() > 49 {
544            tracing::warn!("attempted to merge more than 49 tickets");
545            return Err(Error::from_sdp(
546                400,
547                "Cannot merge more than 49 tickets at once".to_string(),
548                None,
549            ));
550        }
551        let merge_requests: Vec<MergeRequestId> = merge_ids
552            .iter()
553            .map(|id| MergeRequestId {
554                id: id.0.to_string(),
555            })
556            .collect();
557
558        let _: SdpGenericResponse = self
559            .request_form(
560                Method::PUT,
561                &format!("/api/v3/requests/{}/merge_requests", ticket_id),
562                &MergeTicketsRequest { merge_requests },
563            )
564            .await?;
565        Ok(())
566    }
567}
568
569use serde::Deserialize;
570use serde_json::Value;
571
572use crate::{NoteID, ServiceDesk, TicketID, UserID, error::Error};
573
574#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
575pub(crate) struct SearchRequest {
576    pub(crate) list_info: ListInfo,
577}
578
579#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
580pub struct ListInfo {
581    pub row_count: u32,
582    pub search_criteria: Criteria,
583}
584
585/// Criteria structure for building search queries.
586/// This structure allows for complex nested criteria using logical operators.
587/// The inner field, condition, and value define a single search condition.
588/// The children field allows for nesting additional criteria, combined using the specified logical operator.
589#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
590pub struct Criteria {
591    pub field: String,
592    pub condition: Condition,
593    pub value: Value,
594
595    #[serde(skip_serializing_if = "Vec::is_empty")]
596    pub children: Vec<Criteria>,
597
598    #[serde(skip_serializing_if = "Option::is_none")]
599    pub logical_operator: Option<LogicalOp>,
600}
601
602impl Default for Criteria {
603    fn default() -> Self {
604        Criteria {
605            field: String::new(),
606            condition: Condition::Is,
607            value: Value::Null,
608            children: vec![],
609            logical_operator: None,
610        }
611    }
612}
613
614/// Condition enum for specifying search conditions in criteria.
615/// Used in the Criteria struct to define how to compare field values.
616#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
617#[serde(rename_all = "snake_case")]
618pub enum Condition {
619    #[serde(rename = "is")]
620    Is,
621    #[serde(rename = "greater than")]
622    GreaterThan,
623    #[serde(rename = "lesser than")]
624    LesserThan,
625    #[serde(rename = "contains")]
626    Contains,
627}
628
629/// Logical operators for combining multiple criteria.
630#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
631pub enum LogicalOp {
632    #[serde(rename = "AND")]
633    And,
634    #[serde(rename = "OR")]
635    Or,
636}
637
638#[derive(Deserialize, Serialize, Debug, PartialEq)]
639pub struct TicketSearchResponse {
640    pub requests: Vec<DetailedTicket>,
641}
642
643#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
644pub struct Account {
645    pub id: String,
646    pub name: String,
647}
648
649#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
650struct DetailedTicketResponse {
651    request: DetailedTicket,
652    #[serde(skip_serializing)]
653    response_status: ResponseStatus,
654}
655
656#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
657#[serde(rename = "request")]
658pub struct DetailedTicket {
659    pub id: TicketID,
660    pub subject: String,
661    pub description: Option<String>,
662    pub status: Status,
663    pub priority: Option<Priority>,
664    pub requester: Option<UserInfo>,
665    pub technician: Option<UserInfo>,
666    #[serde(skip_serializing)]
667    pub created_by: UserInfo,
668    pub created_time: TimeEntry,
669    pub resolution: Option<Resolution>,
670    pub due_by_time: Option<TimeEntry>,
671    pub resolved_time: Option<TimeEntry>,
672    pub completed_time: Option<TimeEntry>,
673    pub udf_fields: Option<Value>,
674    pub attachments: Option<Vec<Attachment>>,
675    pub closure_info: Option<Value>,
676    pub site: Option<Value>,
677    pub department: Option<Value>,
678    pub account: Option<Value>,
679}
680
681#[derive(Serialize, Debug)]
682struct EditTicketRequest<'a> {
683    request: &'a EditTicketData,
684}
685
686/// Data structure for editing a ticket.
687/// Contains fields that WILL be updated on the associated ticket.
688/// For some reason SDP does not provide simple API to patch a single attribute of a ticket,
689/// instead it requires sending a PUT that will replace all of the fields even None ones,
690/// which will be treated as empty values and overwrite existing data.
691///
692/// To conveniently use this API I'd recommend to use `From<DetailedTicket>` implementation for this struct.
693#[derive(Serialize, Deserialize, Debug, PartialEq)]
694pub struct EditTicketData {
695    pub subject: String,
696    pub status: Status,
697    pub description: Option<String>,
698    pub requester: Option<UserInfo>,
699    pub priority: Option<Priority>,
700    /// Dynamically defined template fields
701    pub udf_fields: Option<Value>,
702}
703
704impl From<DetailedTicket> for EditTicketData {
705    fn from(value: DetailedTicket) -> Self {
706        Self {
707            subject: value.subject,
708            status: value.status,
709            description: value.description,
710            requester: value.requester,
711            priority: value.priority,
712            udf_fields: value.udf_fields,
713        }
714    }
715}
716
717#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
718pub(crate) struct ResponseStatus {
719    pub(crate) status: String,
720    pub(crate) status_code: i64,
721}
722
723pub const STATUS_ID_OPEN: u64 = 2;
724pub const STATUS_ID_ASSIGNED: u64 = 5;
725pub const STATUS_ID_CANCELLED: u64 = 7;
726pub const STATUS_ID_CLOSED: u64 = 1;
727pub const STATUS_ID_IN_PROGRESS: u64 = 6;
728pub const STATUS_ID_ONHOLD: u64 = 3;
729pub const STATUS_ID_RESOLVED: u64 = 4;
730
731#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
732pub struct Status {
733    pub id: String,
734    pub name: String,
735    pub color: Option<String>,
736}
737
738impl Status {
739    pub fn open() -> Self {
740        Status {
741            id: STATUS_ID_OPEN.to_string(),
742            name: "Open".to_string(),
743            color: Some("#0066ff".to_string()),
744        }
745    }
746
747    pub fn assigned() -> Self {
748        Status {
749            id: STATUS_ID_ASSIGNED.to_string(),
750            name: "Assigned".to_string(),
751            // blue
752            color: Some("#0000ff".to_string()),
753        }
754    }
755
756    pub fn cancelled() -> Self {
757        Status {
758            id: STATUS_ID_CANCELLED.to_string(),
759            name: "Cancelled".to_string(),
760            // grey
761            color: Some("#999999".to_string()),
762        }
763    }
764
765    pub fn closed() -> Self {
766        Status {
767            id: STATUS_ID_CLOSED.to_string(),
768            name: "Closed".to_string(),
769            color: Some("#006600".to_string()),
770        }
771    }
772
773    pub fn in_progress() -> Self {
774        Status {
775            id: STATUS_ID_IN_PROGRESS.to_string(),
776            name: "In Progress".to_string(),
777            color: Some("#00ffcc".to_string()),
778        }
779    }
780
781    pub fn onhold() -> Self {
782        Status {
783            id: STATUS_ID_ONHOLD.to_string(),
784            name: "On Hold".to_string(),
785            color: Some("#ff0000".to_string()),
786        }
787    }
788
789    pub fn resolved() -> Self {
790        Status {
791            id: STATUS_ID_RESOLVED.to_string(),
792            name: "Resolved".to_string(),
793            color: Some("#00ff66".to_string()),
794        }
795    }
796}
797
798/// Priority structure representing the priority of a ticket in SDP.
799/// Contains an ID, name, and an optional color for visual representation.
800///
801/// 'Not specified' priority is represented by None, which is the default value for the Priority struct.
802#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
803pub struct Priority {
804    pub id: String,
805    pub name: String,
806    pub color: Option<String>,
807}
808
809pub const PRIORITY_ID_LOW: u64 = 1;
810pub const PRIORITY_ID_MEDIUM: u64 = 3;
811pub const PRIORITY_ID_HIGH: u64 = 4;
812pub const PRIORITY_ID_CRITICAL: u64 = 301;
813
814// priority: Some(
815//     Priority {
816//         id: "1",
817//         name: "Low",
818//         color: Some(
819//             "#288251",
820//         ),
821//     },
822//
823// priority: Some(
824//     Priority {
825//         id: "3",
826//         name: "Medium",
827//         color: Some(
828//             "#efb116",
829//         ),
830//     },
831// ),
832//
833//     Priority {
834//         priority: Some(
835//         id: "4",
836//         name: "High",
837//         color: Some(
838//             "#ff5e00",
839//         ),
840//     },
841// ),
842//
843// priority: Some(
844//     Priority {
845//         id: "301",
846//         name: "Critical",
847//         color: Some(
848//             "#8b0808",
849//         ),
850//     },
851// ),
852impl Priority {
853    pub fn low() -> Self {
854        Priority {
855            id: PRIORITY_ID_LOW.to_string(),
856            name: "Low".to_string(),
857            color: Some("#288251".to_string()),
858        }
859    }
860
861    pub fn medium() -> Self {
862        Priority {
863            id: PRIORITY_ID_MEDIUM.to_string(),
864            name: "Medium".to_string(),
865            color: Some("#efb116".to_string()),
866        }
867    }
868
869    pub fn high() -> Self {
870        Priority {
871            id: PRIORITY_ID_HIGH.to_string(),
872            name: "High".to_string(),
873            color: Some("#ff5e00".to_string()),
874        }
875    }
876
877    /// Suspiciously high internal ID, might be specific to our SDP instance.
878    /// Please verify on your end if this ID is correct for the Critical priority, or if it needs to be adjusted.
879    pub fn critical() -> Self {
880        Priority {
881            id: PRIORITY_ID_CRITICAL.to_string(),
882            name: "Critical".to_string(),
883            color: Some("#8b0808".to_string()),
884        }
885    }
886}
887
888#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
889pub struct UserInfo {
890    pub id: UserID,
891    pub name: String,
892    pub email_id: Option<String>,
893    pub account: Option<Value>,
894    pub department: Option<Value>,
895    #[serde(default)]
896    pub is_vipuser: bool,
897    pub mobile: Option<String>,
898    pub org_user_status: Option<String>,
899    pub phone: Option<String>,
900    #[serde(skip_serializing)]
901    pub profile_pic: Option<Value>,
902}
903
904#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
905pub struct Resolution {
906    pub content: Option<String>,
907    pub submitted_by: Option<UserInfo>,
908    pub submitted_on: Option<TimeEntry>,
909    pub resolution_attachments: Option<Vec<Attachment>>,
910}
911
912#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
913pub struct Attachment {
914    pub id: String,
915    pub name: String,
916    pub content_url: String,
917    pub content_type: Option<String>,
918    pub description: Option<String>,
919    pub module: Option<String>,
920    pub size: Option<SizeInfo>,
921    pub attached_by: Option<UserInfo>,
922    pub attached_on: Option<TimeEntry>,
923}
924
925#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
926pub struct SizeInfo {
927    pub display_value: String,
928    pub value: u64,
929}
930
931#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
932pub struct TimeEntry {
933    pub display_value: String,
934    pub value: String,
935}
936
937#[derive(Serialize, Debug)]
938struct CreateTicketRequest<'a> {
939    request: &'a CreateTicketData,
940}
941
942#[derive(Serialize, Deserialize, Debug, PartialEq)]
943pub struct CreateTicketData {
944    pub subject: String,
945    pub description: String,
946    #[serde(
947        serialize_with = "serialize_name_object",
948        deserialize_with = "deserialize_name_object"
949    )]
950    pub requester: String,
951    #[serde(
952        serialize_with = "serialize_name_object",
953        deserialize_with = "deserialize_name_object"
954    )]
955    pub priority: String,
956    // Can't do much here, since these fields seem to be dynamically defined
957    // per template at SDP. They need to be explicitly deserialized by the user
958    // after we've converted them to plain serde_json::Value.
959    pub udf_fields: Value,
960    #[serde(
961        serialize_with = "serialize_name_object",
962        deserialize_with = "deserialize_name_object"
963    )]
964    pub account: String,
965    #[serde(
966        serialize_with = "serialize_name_object",
967        deserialize_with = "deserialize_name_object"
968    )]
969    pub template: String,
970}
971
972impl Default for CreateTicketData {
973    fn default() -> Self {
974        CreateTicketData {
975            subject: String::new(),
976            description: String::new(),
977            requester: String::new(),
978            priority: "Low".to_string(),
979            udf_fields: Value::Null,
980            account: String::new(),
981            template: String::new(),
982        }
983    }
984}
985
986pub(crate) fn deserialize_name_object<'de, D>(deserializer: D) -> Result<String, D::Error>
987where
988    D: Deserializer<'de>,
989{
990    #[derive(Deserialize)]
991    struct NameObject {
992        name: String,
993    }
994
995    Ok(NameObject::deserialize(deserializer)?.name)
996}
997
998pub(crate) fn serialize_name_object<S>(name: &String, serializer: S) -> Result<S::Ok, S::Error>
999where
1000    S: Serializer,
1001{
1002    let mut s = serializer.serialize_struct("NameWrapper", 1)?;
1003    s.serialize_field("name", name)?;
1004    s.end()
1005}
1006
1007#[allow(dead_code)]
1008#[derive(Serialize, Debug, PartialEq, Eq)]
1009pub(crate) struct NameWrapper {
1010    pub(crate) name: String,
1011}
1012
1013impl From<&str> for NameWrapper {
1014    fn from(name: &str) -> Self {
1015        Self {
1016            name: name.to_string(),
1017        }
1018    }
1019}
1020
1021impl From<String> for NameWrapper {
1022    fn from(name: String) -> Self {
1023        Self { name }
1024    }
1025}
1026
1027impl std::ops::Deref for NameWrapper {
1028    type Target = String;
1029
1030    fn deref(&self) -> &Self::Target {
1031        &self.name
1032    }
1033}
1034
1035impl std::ops::DerefMut for NameWrapper {
1036    fn deref_mut(&mut self) -> &mut Self::Target {
1037        &mut self.name
1038    }
1039}
1040
1041#[derive(Serialize, Debug, PartialEq, Eq)]
1042struct CloseTicketRequest {
1043    request: CloseTicketData,
1044}
1045
1046#[derive(Serialize, Debug, PartialEq, Eq)]
1047struct CloseTicketData {
1048    closure_info: ClosureInfo,
1049}
1050
1051#[derive(Serialize, Debug, PartialEq, Eq)]
1052struct ClosureInfo {
1053    closure_comments: String,
1054    closure_code: String,
1055}
1056
1057#[derive(Serialize, Debug)]
1058struct AddNoteRequest<'a> {
1059    note: &'a NoteData,
1060}
1061
1062#[derive(Serialize, Debug, Default, PartialEq, Eq)]
1063pub struct NoteData {
1064    pub mark_first_response: bool,
1065    pub add_to_linked_requests: bool,
1066    pub notify_technician: bool,
1067    pub show_to_requester: bool,
1068    pub description: String,
1069}
1070
1071// Note response structures
1072#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1073pub(crate) struct NoteResponse {
1074    pub(crate) note: Note,
1075}
1076
1077#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1078pub struct NotesListResponse {
1079    pub list_info: Option<ListInfoResponse>,
1080    pub notes: Vec<Note>,
1081    pub response_status: Vec<ResponseStatus>,
1082}
1083
1084#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1085pub struct ListInfoResponse {
1086    pub has_more_rows: bool,
1087    pub page: u32,
1088    pub row_count: u32,
1089    pub sort_field: String,
1090    pub sort_order: String,
1091    pub start_index: u32,
1092}
1093
1094#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1095pub struct Note {
1096    pub id: NoteID,
1097    #[serde(default)]
1098    pub description: String,
1099    #[serde(default)]
1100    pub show_to_requester: bool,
1101    #[serde(default)]
1102    pub mark_first_response: bool,
1103    #[serde(default)]
1104    pub notify_technician: bool,
1105    #[serde(default)]
1106    pub add_to_linked_requests: bool,
1107    pub created_time: Option<TimeEntry>,
1108    pub created_by: Option<UserInfo>,
1109    pub last_updated_time: Option<TimeEntry>,
1110}
1111
1112#[derive(Serialize, Debug)]
1113struct EditNoteRequest<'a> {
1114    request_note: &'a NoteData,
1115}
1116
1117#[derive(Serialize, Debug)]
1118struct ListNotesRequest {
1119    list_info: NotesListInfo,
1120}
1121
1122#[derive(Debug, PartialEq, Eq, Deserialize)]
1123struct ConversationsResponse {
1124    #[serde(default)]
1125    conversations: Vec<ConversationSummary>,
1126}
1127
1128#[derive(Debug, PartialEq, Eq, Deserialize)]
1129struct ConversationSummary {
1130    #[serde(default)]
1131    has_attachments: bool,
1132    #[serde(default)]
1133    content_url: Option<String>,
1134}
1135
1136fn normalize_attachment_url(base_url: &reqwest::Url, value: &str) -> Result<String, Error> {
1137    Ok(base_url.join(value)?.to_string())
1138}
1139
1140#[derive(Serialize, Debug, PartialEq, Eq)]
1141struct NotesListInfo {
1142    row_count: u32,
1143    start_index: u32,
1144}
1145
1146#[derive(Serialize, Debug, PartialEq, Eq)]
1147struct AssignTicketRequest {
1148    request: AssignTicketData,
1149}
1150
1151#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
1152struct AssignTicketData {
1153    #[serde(
1154        serialize_with = "serialize_name_object",
1155        deserialize_with = "deserialize_name_object"
1156    )]
1157    technician: String,
1158}
1159
1160#[derive(Serialize, Debug, PartialEq, Eq)]
1161struct MergeTicketsRequest {
1162    merge_requests: Vec<MergeRequestId>,
1163}
1164
1165#[derive(Serialize, Debug, PartialEq, Eq)]
1166struct MergeRequestId {
1167    id: String,
1168}
1169
1170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1171pub(crate) struct TicketResponse {
1172    pub(crate) request: TicketData,
1173    pub(crate) response_status: ResponseStatus,
1174}
1175
1176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1177pub struct TicketData {
1178    pub id: TicketID,
1179    pub subject: String,
1180    pub description: Option<String>,
1181    pub status: Status,
1182    pub priority: Option<Priority>,
1183    pub created_time: TimeEntry,
1184    pub requester: Option<UserInfo>,
1185    pub account: Account,
1186    pub template: TemplateInfo,
1187    pub udf_fields: Option<Value>,
1188}
1189
1190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1191pub struct TemplateInfo {
1192    pub id: String,
1193    pub name: String,
1194}
1195
1196#[cfg(test)]
1197mod tests {
1198    use super::*;
1199    use serde_json::json;
1200
1201    #[test]
1202    fn criteria_default() {
1203        let criteria = Criteria::default();
1204        assert!(criteria.field.is_empty());
1205        assert!(matches!(criteria.condition, Condition::Is));
1206        assert!(criteria.value.is_null());
1207        assert!(criteria.children.is_empty());
1208        assert!(criteria.logical_operator.is_none());
1209    }
1210
1211    #[test]
1212    fn create_ticket_data_default() {
1213        let data = CreateTicketData::default();
1214        assert!(data.subject.is_empty());
1215        assert!(data.description.is_empty());
1216        assert!(data.requester.is_empty());
1217        assert_eq!(data.priority, "Low");
1218        assert!(data.udf_fields.is_null());
1219        assert!(data.account.is_empty());
1220        assert!(data.template.is_empty());
1221    }
1222
1223    #[test]
1224    fn create_ticket_data_serializes_name_fields_as_objects() {
1225        let data = CreateTicketData {
1226            subject: "test".to_string(),
1227            description: "body".to_string(),
1228            requester: "NETXP".to_string(),
1229            priority: "High".to_string(),
1230            udf_fields: json!({}),
1231            account: "SOC".to_string(),
1232            template: "SOC-with-alert-id".to_string(),
1233        };
1234
1235        let serialized = serde_json::to_value(&data).unwrap();
1236        println!("Serialized CreateTicketData: {}", serialized);
1237
1238        assert_eq!(serialized["requester"], json!({ "name": "NETXP" }));
1239        assert_eq!(serialized["priority"], json!({ "name": "High" }));
1240        assert_eq!(serialized["account"], json!({ "name": "SOC" }));
1241        assert_eq!(
1242            serialized["template"],
1243            json!({ "name": "SOC-with-alert-id" })
1244        );
1245    }
1246
1247    #[test]
1248    fn edit_ticket_data_serializes_optional_name_fields_as_objects() {
1249        let data = EditTicketData {
1250            subject: "test".to_string(),
1251            status: Status {
1252                id: "1".to_string(),
1253                name: "Open".to_string(),
1254                color: None,
1255            },
1256            description: None,
1257            requester: Some(UserInfo {
1258                id: UserID("123".to_string()),
1259                name: "NETXP".to_string(),
1260                ..Default::default()
1261            }),
1262            priority: Some(Priority::high()),
1263            udf_fields: None,
1264        };
1265
1266        let serialized = serde_json::to_value(&data).unwrap();
1267
1268        assert_eq!(serialized["requester"]["name"], "NETXP");
1269        assert_eq!(serialized["priority"]["name"], "High");
1270        assert!(serialized["description"].is_null());
1271        assert_eq!(serialized["status"]["name"], "Open");
1272    }
1273}