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 serde::Serialize;
use serde::de::DeserializeOwned;

use super::client::LinearClient;

const MAX_ATTEMPTS: u32 = 3;

#[derive(Serialize)]
struct GraphQlRequest<'a> {
    query: &'a str,
    variables: serde_json::Value,
}

#[derive(serde::Deserialize)]
struct GraphQlResponse<T> {
    data: Option<T>,
    errors: Option<Vec<serde_json::Value>>,
}

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

fn format_errors(errors: &[serde_json::Value]) -> String {
    errors
        .iter()
        .map(|e| {
            e.get("message")
                .and_then(|m| m.as_str())
                .unwrap_or("unknown error")
                .to_string()
        })
        .collect::<Vec<_>>()
        .join("; ")
}

impl LinearClient {
    pub(crate) async fn execute<T: DeserializeOwned>(
        &self,
        query: &str,
        variables: serde_json::Value,
    ) -> IssueResult<T> {
        let mut last_transient = String::new();

        for _ in 0..MAX_ATTEMPTS {
            let sent = self
                .http
                .post(&self.base_url)
                .header("Authorization", &self.token)
                .json(&GraphQlRequest {
                    query,
                    variables: variables.clone(),
                })
                .send()
                .await;

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

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

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

            if body.trim().is_empty() {
                last_transient = "empty response body".to_string();
                continue;
            }

            let parsed: GraphQlResponse<T> = serde_json::from_str(&body)
                .map_err(|err| error().of(ErrorKind::Decode, format!("decode: {err}")))?;

            if let Some(errors) = parsed.errors
                && !errors.is_empty()
            {
                return Err(error().of(ErrorKind::Provider, format_errors(&errors)));
            }

            return match parsed.data {
                Some(data) => Ok(data),
                None => Err(error().of(ErrorKind::Provider, "linear graphql returned no data")),
            };
        }

        Err(transient(&last_transient))
    }
}

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