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