1use std::sync::Arc;
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::time::{Duration, Instant};
4
5use futures::future::BoxFuture;
6use futures::{Stream, TryStreamExt as _};
7use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderValue};
8use reqwest::{Method, StatusCode};
9use secrecy::{ExposeSecret as _, SecretString};
10use serde::de::DeserializeOwned;
11use serde_json::Value;
12use url::Url;
13
14use crate::api::error::{api_error, unexpected};
15use crate::api::projection::Projection;
16use crate::error::{Result, YtError};
17use crate::http::{is_transient, retry_delay};
18use crate::instance::InstanceUrl;
19
20const TRANSFER_TIMEOUT: Duration = Duration::from_secs(60 * 60);
22pub(crate) const LIST_PAGE: usize = 500;
23
24pub trait TokenSource: Send + Sync {
26 fn bearer(&self) -> BoxFuture<'_, Result<SecretString>>;
27 fn refresh_after_unauthorized(&self) -> BoxFuture<'_, Result<bool>>;
29 fn label(&self) -> &str;
31}
32
33pub struct Client {
34 http: reqwest::Client,
35 instance: InstanceUrl,
36 api: Url,
37 auth: Arc<dyn TokenSource>,
38 retries: u32,
39 written: AtomicBool,
40}
41
42struct Response {
43 status: StatusCode,
44 body: String,
45}
46
47#[derive(Clone, Copy)]
48struct Page {
49 skip: usize,
50 size: usize,
51 done: bool,
52}
53
54impl Client {
55 pub fn new(
56 http: reqwest::Client,
57 instance: InstanceUrl,
58 auth: Arc<dyn TokenSource>,
59 retries: u32,
60 ) -> Self {
61 let api = instance.api();
62 Self {
63 http,
64 instance,
65 api,
66 auth,
67 retries,
68 written: AtomicBool::new(false),
69 }
70 }
71
72 pub fn has_written(&self) -> bool {
74 self.written.load(Ordering::SeqCst)
75 }
76
77 pub fn instance(&self) -> &InstanceUrl {
78 &self.instance
79 }
80
81 pub async fn get<T: Projection>(&self, path: &str, query: &[(&str, &str)]) -> Result<T> {
83 let url = self.url(path, query, Some(T::FIELDS))?;
84 let response = self.execute(Method::GET, url.clone(), None, true).await?;
85 let body = self.success(&Method::GET, &url, response)?;
86 decode(&url, &body)
87 }
88
89 pub async fn post<T: Projection>(&self, path: &str, body: &Value) -> Result<T> {
91 let url = self.url(path, &[], Some(T::FIELDS))?;
92 let response = self
93 .execute(Method::POST, url.clone(), Some(body), false)
94 .await?;
95 let text = self.success(&Method::POST, &url, response)?;
96 decode(&url, &text)
97 }
98
99 pub async fn post_query<T: Projection>(&self, path: &str, body: &Value) -> Result<T> {
101 let url = self.url(path, &[], Some(T::FIELDS))?;
102 let response = self
103 .execute(Method::POST, url.clone(), Some(body), true)
104 .await?;
105 let text = self.success(&Method::POST, &url, response)?;
106 decode(&url, &text)
107 }
108
109 pub async fn post_unit(&self, path: &str, body: &Value) -> Result<()> {
111 let url = self.url(path, &[], None)?;
112 let response = self
113 .execute(Method::POST, url.clone(), Some(body), false)
114 .await?;
115 self.success(&Method::POST, &url, response).map(drop)
116 }
117
118 pub async fn delete(&self, path: &str) -> Result<()> {
119 let url = self.url(path, &[], None)?;
120 let response = self
121 .execute(Method::DELETE, url.clone(), None, false)
122 .await?;
123 self.success(&Method::DELETE, &url, response).map(drop)
124 }
125
126 pub async fn upload<T: Projection>(
128 &self,
129 path: &str,
130 files: &[(String, Vec<u8>)],
131 ) -> Result<T> {
132 let url = self.url(path, &[], Some(T::FIELDS))?;
133 let response = self
134 .send_once(Method::POST, url.clone(), |request| {
135 let mut form = reqwest::multipart::Form::new();
136 for (name, data) in files {
137 form = form.part(
138 "file",
139 reqwest::multipart::Part::bytes(data.clone()).file_name(name.clone()),
140 );
141 }
142 request.multipart(form)
143 })
144 .await?;
145 let status = response.status();
146 let body = response.text().await.map_err(network)?;
147 let text = self.success(&Method::POST, &url, Response { status, body })?;
148 decode(&url, &text)
149 }
150
151 pub async fn download(&self, url: &str) -> Result<reqwest::Response> {
153 let url = self.instance.join(url);
154 let response = self
155 .send_once(Method::GET, url.clone(), |request| request)
156 .await?;
157 let status = response.status();
158 if status.is_success() {
159 return Ok(response);
160 }
161 let body = response.text().await.unwrap_or_default();
162 Err(self.failure(&Method::GET, &url, status, &body))
163 }
164
165 async fn send_once(
167 &self,
168 method: Method,
169 url: Url,
170 build: impl Fn(reqwest::RequestBuilder) -> reqwest::RequestBuilder,
171 ) -> Result<reqwest::Response> {
172 self.check_origin(&url)?;
173 let mut refreshed = false;
174 loop {
175 let request = self
176 .http
177 .request(method.clone(), url.clone())
178 .header(AUTHORIZATION, self.authorization().await?);
179 let response = match build(request).timeout(TRANSFER_TIMEOUT).send().await {
180 Ok(response) => response,
181 Err(error) => {
182 if method != Method::GET && !error.is_connect() {
183 self.written.store(true, Ordering::SeqCst);
184 }
185 return Err(network(error));
186 }
187 };
188 if response.status() == StatusCode::UNAUTHORIZED && !refreshed {
189 refreshed = true;
190 if self.auth.refresh_after_unauthorized().await? {
191 continue;
192 }
193 }
194 if method != Method::GET && response.status() != StatusCode::UNAUTHORIZED {
195 self.written.store(true, Ordering::SeqCst);
196 }
197 return Ok(response);
198 }
199 }
200
201 fn check_origin(&self, url: &Url) -> Result<()> {
202 if url.origin() == self.api.origin() {
203 return Ok(());
204 }
205 Err(YtError::Usage {
206 message: format!(
207 "refusing to send the credential to {}: it is not {}",
208 url.origin().ascii_serialization(),
209 self.instance
210 ),
211 }
212 .into())
213 }
214
215 async fn authorization(&self) -> Result<HeaderValue> {
216 let token = self.auth.bearer().await?;
217 let mut value = HeaderValue::from_str(&format!("Bearer {}", token.expose_secret()))
218 .map_err(|_| YtError::Usage {
219 message: "the credential contains characters that cannot be sent".into(),
220 })?;
221 value.set_sensitive(true);
222 Ok(value)
223 }
224
225 pub async fn raw(
227 &self,
228 method: Method,
229 path_and_query: &str,
230 body: Option<&Value>,
231 ) -> Result<Option<String>> {
232 let relative = path_and_query.trim_start_matches('/');
233 let relative = relative.strip_prefix("api/").unwrap_or(relative);
234 let url = self.api.join(relative).map_err(|error| YtError::Usage {
235 message: format!("invalid API path '{path_and_query}': {error}"),
236 })?;
237 let idempotent = method == Method::GET;
238 let response = self
239 .execute(method.clone(), url.clone(), body, idempotent)
240 .await?;
241 let text = self.success(&method, &url, response)?;
242 if text.trim().is_empty() {
243 return Ok(None);
244 }
245 if serde_json::from_str::<serde::de::IgnoredAny>(&text).is_err() {
246 return Err(YtError::Api {
247 method: method.to_string(),
248 path: url.path().to_string(),
249 status: 200,
250 message: unexpected(StatusCode::OK, &text),
251 help: None,
252 }
253 .into());
254 }
255 Ok(Some(text))
256 }
257
258 pub async fn get_all<T>(&self, path: &str) -> Result<Vec<T>>
260 where
261 T: Projection + Send + 'static,
262 {
263 self.paginate(path.to_string(), Vec::new(), LIST_PAGE)
264 .try_collect()
265 .await
266 }
267
268 pub fn paginate<T>(
270 &self,
271 path: String,
272 query: Vec<(String, String)>,
273 page_size: usize,
274 ) -> impl Stream<Item = Result<T>> + Send + '_
275 where
276 T: Projection + Send + 'static,
277 {
278 let start = Page {
279 skip: 0,
280 size: page_size.max(1),
281 done: false,
282 };
283 futures::stream::try_unfold(start, move |page| {
284 let path = path.clone();
285 let query = query.clone();
286 async move {
287 self.next_page::<T>(&path, &query, page).await.map(|next| {
288 next.map(|(items, page)| {
289 (
290 futures::stream::iter(items.into_iter().map(Ok::<T, miette::Report>)),
291 page,
292 )
293 })
294 })
295 }
296 })
297 .try_flatten()
298 }
299
300 async fn next_page<T: Projection>(
301 &self,
302 path: &str,
303 query: &[(String, String)],
304 mut page: Page,
305 ) -> Result<Option<(Vec<T>, Page)>> {
306 if page.done {
307 return Ok(None);
308 }
309 loop {
310 let skip = page.skip.to_string();
311 let top = page.size.to_string();
312 let mut pairs: Vec<(&str, &str)> = query
313 .iter()
314 .map(|(key, value)| (key.as_str(), value.as_str()))
315 .collect();
316 pairs.push(("$skip", &skip));
317 pairs.push(("$top", &top));
318 let url = self.url(path, &pairs, Some(T::FIELDS))?;
319 let response = self.execute(Method::GET, url.clone(), None, true).await?;
320 if response.status == StatusCode::BAD_REQUEST
321 && let Some(limit) = export_limit(&response.body).filter(|limit| *limit < page.size)
322 {
323 page.size = limit;
324 continue;
325 }
326 let body = self.success(&Method::GET, &url, response)?;
327 let items: Vec<T> = decode(&url, &body)?;
328 page.skip += items.len();
329 page.done = items.len() < page.size;
330 return Ok(Some((items, page)));
331 }
332 }
333
334 fn url(&self, path: &str, query: &[(&str, &str)], fields: Option<&str>) -> Result<Url> {
335 let mut url = self.api.join(path).map_err(|error| YtError::Usage {
336 message: format!("invalid API path '{path}': {error}"),
337 })?;
338 if !query.is_empty() || fields.is_some() {
339 let mut pairs = url.query_pairs_mut();
340 for (key, value) in query {
341 pairs.append_pair(key, value);
342 }
343 if let Some(fields) = fields {
344 pairs.append_pair("fields", fields);
345 }
346 }
347 Ok(url)
348 }
349
350 async fn execute(
351 &self,
352 method: Method,
353 url: Url,
354 body: Option<&Value>,
355 idempotent: bool,
356 ) -> Result<Response> {
357 self.check_origin(&url)?;
358 let mut attempt = 0;
359 let mut refreshed = false;
360 loop {
361 let authorization = self.authorization().await?;
362 let mut request = self
363 .http
364 .request(method.clone(), url.clone())
365 .header(AUTHORIZATION, authorization)
366 .header(ACCEPT, "application/json");
367 if let Some(body) = body {
368 request = request.json(body);
369 }
370 let started = Instant::now();
371 match request.send().await {
372 Ok(response) => {
373 let status = response.status();
374 log::debug!(
375 "{method} {} -> {} in {} ms",
376 url.path(),
377 status.as_u16(),
378 started.elapsed().as_millis()
379 );
380 log::trace!("query: {}", url.query().unwrap_or(""));
381 if status == StatusCode::UNAUTHORIZED && !refreshed {
382 refreshed = true;
383 if self.auth.refresh_after_unauthorized().await? {
384 continue;
385 }
386 }
387 if !idempotent && status != StatusCode::UNAUTHORIZED {
388 self.written.store(true, Ordering::SeqCst);
389 }
390 if idempotent && is_transient(status) && attempt < self.retries {
391 tokio::time::sleep(retry_delay(attempt, Some(response.headers()))).await;
392 attempt += 1;
393 continue;
394 }
395 let body = response.text().await.map_err(network)?;
396 return Ok(Response { status, body });
397 }
398 Err(error)
399 if idempotent
400 && attempt < self.retries
401 && (error.is_connect() || error.is_timeout()) =>
402 {
403 log::debug!("{method} {} failed, retrying: {error}", url.path());
404 tokio::time::sleep(retry_delay(attempt, None)).await;
405 attempt += 1;
406 }
407 Err(error) => {
408 if !idempotent && !error.is_connect() {
409 self.written.store(true, Ordering::SeqCst);
410 }
411 return Err(network(error));
412 }
413 }
414 }
415 }
416
417 fn success(&self, method: &Method, url: &Url, response: Response) -> Result<String> {
418 if response.status.is_success() {
419 return Ok(response.body);
420 }
421 Err(self.failure(method, url, response.status, &response.body))
422 }
423
424 fn failure(
425 &self,
426 method: &Method,
427 url: &Url,
428 status: StatusCode,
429 body: &str,
430 ) -> miette::Report {
431 if status == StatusCode::UNAUTHORIZED {
432 return YtError::CredentialRejected {
433 profile: self.auth.label().to_string(),
434 }
435 .into();
436 }
437 api_error(method, url.path(), status, body)
438 }
439}
440
441fn decode<T: DeserializeOwned>(url: &Url, body: &str) -> Result<T> {
442 let mut deserializer = serde_json::Deserializer::from_str(body);
443 serde_path_to_error::deserialize(&mut deserializer).map_err(|error| {
444 let message = if body.trim_start().starts_with('<') {
445 unexpected(StatusCode::OK, body)
446 } else {
447 format!(
448 "could not read the response at {}: {}",
449 error.path(),
450 error.inner()
451 )
452 };
453 YtError::Api {
454 method: "GET".into(),
455 path: url.path().to_string(),
456 status: 200,
457 message,
458 help: None,
459 }
460 .into()
461 })
462}
463
464fn export_limit(body: &str) -> Option<usize> {
465 let rest = body.split("greater than limit").nth(1)?;
466 rest.trim_start()
467 .chars()
468 .take_while(char::is_ascii_digit)
469 .collect::<String>()
470 .parse()
471 .ok()
472}
473
474fn network(error: reqwest::Error) -> miette::Report {
475 YtError::Network(crate::http::describe(&error)).into()
476}
477
478#[cfg(test)]
479mod tests {
480 use std::sync::atomic::{AtomicUsize, Ordering};
481 use std::time::Duration;
482
483 use serde::Deserialize;
484 use serde_json::json;
485 use wiremock::matchers::{body_json, header, method, path, query_param};
486 use wiremock::{Mock, MockServer, ResponseTemplate};
487
488 use super::*;
489 use crate::http::HttpConfig;
490
491 const TOKEN: &str = "perm-dGVzdA==.Y2xpZW50.secret123456";
492
493 struct Fixed {
494 refreshes: AtomicUsize,
495 }
496
497 impl TokenSource for Fixed {
498 fn bearer(&self) -> BoxFuture<'_, Result<SecretString>> {
499 Box::pin(async { Ok(SecretString::from(TOKEN.to_string())) })
500 }
501 fn refresh_after_unauthorized(&self) -> BoxFuture<'_, Result<bool>> {
502 self.refreshes.fetch_add(1, Ordering::SeqCst);
503 Box::pin(async { Ok(false) })
504 }
505 fn label(&self) -> &str {
506 "acme"
507 }
508 }
509
510 #[derive(Debug, Deserialize, PartialEq)]
511 #[serde(rename_all = "camelCase")]
512 struct Item {
513 id_readable: String,
514 }
515
516 impl Projection for Item {
517 const FIELDS: &'static str = "idReadable";
518 }
519
520 fn client_for(server: &MockServer, auth: Arc<Fixed>) -> Client {
521 let http = crate::http::client(&HttpConfig {
522 timeout: Duration::from_secs(5),
523 retries: 3,
524 ca_file: None,
525 })
526 .unwrap();
527 Client::new(http, InstanceUrl::parse(&server.uri()).unwrap(), auth, 3)
528 }
529
530 fn fixed() -> Arc<Fixed> {
531 Arc::new(Fixed {
532 refreshes: AtomicUsize::new(0),
533 })
534 }
535
536 fn item(id: &str) -> serde_json::Value {
537 json!({"idReadable": id, "$type": "Issue"})
538 }
539
540 #[tokio::test]
541 async fn get_sends_fields_and_the_token() {
542 let server = MockServer::start().await;
543 Mock::given(method("GET"))
544 .and(path("/api/issues/WEB-1"))
545 .and(query_param("fields", "idReadable"))
546 .and(header("authorization", format!("Bearer {TOKEN}").as_str()))
547 .respond_with(ResponseTemplate::new(200).set_body_json(item("WEB-1")))
548 .expect(1)
549 .mount(&server)
550 .await;
551 let found: Item = client_for(&server, fixed())
552 .get("issues/WEB-1", &[])
553 .await
554 .unwrap();
555 assert_eq!(
556 found,
557 Item {
558 id_readable: "WEB-1".into()
559 }
560 );
561 }
562
563 #[tokio::test]
564 async fn a_stopped_redirect_says_why() {
565 let server = MockServer::start().await;
566 Mock::given(method("GET"))
567 .respond_with(|request: &wiremock::Request| {
568 ResponseTemplate::new(302)
569 .insert_header("location", format!("{}/next", request.url.path()))
570 })
571 .mount(&server)
572 .await;
573 let result: Result<Item> = client_for(&server, fixed()).get("issues/WEB-1", &[]).await;
574 let error = result.unwrap_err().to_string();
575 assert!(error.contains("too many redirects"), "{error}");
576 }
577
578 #[tokio::test]
579 async fn unauthorized_asks_for_a_refresh_once_and_never_shows_the_token() {
580 let server = MockServer::start().await;
581 Mock::given(method("GET"))
582 .respond_with(ResponseTemplate::new(401).set_body_json(
583 json!({"error": "Unauthorized", "error_description": "Invalid token"}),
584 ))
585 .mount(&server)
586 .await;
587 let auth = fixed();
588 let error = client_for(&server, auth.clone())
589 .get::<Item>("issues/WEB-1", &[])
590 .await
591 .unwrap_err();
592 assert_eq!(crate::error::exit_code(&error), 4);
593 assert_eq!(auth.refreshes.load(Ordering::SeqCst), 1);
594 assert!(!format!("{error:?}").contains(TOKEN));
595 }
596
597 #[tokio::test]
598 async fn a_rejected_download_asks_to_sign_in_again() {
599 let server = MockServer::start().await;
600 Mock::given(method("GET"))
601 .respond_with(ResponseTemplate::new(401))
602 .mount(&server)
603 .await;
604 let auth = fixed();
605 let error = client_for(&server, auth.clone())
606 .download("/api/files/8-1?sign=x")
607 .await
608 .unwrap_err();
609 assert_eq!(crate::error::exit_code(&error), 4);
610 assert_eq!(auth.refreshes.load(Ordering::SeqCst), 1);
611 }
612
613 #[tokio::test]
614 async fn reads_are_retried_on_503() {
615 let server = MockServer::start().await;
616 Mock::given(method("GET"))
617 .respond_with(ResponseTemplate::new(503).insert_header("retry-after", "0"))
618 .up_to_n_times(1)
619 .mount(&server)
620 .await;
621 Mock::given(method("GET"))
622 .respond_with(ResponseTemplate::new(200).set_body_json(item("WEB-1")))
623 .mount(&server)
624 .await;
625 let found: Item = client_for(&server, fixed())
626 .get("issues/WEB-1", &[])
627 .await
628 .unwrap();
629 assert_eq!(found.id_readable, "WEB-1");
630 }
631
632 #[tokio::test]
633 async fn writes_are_never_retried() {
634 let server = MockServer::start().await;
635 Mock::given(method("POST"))
636 .respond_with(ResponseTemplate::new(503).insert_header("retry-after", "0"))
637 .expect(1)
638 .mount(&server)
639 .await;
640 let result = client_for(&server, fixed())
641 .raw(Method::POST, "commands", Some(&json!({"query": "x"})))
642 .await;
643 assert!(result.is_err());
644 }
645
646 #[tokio::test]
647 async fn html_error_pages_are_reported_as_such() {
648 let server = MockServer::start().await;
649 Mock::given(method("GET"))
650 .respond_with(
651 ResponseTemplate::new(502)
652 .set_body_raw("<html><body>Bad gateway</body></html>", "text/html"),
653 )
654 .mount(&server)
655 .await;
656 let auth = fixed();
657 let http = crate::http::client(&HttpConfig {
658 timeout: Duration::from_secs(5),
659 retries: 0,
660 ca_file: None,
661 })
662 .unwrap();
663 let client = Client::new(http, InstanceUrl::parse(&server.uri()).unwrap(), auth, 0);
664 let error = client.get::<Item>("issues/WEB-1", &[]).await.unwrap_err();
665 assert!(
666 error
667 .to_string()
668 .contains("unexpected HTML response (HTTP 502"),
669 "{error}"
670 );
671 }
672
673 #[tokio::test]
674 async fn html_with_status_200_is_not_a_decode_panic() {
675 let server = MockServer::start().await;
676 Mock::given(method("GET"))
677 .respond_with(
678 ResponseTemplate::new(200).set_body_raw("<html>Sign in</html>", "text/html"),
679 )
680 .mount(&server)
681 .await;
682 let error = client_for(&server, fixed())
683 .get::<Item>("issues/WEB-1", &[])
684 .await
685 .unwrap_err();
686 assert!(
687 error.to_string().contains("unexpected HTML response"),
688 "{error}"
689 );
690 }
691
692 #[tokio::test]
693 async fn youtrack_errors_are_shown_verbatim() {
694 let server = MockServer::start().await;
695 Mock::given(method("GET"))
696 .respond_with(ResponseTemplate::new(400).set_body_json(json!({
697 "error": "invalid_query",
698 "error_description": "Can't parse search query",
699 "error_children": [{"error": "The value \"NOPE\" isn't used for the project field."}]
700 })))
701 .mount(&server)
702 .await;
703 let error = client_for(&server, fixed())
704 .get::<Item>("issues", &[])
705 .await
706 .unwrap_err();
707 let text = error.to_string();
708 assert!(
709 text.contains("Can't parse search query")
710 && text.contains("isn't used for the project field"),
711 "{text}"
712 );
713 assert_eq!(crate::error::exit_code(&error), 1);
714 }
715
716 #[tokio::test]
717 async fn decode_errors_name_the_json_path() {
718 let server = MockServer::start().await;
719 Mock::given(method("GET"))
720 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"idReadable": 5})))
721 .mount(&server)
722 .await;
723 let error = client_for(&server, fixed())
724 .get::<Item>("issues/WEB-1", &[])
725 .await
726 .unwrap_err();
727 assert!(error.to_string().contains("idReadable"), "{error}");
728 }
729
730 #[tokio::test]
731 async fn paginate_walks_pages_until_a_short_one() {
732 let server = MockServer::start().await;
733 Mock::given(method("GET"))
734 .and(path("/api/issues"))
735 .and(query_param("$skip", "0"))
736 .and(query_param("$top", "2"))
737 .respond_with(
738 ResponseTemplate::new(200).set_body_json(json!([item("WEB-1"), item("WEB-2")])),
739 )
740 .expect(1)
741 .mount(&server)
742 .await;
743 Mock::given(method("GET"))
744 .and(path("/api/issues"))
745 .and(query_param("$skip", "2"))
746 .respond_with(ResponseTemplate::new(200).set_body_json(json!([item("WEB-3")])))
747 .expect(1)
748 .mount(&server)
749 .await;
750 let client = client_for(&server, fixed());
751 let items: Vec<Item> = client
752 .paginate("issues".into(), vec![], 2)
753 .try_collect()
754 .await
755 .unwrap();
756 assert_eq!(items.len(), 3);
757 }
758
759 #[tokio::test]
760 async fn paginate_shrinks_to_the_export_limit() {
761 let server = MockServer::start().await;
762 Mock::given(method("GET"))
763 .and(query_param("$top", "100"))
764 .respond_with(ResponseTemplate::new(400).set_body_json(json!({
765 "error": "Bad Request",
766 "error_description": "Requested size 100 is greater than limit 50"
767 })))
768 .mount(&server)
769 .await;
770 Mock::given(method("GET"))
771 .and(query_param("$top", "50"))
772 .respond_with(ResponseTemplate::new(200).set_body_json(json!([item("WEB-1")])))
773 .mount(&server)
774 .await;
775 let client = client_for(&server, fixed());
776 let items: Vec<Item> = client
777 .paginate("issues".into(), vec![], 100)
778 .try_collect()
779 .await
780 .unwrap();
781 assert_eq!(items.len(), 1);
782 }
783
784 #[tokio::test]
785 async fn raw_accepts_paths_with_or_without_the_api_prefix() {
786 let server = MockServer::start().await;
787 Mock::given(method("GET"))
788 .and(path("/api/admin/projects"))
789 .and(query_param("$top", "1"))
790 .respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
791 .expect(2)
792 .mount(&server)
793 .await;
794 let client = client_for(&server, fixed());
795 assert_eq!(
796 client
797 .raw(Method::GET, "/api/admin/projects?$top=1", None)
798 .await
799 .unwrap()
800 .as_deref(),
801 Some("[]")
802 );
803 assert_eq!(
804 client
805 .raw(Method::GET, "admin/projects?$top=1", None)
806 .await
807 .unwrap()
808 .as_deref(),
809 Some("[]")
810 );
811 }
812
813 #[tokio::test]
814 async fn the_token_never_leaves_the_instance() {
815 let instance = MockServer::start().await;
816 let other = MockServer::start().await;
817 Mock::given(method("GET"))
818 .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
819 .mount(&other)
820 .await;
821 let client = client_for(&instance, fixed());
822 let other_host = other.address().to_string();
823 for path in [
824 format!("{}/steal", other.uri()),
825 format!("\\\\{other_host}/steal"),
826 ] {
827 let error = client.raw(Method::GET, &path, None).await.unwrap_err();
828 assert_eq!(crate::error::exit_code(&error), 2, "{path}: {error}");
829 }
830 assert!(other.received_requests().await.unwrap().is_empty());
831 }
832
833 #[tokio::test]
834 async fn only_changes_the_server_accepted_count_as_writes() {
835 let server = MockServer::start().await;
836 Mock::given(method("GET"))
837 .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
838 .mount(&server)
839 .await;
840 Mock::given(method("POST"))
841 .and(path("/api/rejected"))
842 .respond_with(ResponseTemplate::new(401))
843 .mount(&server)
844 .await;
845 Mock::given(method("POST"))
846 .and(path("/api/accepted"))
847 .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
848 .mount(&server)
849 .await;
850 let client = client_for(&server, fixed());
851 client.raw(Method::GET, "issues", None).await.unwrap();
852 assert!(!client.has_written());
853 client
854 .raw(Method::POST, "rejected", Some(&json!({})))
855 .await
856 .unwrap_err();
857 assert!(!client.has_written());
858 client
859 .raw(Method::POST, "accepted", Some(&json!({})))
860 .await
861 .unwrap();
862 assert!(client.has_written());
863 }
864
865 #[tokio::test]
866 async fn post_sends_the_body_and_decodes_the_answer() {
867 let server = MockServer::start().await;
868 Mock::given(method("POST"))
869 .and(path("/api/issues"))
870 .and(query_param("fields", "idReadable"))
871 .and(body_json(json!({"summary": "x"})))
872 .respond_with(ResponseTemplate::new(200).set_body_json(item("WEB-9")))
873 .expect(1)
874 .mount(&server)
875 .await;
876 let client = client_for(&server, fixed());
877 let created: Item = client
878 .post("issues", &json!({"summary": "x"}))
879 .await
880 .unwrap();
881 assert_eq!(created.id_readable, "WEB-9");
882 assert!(client.has_written());
883 }
884
885 #[tokio::test]
886 async fn deletes_and_unit_posts_are_never_retried() {
887 let server = MockServer::start().await;
888 Mock::given(method("DELETE"))
889 .respond_with(ResponseTemplate::new(503))
890 .expect(1)
891 .mount(&server)
892 .await;
893 Mock::given(method("POST"))
894 .respond_with(ResponseTemplate::new(503))
895 .expect(1)
896 .mount(&server)
897 .await;
898 let client = client_for(&server, fixed());
899 assert!(client.delete("issues/WEB-1").await.is_err());
900 assert!(client.post_unit("issues/WEB-1", &json!({})).await.is_err());
901 }
902
903 #[tokio::test]
904 async fn a_read_only_post_is_not_a_write() {
905 let server = MockServer::start().await;
906 Mock::given(method("POST"))
907 .and(path("/api/commands/assist"))
908 .respond_with(ResponseTemplate::new(200).set_body_json(item("WEB-1")))
909 .mount(&server)
910 .await;
911 let client = client_for(&server, fixed());
912 let _: Item = client
913 .post_query("commands/assist", &json!({}))
914 .await
915 .unwrap();
916 assert!(!client.has_written());
917 }
918}