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