Skip to main content

sdp_request_client/
client.rs

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