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                u32::from(status.as_u16()),
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/{ticket_id}/notes"),
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/{ticket_id}/worklogs"),
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/{ticket_id}/notes/{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/{ticket_id}/notes"),
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/{ticket_id}/notes/{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/{ticket_id}/notes/{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/{ticket_id}/assign"),
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/{ticket_id}/close"),
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/{ticket_id}/merge_requests"),
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    #[must_use]
817    pub fn open() -> Self {
818        Status {
819            id: STATUS_ID_OPEN.to_string(),
820            name: "Open".to_string(),
821            color: Some("#0066ff".to_string()),
822        }
823    }
824
825    #[must_use]
826    pub fn assigned() -> Self {
827        Status {
828            id: STATUS_ID_ASSIGNED.to_string(),
829            name: "Assigned".to_string(),
830            // blue
831            color: Some("#0000ff".to_string()),
832        }
833    }
834
835    #[must_use]
836    pub fn cancelled() -> Self {
837        Status {
838            id: STATUS_ID_CANCELLED.to_string(),
839            name: "Cancelled".to_string(),
840            // grey
841            color: Some("#999999".to_string()),
842        }
843    }
844
845    #[must_use]
846    pub fn closed() -> Self {
847        Status {
848            id: STATUS_ID_CLOSED.to_string(),
849            name: "Closed".to_string(),
850            color: Some("#006600".to_string()),
851        }
852    }
853
854    #[must_use]
855    pub fn in_progress() -> Self {
856        Status {
857            id: STATUS_ID_IN_PROGRESS.to_string(),
858            name: "In Progress".to_string(),
859            color: Some("#00ffcc".to_string()),
860        }
861    }
862
863    #[must_use]
864    pub fn onhold() -> Self {
865        Status {
866            id: STATUS_ID_ONHOLD.to_string(),
867            name: "On Hold".to_string(),
868            color: Some("#ff0000".to_string()),
869        }
870    }
871
872    #[must_use]
873    pub fn resolved() -> Self {
874        Status {
875            id: STATUS_ID_RESOLVED.to_string(),
876            name: "Resolved".to_string(),
877            color: Some("#00ff66".to_string()),
878        }
879    }
880}
881
882/// Priority structure representing the priority of a ticket in SDP.
883/// Contains an ID, name, and an optional color for visual representation.
884///
885/// 'Not specified' priority is represented by None, which is the default value for the Priority struct.
886#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
887pub struct Priority {
888    pub id: String,
889    pub name: String,
890    pub color: Option<String>,
891}
892
893pub const PRIORITY_ID_LOW: u64 = 1;
894pub const PRIORITY_ID_MEDIUM: u64 = 3;
895pub const PRIORITY_ID_HIGH: u64 = 4;
896pub const PRIORITY_ID_CRITICAL: u64 = 301;
897
898// priority: Some(
899//     Priority {
900//         id: "1",
901//         name: "Low",
902//         color: Some(
903//             "#288251",
904//         ),
905//     },
906//
907// priority: Some(
908//     Priority {
909//         id: "3",
910//         name: "Medium",
911//         color: Some(
912//             "#efb116",
913//         ),
914//     },
915// ),
916//
917//     Priority {
918//         priority: Some(
919//         id: "4",
920//         name: "High",
921//         color: Some(
922//             "#ff5e00",
923//         ),
924//     },
925// ),
926//
927// priority: Some(
928//     Priority {
929//         id: "301",
930//         name: "Critical",
931//         color: Some(
932//             "#8b0808",
933//         ),
934//     },
935// ),
936impl Priority {
937    #[must_use]
938    pub fn low() -> Self {
939        Priority {
940            id: PRIORITY_ID_LOW.to_string(),
941            name: "Low".to_string(),
942            color: Some("#288251".to_string()),
943        }
944    }
945
946    #[must_use]
947    pub fn medium() -> Self {
948        Priority {
949            id: PRIORITY_ID_MEDIUM.to_string(),
950            name: "Medium".to_string(),
951            color: Some("#efb116".to_string()),
952        }
953    }
954
955    #[must_use]
956    pub fn high() -> Self {
957        Priority {
958            id: PRIORITY_ID_HIGH.to_string(),
959            name: "High".to_string(),
960            color: Some("#ff5e00".to_string()),
961        }
962    }
963
964    /// Suspiciously high internal ID, might be specific to our SDP instance.
965    /// Please verify on your end if this ID is correct for the Critical priority, or if it needs to be adjusted.
966    #[must_use]
967    pub fn critical() -> Self {
968        Priority {
969            id: PRIORITY_ID_CRITICAL.to_string(),
970            name: "Critical".to_string(),
971            color: Some("#8b0808".to_string()),
972        }
973    }
974}
975
976#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
977pub struct UserInfo {
978    pub id: UserID,
979    pub name: String,
980    pub email_id: Option<String>,
981    pub account: Option<Value>,
982    pub department: Option<Value>,
983    #[serde(default)]
984    pub is_vipuser: bool,
985    pub mobile: Option<String>,
986    pub org_user_status: Option<String>,
987    pub phone: Option<String>,
988    #[serde(skip_serializing)]
989    pub profile_pic: Option<Value>,
990}
991
992#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
993pub struct Resolution {
994    pub content: Option<String>,
995    pub submitted_by: Option<UserInfo>,
996    pub submitted_on: Option<TimeEntry>,
997    pub resolution_attachments: Option<Vec<Attachment>>,
998}
999
1000#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
1001pub struct Attachment {
1002    pub id: String,
1003    pub name: String,
1004    pub content_url: String,
1005    pub content_type: Option<String>,
1006    pub description: Option<String>,
1007    pub module: Option<String>,
1008    pub size: Option<SizeInfo>,
1009    pub attached_by: Option<UserInfo>,
1010    pub attached_on: Option<TimeEntry>,
1011}
1012
1013#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1014pub struct SizeInfo {
1015    pub display_value: String,
1016    pub value: u64,
1017}
1018
1019#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1020pub struct TimeEntry {
1021    pub display_value: String,
1022    pub value: String,
1023}
1024
1025#[derive(Serialize, Debug)]
1026struct CreateTicketRequest<'a> {
1027    request: &'a CreateTicketData,
1028}
1029
1030#[derive(Serialize, Deserialize, Debug, PartialEq)]
1031pub struct CreateTicketData {
1032    pub subject: String,
1033    pub description: String,
1034    #[serde(
1035        serialize_with = "serialize_name_object",
1036        deserialize_with = "deserialize_name_object"
1037    )]
1038    pub requester: String,
1039    pub priority: Priority,
1040    // Can't do much here, since these fields seem to be dynamically defined
1041    // per template at SDP. They need to be explicitly deserialized by the user
1042    // after we've converted them to plain serde_json::Value.
1043    pub udf_fields: Value,
1044    #[serde(
1045        serialize_with = "serialize_name_object",
1046        deserialize_with = "deserialize_name_object"
1047    )]
1048    pub account: String,
1049    #[serde(
1050        serialize_with = "serialize_name_object",
1051        deserialize_with = "deserialize_name_object"
1052    )]
1053    pub template: String,
1054}
1055
1056impl Default for CreateTicketData {
1057    fn default() -> Self {
1058        CreateTicketData {
1059            subject: String::new(),
1060            description: String::new(),
1061            requester: String::new(),
1062            priority: Priority::medium(),
1063            udf_fields: Value::Null,
1064            account: String::new(),
1065            template: String::new(),
1066        }
1067    }
1068}
1069
1070pub(crate) fn deserialize_name_object<'de, D>(deserializer: D) -> Result<String, D::Error>
1071where
1072    D: Deserializer<'de>,
1073{
1074    #[derive(Deserialize)]
1075    struct NameObject {
1076        name: String,
1077    }
1078
1079    Ok(NameObject::deserialize(deserializer)?.name)
1080}
1081
1082pub(crate) fn serialize_name_object<S>(name: &String, serializer: S) -> Result<S::Ok, S::Error>
1083where
1084    S: Serializer,
1085{
1086    let mut s = serializer.serialize_struct("NameWrapper", 1)?;
1087    s.serialize_field("name", name)?;
1088    s.end()
1089}
1090
1091#[allow(dead_code)]
1092#[derive(Serialize, Debug, PartialEq, Eq)]
1093pub(crate) struct NameWrapper {
1094    pub(crate) name: String,
1095}
1096
1097impl From<&str> for NameWrapper {
1098    fn from(name: &str) -> Self {
1099        Self {
1100            name: name.to_string(),
1101        }
1102    }
1103}
1104
1105impl From<String> for NameWrapper {
1106    fn from(name: String) -> Self {
1107        Self { name }
1108    }
1109}
1110
1111impl std::ops::Deref for NameWrapper {
1112    type Target = String;
1113
1114    fn deref(&self) -> &Self::Target {
1115        &self.name
1116    }
1117}
1118
1119impl std::ops::DerefMut for NameWrapper {
1120    fn deref_mut(&mut self) -> &mut Self::Target {
1121        &mut self.name
1122    }
1123}
1124
1125#[derive(Serialize, Debug, PartialEq, Eq)]
1126struct CloseTicketRequest {
1127    request: CloseTicketData,
1128}
1129
1130#[derive(Serialize, Debug, PartialEq, Eq)]
1131struct CloseTicketData {
1132    closure_info: ClosureInfo,
1133}
1134
1135#[derive(Serialize, Debug, PartialEq, Eq)]
1136struct ClosureInfo {
1137    closure_comments: String,
1138    closure_code: String,
1139}
1140
1141#[derive(Serialize, Debug)]
1142struct AddNoteRequest<'a> {
1143    note: &'a NoteData,
1144}
1145
1146#[derive(Serialize, Debug)]
1147struct AddWorklogRequest<'a> {
1148    worklog: &'a WorklogData,
1149}
1150
1151#[derive(Serialize, Debug, Default, PartialEq, Eq)]
1152pub struct NoteData {
1153    pub mark_first_response: bool,
1154    pub add_to_linked_requests: bool,
1155    pub notify_technician: bool,
1156    pub show_to_requester: bool,
1157    pub description: String,
1158}
1159
1160// Note response structures
1161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1162pub(crate) struct NoteResponse {
1163    pub(crate) note: Note,
1164}
1165
1166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1167pub struct NotesListResponse {
1168    pub list_info: Option<ListInfoResponse>,
1169    pub notes: Vec<Note>,
1170    pub response_status: Vec<ResponseStatus>,
1171}
1172
1173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1174pub struct ListInfoResponse {
1175    pub has_more_rows: bool,
1176    pub page: u32,
1177    pub row_count: u32,
1178    pub sort_field: String,
1179    pub sort_order: String,
1180    pub start_index: u32,
1181}
1182
1183#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1184pub struct Note {
1185    pub id: NoteID,
1186    #[serde(default)]
1187    pub description: String,
1188    #[serde(default)]
1189    pub show_to_requester: bool,
1190    #[serde(default)]
1191    pub mark_first_response: bool,
1192    #[serde(default)]
1193    pub notify_technician: bool,
1194    #[serde(default)]
1195    pub add_to_linked_requests: bool,
1196    pub created_time: Option<TimeEntry>,
1197    pub created_by: Option<UserInfo>,
1198    pub last_updated_time: Option<TimeEntry>,
1199}
1200
1201#[derive(Serialize, Debug)]
1202struct EditNoteRequest<'a> {
1203    request_note: &'a NoteData,
1204}
1205
1206#[derive(Serialize, Debug)]
1207struct ListNotesRequest {
1208    list_info: NotesListInfo,
1209}
1210
1211#[derive(Debug, PartialEq, Eq, Deserialize)]
1212struct ConversationsResponse {
1213    #[serde(default)]
1214    conversations: Vec<ConversationSummary>,
1215}
1216
1217#[derive(Debug, PartialEq, Eq, Deserialize)]
1218struct ConversationSummary {
1219    #[serde(default)]
1220    has_attachments: bool,
1221    #[serde(default)]
1222    content_url: Option<String>,
1223}
1224
1225fn normalize_attachment_url(base_url: &reqwest::Url, value: &str) -> Result<String, Error> {
1226    Ok(base_url.join(value)?.to_string())
1227}
1228
1229#[derive(Serialize, Debug, PartialEq, Eq)]
1230struct NotesListInfo {
1231    row_count: u32,
1232    start_index: u32,
1233}
1234
1235#[derive(Serialize, Debug, PartialEq, Eq)]
1236struct AssignTicketRequest {
1237    request: AssignTicketData,
1238}
1239
1240#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
1241struct AssignTicketData {
1242    #[serde(
1243        serialize_with = "serialize_name_object",
1244        deserialize_with = "deserialize_name_object"
1245    )]
1246    technician: String,
1247}
1248
1249#[derive(Serialize, Debug, PartialEq, Eq)]
1250struct MergeTicketsRequest {
1251    merge_requests: Vec<MergeRequestId>,
1252}
1253
1254#[derive(Serialize, Debug, PartialEq, Eq)]
1255struct MergeRequestId {
1256    id: String,
1257}
1258
1259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1260pub(crate) struct TicketResponse {
1261    pub(crate) request: TicketData,
1262    pub(crate) response_status: ResponseStatus,
1263}
1264
1265#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1266pub struct TicketData {
1267    pub id: TicketID,
1268    pub subject: String,
1269    pub description: Option<String>,
1270    pub status: Status,
1271    pub priority: Option<Priority>,
1272    pub created_time: TimeEntry,
1273    pub requester: Option<UserInfo>,
1274    pub account: Account,
1275    pub template: TemplateInfo,
1276    pub udf_fields: Option<Value>,
1277}
1278
1279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1280pub struct TemplateInfo {
1281    pub id: String,
1282    pub name: String,
1283}
1284
1285#[cfg(test)]
1286mod tests {
1287    use super::*;
1288    use serde_json::json;
1289
1290    #[test]
1291    fn criteria_default() {
1292        let criteria = Criteria::default();
1293        assert!(criteria.field.is_empty());
1294        assert!(matches!(criteria.condition, Condition::Is));
1295        assert!(criteria.value.is_null());
1296        assert!(criteria.children.is_empty());
1297        assert!(criteria.logical_operator.is_none());
1298    }
1299
1300    #[test]
1301    fn create_ticket_data_default() {
1302        let data = CreateTicketData::default();
1303        assert!(data.subject.is_empty());
1304        assert!(data.description.is_empty());
1305        assert!(data.requester.is_empty());
1306        assert_eq!(data.priority, Priority::medium());
1307        assert!(data.udf_fields.is_null());
1308        assert!(data.account.is_empty());
1309        assert!(data.template.is_empty());
1310    }
1311
1312    #[test]
1313    fn create_ticket_data_serializes_name_fields_as_objects() {
1314        let data = CreateTicketData {
1315            subject: "test".to_string(),
1316            description: "body".to_string(),
1317            requester: "NETXP".to_string(),
1318            priority: Priority::high(),
1319            udf_fields: json!({}),
1320            account: "SOC".to_string(),
1321            template: "SOC-with-alert-id".to_string(),
1322        };
1323
1324        let serialized = serde_json::to_value(&data).unwrap();
1325        println!("Serialized CreateTicketData: {}", serialized);
1326
1327        assert_eq!(serialized["requester"], json!({ "name": "NETXP" }));
1328        assert_eq!(
1329            serialized["priority"],
1330            json!({"color": "#ff5e00", "id": "4", "name": "High"})
1331        );
1332        assert_eq!(serialized["account"], json!({ "name": "SOC" }));
1333        assert_eq!(
1334            serialized["template"],
1335            json!({ "name": "SOC-with-alert-id" })
1336        );
1337    }
1338
1339    #[test]
1340    fn edit_ticket_data_serializes_optional_name_fields_as_objects() {
1341        let data = EditTicketData {
1342            subject: "test".to_string(),
1343            status: Status {
1344                id: "1".to_string(),
1345                name: "Open".to_string(),
1346                color: None,
1347            },
1348            description: None,
1349            requester: Some(UserInfo {
1350                id: UserID("123".to_string()),
1351                name: "NETXP".to_string(),
1352                ..Default::default()
1353            }),
1354            priority: Some(Priority::high()),
1355            udf_fields: None,
1356        };
1357
1358        let serialized = serde_json::to_value(&data).unwrap();
1359
1360        assert_eq!(serialized["requester"]["name"], "NETXP");
1361        assert_eq!(serialized["priority"]["name"], "High");
1362        assert!(serialized["description"].is_null());
1363        assert_eq!(serialized["status"]["name"], "Open");
1364    }
1365}