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