1pub mod duration;
7pub mod error;
8pub mod models;
9pub mod parse;
10pub mod query;
11
12use std::time::Duration;
13
14use backon::{ExponentialBuilder, Retryable};
15use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderName, HeaderValue, USER_AGENT};
16
17use serde_json::Value;
18
19use crate::api::error::ApiError;
20use crate::api::models::{
21 Attachment, Change, ChecklistItem, Comment, DictEntry, Entity, Issue, Link, Page, Person, User,
22 Worklog,
23};
24use crate::config::OrgKind;
25
26pub const DEFAULT_BASE_URL: &str = "https://api.tracker.yandex.net";
28
29const ENTITY_FIELDS: &str = "summary,description,entityStatus,start,end,lead,author,parentEntity";
35
36fn host_of(url: &str) -> Option<String> {
38 let without_scheme = url.split_once("://")?.1;
39 let authority = without_scheme
40 .split(['/', '?', '#'])
41 .next()
42 .unwrap_or(without_scheme);
43 Some(authority.to_ascii_lowercase())
44}
45
46#[derive(Debug, Clone)]
48pub struct ClientConfig {
49 pub base_url: String,
50 pub token: String,
51 pub org_id: String,
52 pub org_kind: OrgKind,
53 pub timeout: Duration,
54 pub retries: usize,
56}
57
58impl ClientConfig {
59 #[must_use]
60 pub fn new(token: String, org_id: String, org_kind: OrgKind) -> Self {
61 Self {
62 base_url: DEFAULT_BASE_URL.to_owned(),
63 token,
64 org_id,
65 org_kind,
66 timeout: Duration::from_secs(30),
67 retries: 3,
68 }
69 }
70}
71
72#[derive(Debug, Clone)]
74pub struct Client {
75 http: reqwest::Client,
76 base_url: String,
77 retries: usize,
78}
79
80impl Client {
81 pub fn new(config: &ClientConfig) -> Result<Self, ApiError> {
82 let mut headers = HeaderMap::new();
83 headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
84 headers.insert(
85 USER_AGENT,
86 HeaderValue::from_static(concat!("ytcli/", env!("CARGO_PKG_VERSION"))),
87 );
88
89 let mut auth = HeaderValue::try_from(format!("OAuth {}", config.token))
91 .map_err(|_| ApiError::Unauthorized)?;
92 auth.set_sensitive(true);
93 headers.insert(AUTHORIZATION, auth);
94
95 let org_header = HeaderName::from_static(config.org_kind.header_name());
96 let org_value =
97 HeaderValue::try_from(config.org_id.clone()).map_err(|_| ApiError::Forbidden)?;
98 headers.insert(org_header, org_value);
99
100 let http = reqwest::Client::builder()
101 .timeout(config.timeout)
102 .default_headers(headers)
103 .build()?;
104
105 Ok(Self {
106 http,
107 base_url: config.base_url.trim_end_matches('/').to_owned(),
108 retries: config.retries,
109 })
110 }
111
112 pub async fn myself(&self) -> Result<User, ApiError> {
115 let value = self.get_value("/v3/myself", "current user").await?;
116 Ok(User {
117 id: value
118 .get("uid")
119 .map_or_else(String::new, ToString::to_string),
120 login: value
121 .get("login")
122 .and_then(serde_json::Value::as_str)
123 .map(ToOwned::to_owned),
124 display: value
125 .get("display")
126 .and_then(serde_json::Value::as_str)
127 .map(ToOwned::to_owned),
128 })
129 }
130
131 pub async fn issue(&self, key: &str) -> Result<(Issue, Value), ApiError> {
136 let raw = self
137 .get_value(&format!("/v3/issues/{key}"), &format!("issue {key}"))
138 .await?;
139 let issue = parse::issue(&raw).ok_or_else(|| ApiError::NotFound(format!("issue {key}")))?;
140 Ok((issue, raw))
141 }
142
143 pub async fn issue_links(&self, key: &str) -> Result<Vec<Link>, ApiError> {
150 let raw = self
151 .get_value(
152 &format!("/v3/issues/{key}/links"),
153 &format!("issue {key} links"),
154 )
155 .await?;
156
157 Ok(raw
158 .as_array()
159 .map(|entries| entries.iter().filter_map(parse::link).collect())
160 .unwrap_or_default())
161 }
162
163 pub async fn search(
169 &self,
170 query: &str,
171 page: u32,
172 per_page: u32,
173 ) -> Result<Page<Issue>, ApiError> {
174 let path = format!("/v3/issues/_search?page={page}&perPage={per_page}");
175 let body = serde_json::json!({ "query": query });
176 let (value, headers) = self.post_value(&path, &body, "issues").await?;
177
178 let items = value
179 .as_array()
180 .map(|entries| entries.iter().filter_map(parse::issue).collect())
181 .unwrap_or_default();
182
183 Ok(Page {
184 items,
185 page,
186 per_page,
187 total: headers
188 .get("x-total-count")
189 .and_then(|count| count.to_str().ok())
190 .and_then(|count| count.parse().ok()),
191 })
192 }
193
194 pub async fn count(&self, query: &str) -> Result<u64, ApiError> {
196 let body = serde_json::json!({ "query": query });
197 let (value, _) = self
198 .post_value("/v3/issues/_count", &body, "issues")
199 .await?;
200
201 value
202 .as_u64()
203 .ok_or_else(|| ApiError::NotFound("issue count".to_owned()))
204 }
205
206 pub async fn create_issue(&self, body: &Value) -> Result<Issue, ApiError> {
208 let (value, _) = self.post_value("/v3/issues/", body, "issue").await?;
209 parse::issue(&value).ok_or_else(|| ApiError::NotFound("created issue".to_owned()))
210 }
211
212 pub async fn update_issue(&self, key: &str, body: &Value) -> Result<Issue, ApiError> {
214 let value = self
215 .send_value(
216 reqwest::Method::PATCH,
217 &format!("/v3/issues/{key}"),
218 Some(body),
219 &format!("issue {key}"),
220 )
221 .await?
222 .0;
223 parse::issue(&value).ok_or_else(|| ApiError::NotFound(format!("issue {key}")))
224 }
225
226 pub async fn add_comment(&self, key: &str, text: &str) -> Result<Comment, ApiError> {
228 let body = serde_json::json!({ "text": text });
229 let (value, _) = self
230 .post_value(
231 &format!("/v3/issues/{key}/comments"),
232 &body,
233 &format!("issue {key}"),
234 )
235 .await?;
236 parse::comment(&value).ok_or_else(|| ApiError::NotFound("created comment".to_owned()))
237 }
238
239 pub async fn update_comment(
244 &self,
245 key: &str,
246 id: &str,
247 text: &str,
248 ) -> Result<Comment, ApiError> {
249 let body = serde_json::json!({ "text": text });
250 let (value, _) = self
251 .send_value(
252 reqwest::Method::PATCH,
253 &format!("/v3/issues/{key}/comments/{id}"),
254 Some(&body),
255 &format!("comment {id} of issue {key}"),
256 )
257 .await?;
258 parse::comment(&value).ok_or_else(|| ApiError::NotFound(format!("comment {id}")))
259 }
260
261 pub async fn delete_comment(&self, key: &str, id: &str) -> Result<(), ApiError> {
263 self.send_value(
264 reqwest::Method::DELETE,
265 &format!("/v3/issues/{key}/comments/{id}"),
266 None,
267 &format!("comment {id} of issue {key}"),
268 )
269 .await?;
270 Ok(())
271 }
272
273 pub async fn update_worklog(
275 &self,
276 key: &str,
277 id: &str,
278 body: &Value,
279 ) -> Result<Worklog, ApiError> {
280 let (value, _) = self
281 .send_value(
282 reqwest::Method::PATCH,
283 &format!("/v3/issues/{key}/worklog/{id}"),
284 Some(body),
285 &format!("worklog {id} of issue {key}"),
286 )
287 .await?;
288 parse::worklog(&value).ok_or_else(|| ApiError::NotFound(format!("worklog {id}")))
289 }
290
291 pub async fn worklogs(&self, key: &str) -> Result<Vec<Worklog>, ApiError> {
293 let raw = self
294 .get_value(
295 &format!("/v3/issues/{key}/worklog"),
296 &format!("issue {key} worklog"),
297 )
298 .await?;
299
300 Ok(raw
301 .as_array()
302 .map(|entries| entries.iter().filter_map(parse::worklog).collect())
303 .unwrap_or_default())
304 }
305
306 pub async fn add_worklog(&self, key: &str, body: &Value) -> Result<Worklog, ApiError> {
308 let (value, _) = self
309 .post_value(
310 &format!("/v3/issues/{key}/worklog"),
311 body,
312 &format!("issue {key} worklog"),
313 )
314 .await?;
315 parse::worklog(&value).ok_or_else(|| ApiError::NotFound("created worklog".to_owned()))
316 }
317
318 pub async fn delete_worklog(&self, key: &str, id: &str) -> Result<(), ApiError> {
320 self.send_value(
321 reqwest::Method::DELETE,
322 &format!("/v3/issues/{key}/worklog/{id}"),
323 None,
324 &format!("worklog {id} of issue {key}"),
325 )
326 .await?;
327 Ok(())
328 }
329
330 pub async fn checklist(&self, key: &str) -> Result<Vec<ChecklistItem>, ApiError> {
332 let raw = self
333 .get_value(
334 &format!("/v3/issues/{key}/checklistItems"),
335 &format!("issue {key} checklist"),
336 )
337 .await?;
338
339 Ok(raw
340 .as_array()
341 .map(|entries| entries.iter().filter_map(parse::checklist_item).collect())
342 .unwrap_or_default())
343 }
344
345 pub async fn add_checklist_item(
350 &self,
351 key: &str,
352 body: &Value,
353 ) -> Result<Vec<ChecklistItem>, ApiError> {
354 let (value, _) = self
355 .post_value(
356 &format!("/v3/issues/{key}/checklistItems"),
357 body,
358 &format!("issue {key} checklist"),
359 )
360 .await?;
361 Ok(checklist_of(&value))
362 }
363
364 pub async fn update_checklist_item(
366 &self,
367 key: &str,
368 id: &str,
369 body: &Value,
370 ) -> Result<Vec<ChecklistItem>, ApiError> {
371 let (value, _) = self
372 .send_value(
373 reqwest::Method::PATCH,
374 &format!("/v3/issues/{key}/checklistItems/{id}"),
375 Some(body),
376 &format!("checklist item {id} of issue {key}"),
377 )
378 .await?;
379 Ok(checklist_of(&value))
380 }
381
382 pub async fn delete_checklist_item(&self, key: &str, id: &str) -> Result<(), ApiError> {
384 self.send_value(
385 reqwest::Method::DELETE,
386 &format!("/v3/issues/{key}/checklistItems/{id}"),
387 None,
388 &format!("checklist item {id} of issue {key}"),
389 )
390 .await?;
391 Ok(())
392 }
393
394 pub async fn add_link(
396 &self,
397 key: &str,
398 relationship: &str,
399 other: &str,
400 ) -> Result<(), ApiError> {
401 let body = serde_json::json!({ "relationship": relationship, "issue": other });
402 self.post_value(
403 &format!("/v3/issues/{key}/links"),
404 &body,
405 &format!("issue {key} links"),
406 )
407 .await?;
408 Ok(())
409 }
410
411 pub async fn delete_link(&self, key: &str, id: &str) -> Result<(), ApiError> {
413 self.send_value(
414 reqwest::Method::DELETE,
415 &format!("/v3/issues/{key}/links/{id}"),
416 None,
417 &format!("link {id} of issue {key}"),
418 )
419 .await?;
420 Ok(())
421 }
422
423 pub async fn transitions(&self, key: &str) -> Result<Vec<Transition>, ApiError> {
425 let raw = self
426 .get_value(
427 &format!("/v3/issues/{key}/transitions"),
428 &format!("issue {key} transitions"),
429 )
430 .await?;
431
432 Ok(raw
433 .as_array()
434 .map(|entries| entries.iter().filter_map(Transition::parse).collect())
435 .unwrap_or_default())
436 }
437
438 pub async fn execute_transition(
440 &self,
441 key: &str,
442 transition: &str,
443 body: &Value,
444 ) -> Result<(), ApiError> {
445 self.post_value(
446 &format!("/v3/issues/{key}/transitions/{transition}/_execute"),
447 body,
448 &format!("transition {transition} of issue {key}"),
449 )
450 .await?;
451 Ok(())
452 }
453
454 pub async fn entities(
460 &self,
461 kind: &str,
462 input: Option<&str>,
463 page: u32,
464 per_page: u32,
465 ) -> Result<Page<Entity>, ApiError> {
466 let path = format!(
467 "/v3/entities/{kind}/_search?page={page}&perPage={per_page}&fields={ENTITY_FIELDS}"
468 );
469 let mut body = serde_json::Map::new();
470 if let Some(input) = input {
471 body.insert("input".to_owned(), Value::String(input.to_owned()));
472 }
473
474 let (value, _) = self
475 .post_value(&path, &Value::Object(body), &format!("{kind}s"))
476 .await?;
477
478 let items = value
479 .get("values")
480 .and_then(Value::as_array)
481 .map(|entries| entries.iter().filter_map(parse::entity).collect())
482 .unwrap_or_default();
483
484 Ok(Page {
485 items,
486 page,
487 per_page,
488 total: value.get("hits").and_then(Value::as_u64),
489 })
490 }
491
492 pub async fn entities_in(
498 &self,
499 parent: &str,
500 page: u32,
501 per_page: u32,
502 ) -> Result<Page<Entity>, ApiError> {
503 let mut items = Vec::new();
504 let mut total = 0;
505
506 for kind in ["portfolio", "project"] {
507 let path = format!(
508 "/v3/entities/{kind}/_search?page={page}&perPage={per_page}&fields={ENTITY_FIELDS}"
509 );
510 let body = serde_json::json!({ "filter": { "parentEntity": parent } });
511 let (value, _) = self
512 .post_value(&path, &body, &format!("{kind}s in {parent}"))
513 .await?;
514
515 if let Some(entries) = value.get("values").and_then(Value::as_array) {
516 items.extend(entries.iter().filter_map(parse::entity));
517 }
518 total += value.get("hits").and_then(Value::as_u64).unwrap_or(0);
519 }
520
521 Ok(Page {
522 items,
523 page,
524 per_page,
525 total: Some(total),
526 })
527 }
528
529 pub async fn entity(&self, kind: &str, id: &str) -> Result<Entity, ApiError> {
531 let raw = self
532 .get_value(
533 &format!("/v3/entities/{kind}/{id}?fields={ENTITY_FIELDS}"),
534 &format!("{kind} {id}"),
535 )
536 .await?;
537
538 parse::entity(&raw).ok_or_else(|| ApiError::NotFound(format!("{kind} {id}")))
539 }
540
541 pub async fn attachments(&self, key: &str) -> Result<Vec<Attachment>, ApiError> {
543 let raw = self
544 .get_value(
545 &format!("/v3/issues/{key}/attachments"),
546 &format!("issue {key} attachments"),
547 )
548 .await?;
549
550 Ok(raw
551 .as_array()
552 .map(|entries| entries.iter().filter_map(parse::attachment).collect())
553 .unwrap_or_default())
554 }
555
556 pub async fn download(&self, url: &str) -> Result<Vec<u8>, ApiError> {
563 let expected = host_of(&self.base_url);
564 if host_of(url) != expected {
565 return Err(ApiError::Rejected {
566 status: reqwest::StatusCode::BAD_REQUEST,
567 message: format!(
568 "attachment points at `{}`, which is not the configured Tracker host `{}`",
569 host_of(url).unwrap_or_default(),
570 expected.unwrap_or_default(),
571 ),
572 });
573 }
574
575 let response = self.http.get(url).send().await?;
576 let status = response.status();
577 if !status.is_success() {
578 return Err(match status.as_u16() {
579 401 => ApiError::Unauthorized,
580 403 => ApiError::Forbidden,
581 404 => ApiError::NotFound("attachment".to_owned()),
582 _ => ApiError::Rejected {
583 status,
584 message: String::new(),
585 },
586 });
587 }
588
589 Ok(response.bytes().await?.to_vec())
590 }
591
592 pub async fn upload(
594 &self,
595 key: &str,
596 filename: &str,
597 bytes: Vec<u8>,
598 ) -> Result<Attachment, ApiError> {
599 let part = reqwest::multipart::Part::bytes(bytes).file_name(filename.to_owned());
600 let form = reqwest::multipart::Form::new().part("file", part);
601
602 let url = format!("{}/v3/issues/{key}/attachments/", self.base_url);
603 let response = self.http.post(&url).multipart(form).send().await?;
604 let text = classify(response, &format!("issue {key}")).await?;
605
606 let value: Value = serde_json::from_str(&text).map_err(ApiError::Decode)?;
607 parse::attachment(&value)
608 .ok_or_else(|| ApiError::NotFound("uploaded attachment".to_owned()))
609 }
610
611 pub async fn queues(&self) -> Result<Vec<Queue>, ApiError> {
617 let raw = self.get_value("/v3/queues?perPage=1000", "queues").await?;
618
619 Ok(raw
620 .as_array()
621 .map(|entries| entries.iter().filter_map(Queue::parse).collect())
622 .unwrap_or_default())
623 }
624
625 pub async fn worklog_search(
631 &self,
632 who: Option<&str>,
633 since: Option<&str>,
634 until: Option<&str>,
635 per_page: u32,
636 ) -> Result<Vec<Worklog>, ApiError> {
637 use std::fmt::Write as _;
638
639 let mut query = format!("perPage={per_page}");
640 if let Some(who) = who {
641 let _ = write!(query, "&createdBy={who}");
642 }
643 match (since, until) {
646 (Some(since), Some(until)) => {
647 let _ = write!(query, "&createdAt=from:{since},to:{until}");
648 }
649 (Some(since), None) => {
650 let _ = write!(query, "&createdAt=from:{since}");
651 }
652 (None, Some(until)) => {
653 let _ = write!(query, "&createdAt=to:{until}");
654 }
655 (None, None) => {}
656 }
657
658 let raw = self
659 .get_value(&format!("/v3/worklog?{query}"), "worklog")
660 .await?;
661
662 Ok(raw
663 .as_array()
664 .map(|entries| entries.iter().filter_map(parse::worklog).collect())
665 .unwrap_or_default())
666 }
667
668 pub async fn move_issue(
675 &self,
676 key: &str,
677 queue: &str,
678 keep_fields: bool,
679 initial_status: bool,
680 ) -> Result<Issue, ApiError> {
681 let path = format!(
682 "/v3/issues/{key}/_move?queue={queue}&moveAllFields={keep_fields}&initialStatus={initial_status}"
683 );
684 let (raw, _) = self
685 .send_value(
686 reqwest::Method::POST,
687 &path,
688 Some(&serde_json::json!({})),
689 &format!("move {key} to {queue}"),
690 )
691 .await?;
692
693 parse::issue(&raw).ok_or_else(|| ApiError::NotFound(format!("issue {key} after the move")))
694 }
695
696 pub async fn changelog(&self, key: &str, per_page: u32) -> Result<Vec<Change>, ApiError> {
703 let raw = self
704 .get_value(
705 &format!("/v3/issues/{key}/changelog?perPage={per_page}"),
706 &format!("changelog of {key}"),
707 )
708 .await?;
709
710 Ok(raw
711 .as_array()
712 .map(|entries| entries.iter().filter_map(parse::change).collect())
713 .unwrap_or_default())
714 }
715
716 pub async fn queue_versions(&self, key: &str) -> Result<Vec<Version>, ApiError> {
721 let raw = self
722 .get_value(
723 &format!("/v3/queues/{key}/versions"),
724 &format!("versions of queue {key}"),
725 )
726 .await?;
727
728 Ok(raw
729 .as_array()
730 .map(|entries| entries.iter().filter_map(Version::parse).collect())
731 .unwrap_or_default())
732 }
733
734 pub async fn queue_tags(&self, key: &str) -> Result<Vec<String>, ApiError> {
736 let raw = self
737 .get_value(
738 &format!("/v3/queues/{key}/tags?perPage=1000"),
739 &format!("tags of queue {key}"),
740 )
741 .await?;
742
743 Ok(raw
747 .as_array()
748 .map(|entries| {
749 entries
750 .iter()
751 .filter_map(|entry| match entry {
752 Value::String(name) => Some(name.clone()),
753 other => other
754 .get("name")
755 .and_then(Value::as_str)
756 .map(ToOwned::to_owned),
757 })
758 .collect()
759 })
760 .unwrap_or_default())
761 }
762
763 pub async fn dictionary(&self, kind: Dictionary) -> Result<Vec<DictEntry>, ApiError> {
768 let raw = self
769 .get_value(&format!("/v3/{}", kind.path()), kind.path())
770 .await?;
771
772 Ok(raw
773 .as_array()
774 .map(|entries| entries.iter().filter_map(parse::dict_entry).collect())
775 .unwrap_or_default())
776 }
777
778 pub async fn users(&self, page: u32, per_page: u32) -> Result<Page<Person>, ApiError> {
784 let path = format!("/v3/users?page={page}&perPage={per_page}");
785 let (value, headers) = self
786 .send_value(reqwest::Method::GET, &path, None, "users")
787 .await?;
788
789 let items = value
790 .as_array()
791 .map(|entries| entries.iter().filter_map(parse::person).collect())
792 .unwrap_or_default();
793
794 Ok(Page {
795 items,
796 page,
797 per_page,
798 total: headers
799 .get("x-total-count")
800 .and_then(|count| count.to_str().ok())
801 .and_then(|count| count.parse().ok()),
802 })
803 }
804
805 pub async fn user(&self, who: &str) -> Result<Person, ApiError> {
810 let raw = self
811 .get_value(&format!("/v3/users/{who}"), &format!("user {who}"))
812 .await?;
813
814 parse::person(&raw).ok_or_else(|| ApiError::NotFound(format!("user {who}")))
815 }
816
817 pub async fn boards(&self) -> Result<Vec<Board>, ApiError> {
822 let raw = self.get_value("/v3/boards", "boards").await?;
823
824 Ok(raw
825 .as_array()
826 .map(|entries| entries.iter().filter_map(Board::parse).collect())
827 .unwrap_or_default())
828 }
829
830 pub async fn board(&self, id: &str) -> Result<Board, ApiError> {
832 let raw = self
833 .get_value(&format!("/v3/boards/{id}"), &format!("board {id}"))
834 .await?;
835
836 Board::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("board {id}")))
837 }
838
839 pub async fn sprints(&self, board: &str) -> Result<Vec<Sprint>, ApiError> {
847 let raw = self
848 .get_value(
849 &format!("/v3/boards/{board}/sprints"),
850 &format!("board {board} sprints"),
851 )
852 .await?;
853
854 Ok(raw
855 .as_array()
856 .map(|entries| entries.iter().filter_map(Sprint::parse).collect())
857 .unwrap_or_default())
858 }
859
860 pub async fn create_entity(&self, kind: &str, fields: &Value) -> Result<Entity, ApiError> {
865 let body = serde_json::json!({ "fields": fields });
866 let (value, _) = self
867 .post_value(
868 &format!("/v3/entities/{kind}?fields={ENTITY_FIELDS}"),
869 &body,
870 kind,
871 )
872 .await?;
873
874 parse::entity(&value).ok_or_else(|| ApiError::NotFound(kind.to_owned()))
875 }
876
877 pub async fn delete_entity(&self, kind: &str, id: &str) -> Result<(), ApiError> {
883 self.send_value(
884 reqwest::Method::DELETE,
885 &format!("/v3/entities/{kind}/{id}"),
886 None,
887 &format!("{kind} {id}"),
888 )
889 .await?;
890 Ok(())
891 }
892
893 pub async fn update_entity(
898 &self,
899 kind: &str,
900 id: &str,
901 fields: &Value,
902 version: Option<u64>,
903 ) -> Result<Entity, ApiError> {
904 let path = match version {
905 Some(version) => {
906 format!("/v3/entities/{kind}/{id}?version={version}&fields={ENTITY_FIELDS}")
907 }
908 None => format!("/v3/entities/{kind}/{id}?fields={ENTITY_FIELDS}"),
909 };
910 let body = serde_json::json!({ "fields": fields });
911
912 let (value, _) = self
913 .send_value(
914 reqwest::Method::PATCH,
915 &path,
916 Some(&body),
917 &format!("{kind} {id}"),
918 )
919 .await?;
920
921 parse::entity(&value).ok_or_else(|| ApiError::NotFound(format!("{kind} {id}")))
922 }
923
924 pub async fn place_entity(
931 &self,
932 kind: &str,
933 id: &str,
934 parent: Option<&str>,
935 version: Option<u64>,
936 ) -> Result<Entity, ApiError> {
937 let path = match version {
941 Some(version) => {
942 format!("/v3/entities/{kind}/{id}?version={version}&fields={ENTITY_FIELDS}")
943 }
944 None => format!("/v3/entities/{kind}/{id}?fields={ENTITY_FIELDS}"),
945 };
946 let body = serde_json::json!({
947 "fields": { "parentEntity": place_body(parent) }
948 });
949
950 let (value, _) = self
951 .send_value(
952 reqwest::Method::PATCH,
953 &path,
954 Some(&body),
955 &format!("{kind} {id}"),
956 )
957 .await?;
958
959 parse::entity(&value).ok_or_else(|| ApiError::NotFound(format!("{kind} {id}")))
960 }
961
962 pub async fn queue(&self, key: &str) -> Result<QueueSettings, ApiError> {
964 let raw = self
965 .get_value(&format!("/v3/queues/{key}"), &format!("queue {key}"))
966 .await?;
967
968 QueueSettings::parse(&raw).ok_or_else(|| ApiError::NotFound(format!("queue {key}")))
969 }
970
971 pub async fn queue_blueprint(&self, key: &str) -> Result<Blueprint, ApiError> {
978 let raw = self
979 .get_value(
980 &format!("/v3/queues/{key}?expand=all"),
981 &format!("queue {key}"),
982 )
983 .await?;
984
985 let named = |name: &str| {
986 raw.get(name)
987 .and_then(|field| field.get("key"))
988 .and_then(Value::as_str)
989 .map(ToOwned::to_owned)
990 };
991
992 let types = raw
993 .get("issueTypesConfig")
994 .and_then(Value::as_array)
995 .map(|entries| {
996 entries
997 .iter()
998 .filter_map(|entry| {
999 Some(serde_json::json!({
1000 "issueType": entry.get("issueType")?.get("key")?.as_str()?,
1001 "workflow": entry.get("workflow")?.get("id")?.as_str()?,
1002 "resolutions": entry
1003 .get("resolutions")
1004 .and_then(Value::as_array)
1005 .map(|resolutions| {
1006 resolutions
1007 .iter()
1008 .filter_map(|resolution| {
1009 resolution.get("key").and_then(Value::as_str)
1010 })
1011 .collect::<Vec<_>>()
1012 })
1013 .unwrap_or_default(),
1014 }))
1015 })
1016 .collect::<Vec<_>>()
1017 })
1018 .unwrap_or_default();
1019
1020 if types.is_empty() {
1021 return Err(ApiError::NotFound(format!("issue types of queue {key}")));
1022 }
1023
1024 Ok(Blueprint {
1025 default_type: named("defaultType"),
1026 default_priority: named("defaultPriority"),
1027 issue_types: types,
1028 })
1029 }
1030
1031 pub async fn create_queue(&self, body: &Value) -> Result<QueueSettings, ApiError> {
1033 let (value, _) = self.post_value("/v3/queues", body, "queue").await?;
1034
1035 QueueSettings::parse(&value)
1036 .ok_or_else(|| ApiError::NotFound("the created queue".to_owned()))
1037 }
1038
1039 pub async fn fields(&self) -> Result<Vec<QueueField>, ApiError> {
1045 let raw = self.get_value("/v3/fields", "fields").await?;
1046
1047 Ok(raw
1048 .as_array()
1049 .map(|entries| entries.iter().filter_map(QueueField::parse).collect())
1050 .unwrap_or_default())
1051 }
1052
1053 pub async fn templates(&self, kind: TemplateKind) -> Result<Vec<Template>, ApiError> {
1059 let raw = self
1060 .get_value(&format!("/v3/{}", kind.path()), kind.path())
1061 .await?;
1062
1063 Ok(raw
1064 .as_array()
1065 .map(|entries| entries.iter().filter_map(Template::parse).collect())
1066 .unwrap_or_default())
1067 }
1068
1069 pub async fn issue_comments(&self, key: &str) -> Result<Vec<Comment>, ApiError> {
1075 let raw = self
1076 .get_value(
1077 &format!("/v3/issues/{key}/comments?perPage=100"),
1078 &format!("issue {key} comments"),
1079 )
1080 .await?;
1081
1082 Ok(raw
1083 .as_array()
1084 .map(|entries| entries.iter().filter_map(parse::comment).collect())
1085 .unwrap_or_default())
1086 }
1087
1088 pub async fn queue_fields(&self, key: &str) -> Result<Vec<QueueField>, ApiError> {
1090 let raw = self
1091 .get_value(
1092 &format!("/v3/queues/{key}/fields"),
1093 &format!("queue {key} fields"),
1094 )
1095 .await?;
1096
1097 Ok(raw
1098 .as_array()
1099 .map(|entries| entries.iter().filter_map(QueueField::parse).collect())
1100 .unwrap_or_default())
1101 }
1102
1103 async fn post_value(
1106 &self,
1107 path: &str,
1108 body: &Value,
1109 what: &str,
1110 ) -> Result<(Value, reqwest::header::HeaderMap), ApiError> {
1111 self.send_value(reqwest::Method::POST, path, Some(body), what)
1112 .await
1113 }
1114
1115 async fn send_value(
1116 &self,
1117 method: reqwest::Method,
1118 path: &str,
1119 body: Option<&Value>,
1120 what: &str,
1121 ) -> Result<(Value, reqwest::header::HeaderMap), ApiError> {
1122 let url = format!("{}{path}", self.base_url);
1123
1124 let send = || async {
1125 let mut request = self.http.request(method.clone(), &url);
1126 if let Some(body) = body {
1127 request = request.json(body);
1128 }
1129 let response = request.send().await?;
1130 let headers = response.headers().clone();
1131 let text = classify(response, what).await?;
1132 Ok((text, headers))
1133 };
1134
1135 let (text, headers) = if method == reqwest::Method::GET {
1138 send.retry(
1139 ExponentialBuilder::default()
1140 .with_max_times(self.retries)
1141 .with_jitter(),
1142 )
1143 .when(is_retryable)
1144 .await?
1145 } else {
1146 send().await?
1147 };
1148
1149 let value = if text.trim().is_empty() {
1151 Value::Null
1152 } else {
1153 serde_json::from_str(&text).map_err(ApiError::Decode)?
1154 };
1155 Ok((value, headers))
1156 }
1157
1158 async fn get_value(&self, path: &str, what: &str) -> Result<Value, ApiError> {
1159 Ok(self
1160 .send_value(reqwest::Method::GET, path, None, what)
1161 .await?
1162 .0)
1163 }
1164}
1165
1166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1172pub enum Dictionary {
1173 Types,
1174 Priorities,
1175 Statuses,
1176 Resolutions,
1177}
1178
1179impl Dictionary {
1180 pub const ALL: [Self; 4] = [
1183 Self::Types,
1184 Self::Priorities,
1185 Self::Statuses,
1186 Self::Resolutions,
1187 ];
1188
1189 #[must_use]
1190 pub fn path(self) -> &'static str {
1191 match self {
1192 Self::Types => "issuetypes",
1193 Self::Priorities => "priorities",
1194 Self::Statuses => "statuses",
1195 Self::Resolutions => "resolutions",
1196 }
1197 }
1198
1199 #[must_use]
1201 pub fn label(self) -> &'static str {
1202 match self {
1203 Self::Types => "types",
1204 Self::Priorities => "priorities",
1205 Self::Statuses => "statuses",
1206 Self::Resolutions => "resolutions",
1207 }
1208 }
1209}
1210
1211#[derive(Debug, Clone, serde::Serialize)]
1213pub struct Transition {
1214 pub id: String,
1215 pub name: String,
1216 pub to: Option<String>,
1218}
1219
1220impl Transition {
1221 fn parse(value: &Value) -> Option<Self> {
1222 Some(Self {
1223 id: value.get("id").and_then(Value::as_str)?.to_owned(),
1224 name: value
1225 .get("display")
1226 .and_then(Value::as_str)
1227 .unwrap_or_default()
1228 .to_owned(),
1229 to: value
1230 .get("to")
1231 .and_then(|to| to.get("display").or_else(|| to.get("key")))
1232 .and_then(Value::as_str)
1233 .map(ToOwned::to_owned),
1234 })
1235 }
1236}
1237
1238#[derive(Debug, Clone, serde::Serialize)]
1240pub struct Queue {
1241 pub key: String,
1242 pub name: String,
1243 pub lead: Option<String>,
1244}
1245
1246impl Queue {
1247 fn parse(value: &Value) -> Option<Self> {
1248 Some(Self {
1249 key: value.get("key").and_then(Value::as_str)?.to_owned(),
1250 name: value
1251 .get("name")
1252 .and_then(Value::as_str)
1253 .unwrap_or_default()
1254 .to_owned(),
1255 lead: value
1256 .get("lead")
1257 .and_then(|lead| {
1258 lead.get("login")
1259 .or_else(|| lead.get("display"))
1260 .or_else(|| lead.get("id"))
1261 })
1262 .and_then(Value::as_str)
1263 .map(ToOwned::to_owned),
1264 })
1265 }
1266}
1267
1268#[derive(Debug, Clone, serde::Serialize)]
1270pub struct Version {
1271 pub id: String,
1272 pub name: String,
1273 pub description: Option<String>,
1274 pub state: &'static str,
1276 pub due: Option<String>,
1277}
1278
1279impl Version {
1280 fn parse(value: &Value) -> Option<Self> {
1281 let flag = |member: &str| value.get(member).and_then(Value::as_bool).unwrap_or(false);
1282
1283 Some(Self {
1284 id: match value.get("id")? {
1285 Value::String(id) => id.clone(),
1286 other => other.to_string(),
1287 },
1288 name: value
1289 .get("name")
1290 .and_then(Value::as_str)
1291 .unwrap_or_default()
1292 .to_owned(),
1293 description: value
1294 .get("description")
1295 .and_then(Value::as_str)
1296 .filter(|text| !text.is_empty())
1297 .map(ToOwned::to_owned),
1298 state: if flag("archived") {
1301 "archived"
1302 } else if flag("released") {
1303 "released"
1304 } else {
1305 "open"
1306 },
1307 due: value
1308 .get("dueDate")
1309 .and_then(Value::as_str)
1310 .map(ToOwned::to_owned),
1311 })
1312 }
1313}
1314
1315#[derive(Debug, Clone, serde::Serialize)]
1320pub struct Board {
1321 pub id: String,
1322 pub name: String,
1323 pub columns: Vec<String>,
1324 pub estimate_by: Option<String>,
1326 pub owner: Option<String>,
1327}
1328
1329impl Board {
1330 fn parse(value: &Value) -> Option<Self> {
1331 Some(Self {
1332 id: match value.get("id")? {
1333 Value::String(id) => id.clone(),
1334 other => other.to_string(),
1335 },
1336 name: value
1337 .get("name")
1338 .and_then(Value::as_str)
1339 .unwrap_or_default()
1340 .to_owned(),
1341 columns: value
1342 .get("columns")
1343 .and_then(Value::as_array)
1344 .map(|columns| {
1345 columns
1346 .iter()
1347 .filter_map(|column| {
1348 column
1349 .get("display")
1350 .or_else(|| column.get("id"))
1351 .and_then(Value::as_str)
1352 .map(ToOwned::to_owned)
1353 })
1354 .collect()
1355 })
1356 .unwrap_or_default(),
1357 estimate_by: value
1358 .get("estimateBy")
1359 .and_then(|field| field.get("id").or_else(|| field.get("display")))
1360 .and_then(Value::as_str)
1361 .map(ToOwned::to_owned),
1362 owner: value
1365 .get("createdBy")
1366 .and_then(|user| {
1367 user.get("login")
1368 .or_else(|| user.get("display"))
1369 .or_else(|| user.get("id"))
1370 })
1371 .and_then(Value::as_str)
1372 .map(ToOwned::to_owned),
1373 })
1374 }
1375}
1376
1377#[derive(Debug, Clone, serde::Serialize)]
1379pub struct Sprint {
1380 pub id: String,
1381 pub name: String,
1382 pub status: Option<String>,
1383 pub start: Option<String>,
1384 pub end: Option<String>,
1385}
1386
1387impl Sprint {
1388 fn parse(value: &Value) -> Option<Self> {
1389 Some(Self {
1390 id: match value.get("id")? {
1391 Value::String(id) => id.clone(),
1392 other => other.to_string(),
1393 },
1394 name: value
1395 .get("name")
1396 .and_then(Value::as_str)
1397 .unwrap_or_default()
1398 .to_owned(),
1399 status: value
1400 .get("status")
1401 .and_then(Value::as_str)
1402 .map(ToOwned::to_owned),
1403 start: value
1404 .get("startDate")
1405 .and_then(Value::as_str)
1406 .map(ToOwned::to_owned),
1407 end: value
1408 .get("endDate")
1409 .and_then(Value::as_str)
1410 .map(ToOwned::to_owned),
1411 })
1412 }
1413}
1414
1415#[derive(Debug, Clone)]
1417pub struct Blueprint {
1418 pub default_type: Option<String>,
1419 pub default_priority: Option<String>,
1420 pub issue_types: Vec<Value>,
1423}
1424
1425fn place_body(parent: Option<&str>) -> Value {
1430 match parent {
1431 Some(parent) => serde_json::json!({ "primary": parent }),
1432 None => Value::Null,
1433 }
1434}
1435
1436#[derive(Debug, Clone, serde::Serialize)]
1441pub struct QueueSettings {
1442 pub key: String,
1443 pub name: String,
1444 pub lead: Option<String>,
1445 pub default_type: Option<String>,
1446 pub default_priority: Option<String>,
1447 pub version: Option<u64>,
1448}
1449
1450impl QueueSettings {
1451 fn parse(value: &Value) -> Option<Self> {
1452 let named = |name: &str| {
1453 value
1454 .get(name)
1455 .and_then(|field| field.get("key").or_else(|| field.get("display")))
1456 .and_then(Value::as_str)
1457 .map(ToOwned::to_owned)
1458 };
1459
1460 Some(Self {
1461 key: value.get("key").and_then(Value::as_str)?.to_owned(),
1462 name: value
1463 .get("name")
1464 .and_then(Value::as_str)
1465 .unwrap_or_default()
1466 .to_owned(),
1467 lead: value
1468 .get("lead")
1469 .and_then(|lead| {
1470 lead.get("login")
1471 .or_else(|| lead.get("display"))
1472 .or_else(|| lead.get("id"))
1473 })
1474 .and_then(Value::as_str)
1475 .map(ToOwned::to_owned),
1476 default_type: named("defaultType"),
1477 default_priority: named("defaultPriority"),
1478 version: value.get("version").and_then(Value::as_u64),
1479 })
1480 }
1481}
1482
1483#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1485pub enum TemplateKind {
1486 Issue,
1487 Comment,
1488}
1489
1490impl TemplateKind {
1491 #[must_use]
1492 pub const fn path(self) -> &'static str {
1493 match self {
1494 Self::Issue => "issueTemplates",
1495 Self::Comment => "commentTemplates",
1496 }
1497 }
1498}
1499
1500#[derive(Debug, Clone, serde::Serialize)]
1502pub struct Template {
1503 pub id: String,
1504 pub name: String,
1505 pub queue: Option<String>,
1507 pub author: Option<String>,
1508}
1509
1510impl Template {
1511 fn parse(value: &Value) -> Option<Self> {
1512 Some(Self {
1513 id: match value.get("id")? {
1514 Value::String(id) => id.clone(),
1515 other => other.to_string(),
1516 },
1517 name: value
1518 .get("name")
1519 .or_else(|| value.get("summary"))
1520 .and_then(Value::as_str)
1521 .unwrap_or_default()
1522 .to_owned(),
1523 queue: value
1524 .get("queue")
1525 .and_then(|queue| queue.get("key").or_else(|| queue.get("id")).or(Some(queue)))
1526 .and_then(Value::as_str)
1527 .map(ToOwned::to_owned),
1528 author: value
1529 .get("createdBy")
1530 .or_else(|| value.get("author"))
1531 .and_then(|user| {
1532 user.get("login")
1533 .or_else(|| user.get("display"))
1534 .or_else(|| user.get("id"))
1535 })
1536 .and_then(Value::as_str)
1537 .map(ToOwned::to_owned),
1538 })
1539 }
1540}
1541
1542#[derive(Debug, Clone, serde::Serialize)]
1545pub struct QueueField {
1546 pub key: String,
1547 pub name: String,
1548 pub field_type: String,
1549 pub system: bool,
1551}
1552
1553impl QueueField {
1554 fn parse(value: &Value) -> Option<Self> {
1555 let id = value.get("id").and_then(Value::as_str)?;
1556 Some(Self {
1557 key: id.rsplit("--").next().unwrap_or(id).to_owned(),
1561 name: value
1562 .get("name")
1563 .and_then(Value::as_str)
1564 .unwrap_or(id)
1565 .to_owned(),
1566 field_type: value
1567 .get("schema")
1568 .and_then(|schema| schema.get("type"))
1569 .and_then(Value::as_str)
1570 .unwrap_or("unknown")
1571 .to_owned(),
1572 system: !id.contains("--"),
1573 })
1574 }
1575}
1576
1577fn checklist_of(value: &Value) -> Vec<ChecklistItem> {
1583 let entries = value
1584 .get("checklistItems")
1585 .and_then(Value::as_array)
1586 .or_else(|| value.as_array());
1587
1588 entries
1589 .map(|entries| entries.iter().filter_map(parse::checklist_item).collect())
1590 .unwrap_or_default()
1591}
1592
1593async fn classify(response: reqwest::Response, what: &str) -> Result<String, ApiError> {
1598 let status = response.status();
1599 if status.is_success() {
1600 return Ok(response.text().await?);
1601 }
1602
1603 let message = response.text().await.unwrap_or_default();
1604 Err(match status.as_u16() {
1605 401 => ApiError::Unauthorized,
1606 403 => ApiError::Forbidden,
1607 404 => ApiError::NotFound(what.to_owned()),
1608 429 => ApiError::RateLimited,
1609 _ => ApiError::Rejected {
1610 status,
1611 message: complaint(&message),
1612 },
1613 })
1614}
1615
1616fn complaint(body: &str) -> String {
1623 let messages = serde_json::from_str::<Value>(body)
1624 .ok()
1625 .and_then(|value| {
1626 let mut said: Vec<String> = value
1627 .get("errorMessages")
1628 .and_then(Value::as_array)
1629 .map(|entries| {
1630 entries
1631 .iter()
1632 .filter_map(Value::as_str)
1633 .map(ToOwned::to_owned)
1634 .collect()
1635 })
1636 .unwrap_or_default();
1637 if let Some(errors) = value.get("errors").and_then(Value::as_object) {
1640 said.extend(
1641 errors
1642 .iter()
1643 .filter_map(|(field, text)| Some(format!("{field}: {}", text.as_str()?))),
1644 );
1645 }
1646 (!said.is_empty()).then(|| said.join("; "))
1647 })
1648 .unwrap_or_else(|| body.to_owned());
1649
1650 messages.chars().take(400).collect()
1651}
1652
1653fn is_retryable(error: &ApiError) -> bool {
1656 match error {
1657 ApiError::RateLimited => true,
1658 ApiError::Transport(err) => err.is_timeout() || err.is_connect(),
1659 ApiError::Rejected { status, .. } => status.is_server_error(),
1660 _ => false,
1661 }
1662}
1663
1664#[cfg(test)]
1665mod tests {
1666 use super::*;
1667
1668 #[test]
1670 fn a_rejection_reads_as_what_tracker_said() {
1671 assert_eq!(
1672 complaint(
1673 r#"{"errors":{},"errorMessages":["A board of this type cannot have sprints."],"statusCode":400}"#
1674 ),
1675 "A board of this type cannot have sprints."
1676 );
1677 }
1678
1679 #[test]
1682 fn a_field_complaint_keeps_its_field() {
1683 assert_eq!(
1684 complaint(r#"{"errors":{"summary":"cannot be empty"},"errorMessages":[]}"#),
1685 "summary: cannot be empty"
1686 );
1687 }
1688
1689 #[test]
1692 fn an_unfamiliar_body_survives_untouched() {
1693 assert_eq!(
1694 complaint("<html>gateway timeout</html>"),
1695 "<html>gateway timeout</html>"
1696 );
1697 assert_eq!(complaint("{}"), "{}");
1698 }
1699
1700 #[test]
1701 fn host_comparison_ignores_scheme_path_and_case() {
1702 assert_eq!(
1703 host_of("https://API.tracker.yandex.net/v3/issues/PROJ-1"),
1704 host_of("https://api.tracker.yandex.net")
1705 );
1706 }
1707
1708 #[test]
1712 fn a_different_host_does_not_match() {
1713 assert_ne!(
1714 host_of("https://evil.example.com/steal"),
1715 host_of("https://api.tracker.yandex.net")
1716 );
1717 }
1718
1719 #[test]
1721 fn a_prefix_of_the_real_host_does_not_match() {
1722 assert_ne!(
1723 host_of("https://api.tracker.yandex.net.evil.com/steal"),
1724 host_of("https://api.tracker.yandex.net")
1725 );
1726 }
1727
1728 #[test]
1729 fn a_port_is_part_of_the_host() {
1730 assert_ne!(
1731 host_of("http://127.0.0.1:9999/x"),
1732 host_of("http://127.0.0.1:8888")
1733 );
1734 }
1735}