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#[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 if let Some(message) = status.messages.into_iter().flatten().next() {
30 return Error::from_sdp_message(message.status_code, message.message);
31 }
32
33 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#[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 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 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 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 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 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 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 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 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 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 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 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 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#[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#[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#[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#[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 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 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 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#[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
898impl 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 #[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 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#[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}