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::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}