omnitrack 0.3.0

Universal issue-tracker provider contracts and clients (Linear, Jira, ...) for Rust, in one crate.
Documentation
use crate::{ErrorKind, IssueError, IssueResult, error};
use reqwest::Method;
use serde::de::DeserializeOwned;

use super::client::{JiraAuth, JiraClient};

const MAX_ATTEMPTS: u32 = 3;

fn map_status(status: reqwest::StatusCode) -> ErrorKind {
    match status.as_u16() {
        401 | 403 => ErrorKind::Unauthorized,
        404 => ErrorKind::NotFound,
        429 => ErrorKind::RateLimited,
        _ => ErrorKind::Transport,
    }
}

impl JiraClient {
    async fn send(
        &self,
        method: Method,
        path: &str,
        body: Option<&serde_json::Value>,
    ) -> IssueResult<String> {
        let url = format!("{}{}", self.base_url.trim_end_matches('/'), path);
        let mut last_transient = String::new();

        for _ in 0..MAX_ATTEMPTS {
            let mut request = self
                .http
                .request(method.clone(), &url)
                .header("Accept", "application/json");
            request = match &self.auth {
                JiraAuth::Basic { email, token } => request.basic_auth(email, Some(token)),
                JiraAuth::Bearer { token } => request.bearer_auth(token),
            };
            if let Some(body) = body {
                request = request.json(body);
            }

            let sent = request.send().await;
            let response = match sent {
                Ok(response) => response,
                Err(err) => {
                    last_transient = format!("request failed: {err}");
                    continue;
                }
            };

            let status = response.status();
            let text = match response.text().await {
                Ok(text) => text,
                Err(err) => {
                    last_transient = format!("response read failed: {err}");
                    continue;
                }
            };

            if !status.is_success() {
                return Err(error().of(map_status(status), format!("{status}: {text}")));
            }

            return Ok(text);
        }

        Err(transient(&last_transient))
    }

    pub(crate) async fn request<T: DeserializeOwned>(
        &self,
        method: Method,
        path: &str,
        body: Option<&serde_json::Value>,
    ) -> IssueResult<T> {
        let text = self.send(method, path, body).await?;
        serde_json::from_str(&text)
            .map_err(|err| error().of(ErrorKind::Decode, format!("decode: {err}")))
    }

    pub(crate) async fn request_unit(
        &self,
        method: Method,
        path: &str,
        body: Option<&serde_json::Value>,
    ) -> IssueResult<()> {
        self.send(method, path, body).await.map(|_| ())
    }
}

fn transient(detail: &str) -> IssueError {
    error().of(
        ErrorKind::Transport,
        format!("jira request failed after {MAX_ATTEMPTS} attempts: {detail}"),
    )
}