Skip to main content

yt/api/
client.rs

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
20/// Uploads and downloads take as long as the file needs; `http_timeout` would cut them off.
21const TRANSFER_TIMEOUT: Duration = Duration::from_secs(60 * 60);
22pub(crate) const LIST_PAGE: usize = 500;
23
24/// Supplies the bearer credential for requests.
25pub trait TokenSource: Send + Sync {
26    fn bearer(&self) -> BoxFuture<'_, Result<SecretString>>;
27    /// Called once after a 401; `Ok(true)` means a fresh credential is ready and the request is retried.
28    fn refresh_after_unauthorized(&self) -> BoxFuture<'_, Result<bool>>;
29    /// The profile name shown in errors.
30    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    /// Whether this client sent a change the server may have applied (a non-GET request it answered, or one that timed out).
73    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    /// GETs `path` (relative to `/api/`) with `query`, adding `T::FIELDS`.
82    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    /// POSTs `body` to `path` (relative to `/api/`) and decodes the answer, asking for `T::FIELDS`. A write: never retried.
90    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    /// A POST that only reads (`commands/assist`): retried like a GET and not counted as a write.
100    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    /// POSTs `body` to `path` and ignores the answer.
110    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    /// Uploads files in one multipart POST to `path` and decodes the answer. A write: never retried.
127    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    /// GETs a file URL YouTrack handed out (relative to the instance) and returns the response for streaming.
152    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    /// One request with the credential, refreshed once after a 401 and never retried; for non-JSON bodies.
166    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    /// Sends a request as given and returns YouTrack's JSON text, `None` for an empty body. Only GET is retried.
226    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    /// Every item of a collection, read in pages of `LIST_PAGE`.
259    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    /// Pages through a collection with `$skip`/`$top`, shrinking the page if the instance caps its size.
269    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::redact::redact(&error.to_string())).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 unauthorized_asks_for_a_refresh_once_and_never_shows_the_token() {
565        let server = MockServer::start().await;
566        Mock::given(method("GET"))
567            .respond_with(ResponseTemplate::new(401).set_body_json(
568                json!({"error": "Unauthorized", "error_description": "Invalid token"}),
569            ))
570            .mount(&server)
571            .await;
572        let auth = fixed();
573        let error = client_for(&server, auth.clone())
574            .get::<Item>("issues/WEB-1", &[])
575            .await
576            .unwrap_err();
577        assert_eq!(crate::error::exit_code(&error), 4);
578        assert_eq!(auth.refreshes.load(Ordering::SeqCst), 1);
579        assert!(!format!("{error:?}").contains(TOKEN));
580    }
581
582    #[tokio::test]
583    async fn a_rejected_download_asks_to_sign_in_again() {
584        let server = MockServer::start().await;
585        Mock::given(method("GET"))
586            .respond_with(ResponseTemplate::new(401))
587            .mount(&server)
588            .await;
589        let auth = fixed();
590        let error = client_for(&server, auth.clone())
591            .download("/api/files/8-1?sign=x")
592            .await
593            .unwrap_err();
594        assert_eq!(crate::error::exit_code(&error), 4);
595        assert_eq!(auth.refreshes.load(Ordering::SeqCst), 1);
596    }
597
598    #[tokio::test]
599    async fn reads_are_retried_on_503() {
600        let server = MockServer::start().await;
601        Mock::given(method("GET"))
602            .respond_with(ResponseTemplate::new(503).insert_header("retry-after", "0"))
603            .up_to_n_times(1)
604            .mount(&server)
605            .await;
606        Mock::given(method("GET"))
607            .respond_with(ResponseTemplate::new(200).set_body_json(item("WEB-1")))
608            .mount(&server)
609            .await;
610        let found: Item = client_for(&server, fixed())
611            .get("issues/WEB-1", &[])
612            .await
613            .unwrap();
614        assert_eq!(found.id_readable, "WEB-1");
615    }
616
617    #[tokio::test]
618    async fn writes_are_never_retried() {
619        let server = MockServer::start().await;
620        Mock::given(method("POST"))
621            .respond_with(ResponseTemplate::new(503).insert_header("retry-after", "0"))
622            .expect(1)
623            .mount(&server)
624            .await;
625        let result = client_for(&server, fixed())
626            .raw(Method::POST, "commands", Some(&json!({"query": "x"})))
627            .await;
628        assert!(result.is_err());
629    }
630
631    #[tokio::test]
632    async fn html_error_pages_are_reported_as_such() {
633        let server = MockServer::start().await;
634        Mock::given(method("GET"))
635            .respond_with(
636                ResponseTemplate::new(502)
637                    .set_body_raw("<html><body>Bad gateway</body></html>", "text/html"),
638            )
639            .mount(&server)
640            .await;
641        let auth = fixed();
642        let http = crate::http::client(&HttpConfig {
643            timeout: Duration::from_secs(5),
644            retries: 0,
645            ca_file: None,
646        })
647        .unwrap();
648        let client = Client::new(http, InstanceUrl::parse(&server.uri()).unwrap(), auth, 0);
649        let error = client.get::<Item>("issues/WEB-1", &[]).await.unwrap_err();
650        assert!(
651            error
652                .to_string()
653                .contains("unexpected HTML response (HTTP 502"),
654            "{error}"
655        );
656    }
657
658    #[tokio::test]
659    async fn html_with_status_200_is_not_a_decode_panic() {
660        let server = MockServer::start().await;
661        Mock::given(method("GET"))
662            .respond_with(
663                ResponseTemplate::new(200).set_body_raw("<html>Sign in</html>", "text/html"),
664            )
665            .mount(&server)
666            .await;
667        let error = client_for(&server, fixed())
668            .get::<Item>("issues/WEB-1", &[])
669            .await
670            .unwrap_err();
671        assert!(
672            error.to_string().contains("unexpected HTML response"),
673            "{error}"
674        );
675    }
676
677    #[tokio::test]
678    async fn youtrack_errors_are_shown_verbatim() {
679        let server = MockServer::start().await;
680        Mock::given(method("GET"))
681            .respond_with(ResponseTemplate::new(400).set_body_json(json!({
682                "error": "invalid_query",
683                "error_description": "Can't parse search query",
684                "error_children": [{"error": "The value \"NOPE\" isn't used for the project field."}]
685            })))
686            .mount(&server)
687            .await;
688        let error = client_for(&server, fixed())
689            .get::<Item>("issues", &[])
690            .await
691            .unwrap_err();
692        let text = error.to_string();
693        assert!(
694            text.contains("Can't parse search query")
695                && text.contains("isn't used for the project field"),
696            "{text}"
697        );
698        assert_eq!(crate::error::exit_code(&error), 1);
699    }
700
701    #[tokio::test]
702    async fn decode_errors_name_the_json_path() {
703        let server = MockServer::start().await;
704        Mock::given(method("GET"))
705            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"idReadable": 5})))
706            .mount(&server)
707            .await;
708        let error = client_for(&server, fixed())
709            .get::<Item>("issues/WEB-1", &[])
710            .await
711            .unwrap_err();
712        assert!(error.to_string().contains("idReadable"), "{error}");
713    }
714
715    #[tokio::test]
716    async fn paginate_walks_pages_until_a_short_one() {
717        let server = MockServer::start().await;
718        Mock::given(method("GET"))
719            .and(path("/api/issues"))
720            .and(query_param("$skip", "0"))
721            .and(query_param("$top", "2"))
722            .respond_with(
723                ResponseTemplate::new(200).set_body_json(json!([item("WEB-1"), item("WEB-2")])),
724            )
725            .expect(1)
726            .mount(&server)
727            .await;
728        Mock::given(method("GET"))
729            .and(path("/api/issues"))
730            .and(query_param("$skip", "2"))
731            .respond_with(ResponseTemplate::new(200).set_body_json(json!([item("WEB-3")])))
732            .expect(1)
733            .mount(&server)
734            .await;
735        let client = client_for(&server, fixed());
736        let items: Vec<Item> = client
737            .paginate("issues".into(), vec![], 2)
738            .try_collect()
739            .await
740            .unwrap();
741        assert_eq!(items.len(), 3);
742    }
743
744    #[tokio::test]
745    async fn paginate_shrinks_to_the_export_limit() {
746        let server = MockServer::start().await;
747        Mock::given(method("GET"))
748            .and(query_param("$top", "100"))
749            .respond_with(ResponseTemplate::new(400).set_body_json(json!({
750                "error": "Bad Request",
751                "error_description": "Requested size 100 is greater than limit 50"
752            })))
753            .mount(&server)
754            .await;
755        Mock::given(method("GET"))
756            .and(query_param("$top", "50"))
757            .respond_with(ResponseTemplate::new(200).set_body_json(json!([item("WEB-1")])))
758            .mount(&server)
759            .await;
760        let client = client_for(&server, fixed());
761        let items: Vec<Item> = client
762            .paginate("issues".into(), vec![], 100)
763            .try_collect()
764            .await
765            .unwrap();
766        assert_eq!(items.len(), 1);
767    }
768
769    #[tokio::test]
770    async fn raw_accepts_paths_with_or_without_the_api_prefix() {
771        let server = MockServer::start().await;
772        Mock::given(method("GET"))
773            .and(path("/api/admin/projects"))
774            .and(query_param("$top", "1"))
775            .respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
776            .expect(2)
777            .mount(&server)
778            .await;
779        let client = client_for(&server, fixed());
780        assert_eq!(
781            client
782                .raw(Method::GET, "/api/admin/projects?$top=1", None)
783                .await
784                .unwrap()
785                .as_deref(),
786            Some("[]")
787        );
788        assert_eq!(
789            client
790                .raw(Method::GET, "admin/projects?$top=1", None)
791                .await
792                .unwrap()
793                .as_deref(),
794            Some("[]")
795        );
796    }
797
798    #[tokio::test]
799    async fn the_token_never_leaves_the_instance() {
800        let instance = MockServer::start().await;
801        let other = MockServer::start().await;
802        Mock::given(method("GET"))
803            .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
804            .mount(&other)
805            .await;
806        let client = client_for(&instance, fixed());
807        let other_host = other.address().to_string();
808        for path in [
809            format!("{}/steal", other.uri()),
810            format!("\\\\{other_host}/steal"),
811        ] {
812            let error = client.raw(Method::GET, &path, None).await.unwrap_err();
813            assert_eq!(crate::error::exit_code(&error), 2, "{path}: {error}");
814        }
815        assert!(other.received_requests().await.unwrap().is_empty());
816    }
817
818    #[tokio::test]
819    async fn only_changes_the_server_accepted_count_as_writes() {
820        let server = MockServer::start().await;
821        Mock::given(method("GET"))
822            .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
823            .mount(&server)
824            .await;
825        Mock::given(method("POST"))
826            .and(path("/api/rejected"))
827            .respond_with(ResponseTemplate::new(401))
828            .mount(&server)
829            .await;
830        Mock::given(method("POST"))
831            .and(path("/api/accepted"))
832            .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
833            .mount(&server)
834            .await;
835        let client = client_for(&server, fixed());
836        client.raw(Method::GET, "issues", None).await.unwrap();
837        assert!(!client.has_written());
838        client
839            .raw(Method::POST, "rejected", Some(&json!({})))
840            .await
841            .unwrap_err();
842        assert!(!client.has_written());
843        client
844            .raw(Method::POST, "accepted", Some(&json!({})))
845            .await
846            .unwrap();
847        assert!(client.has_written());
848    }
849
850    #[tokio::test]
851    async fn post_sends_the_body_and_decodes_the_answer() {
852        let server = MockServer::start().await;
853        Mock::given(method("POST"))
854            .and(path("/api/issues"))
855            .and(query_param("fields", "idReadable"))
856            .and(body_json(json!({"summary": "x"})))
857            .respond_with(ResponseTemplate::new(200).set_body_json(item("WEB-9")))
858            .expect(1)
859            .mount(&server)
860            .await;
861        let client = client_for(&server, fixed());
862        let created: Item = client
863            .post("issues", &json!({"summary": "x"}))
864            .await
865            .unwrap();
866        assert_eq!(created.id_readable, "WEB-9");
867        assert!(client.has_written());
868    }
869
870    #[tokio::test]
871    async fn deletes_and_unit_posts_are_never_retried() {
872        let server = MockServer::start().await;
873        Mock::given(method("DELETE"))
874            .respond_with(ResponseTemplate::new(503))
875            .expect(1)
876            .mount(&server)
877            .await;
878        Mock::given(method("POST"))
879            .respond_with(ResponseTemplate::new(503))
880            .expect(1)
881            .mount(&server)
882            .await;
883        let client = client_for(&server, fixed());
884        assert!(client.delete("issues/WEB-1").await.is_err());
885        assert!(client.post_unit("issues/WEB-1", &json!({})).await.is_err());
886    }
887
888    #[tokio::test]
889    async fn a_read_only_post_is_not_a_write() {
890        let server = MockServer::start().await;
891        Mock::given(method("POST"))
892            .and(path("/api/commands/assist"))
893            .respond_with(ResponseTemplate::new(200).set_body_json(item("WEB-1")))
894            .mount(&server)
895            .await;
896        let client = client_for(&server, fixed());
897        let _: Item = client
898            .post_query("commands/assist", &json!({}))
899            .await
900            .unwrap();
901        assert!(!client.has_written());
902    }
903}