Skip to main content

yt/api/
error.rs

1use reqwest::{Method, StatusCode};
2use serde::Deserialize;
3
4use crate::error::YtError;
5
6#[derive(Default, Deserialize)]
7struct ErrorBody {
8    error: Option<String>,
9    error_description: Option<String>,
10    error_field: Option<String>,
11    #[serde(default)]
12    error_children: Vec<ErrorChild>,
13    error_rule_name: Option<String>,
14}
15
16#[derive(Deserialize)]
17struct ErrorChild {
18    error: Option<String>,
19}
20
21pub fn api_error(method: &Method, path: &str, status: StatusCode, body: &str) -> miette::Report {
22    let message = match serde_json::from_str::<ErrorBody>(body) {
23        Ok(parsed) if parsed.error.is_some() || parsed.error_description.is_some() => {
24            describe(&parsed, status)
25        }
26        _ => unexpected(status, body),
27    };
28    let help = match status.as_u16() {
29        403 => Some(
30            "your account or token may lack permission; tokens need the YouTrack scope".to_string(),
31        ),
32        404 => Some("check the ID; you may also lack permission to see it".to_string()),
33        _ => None,
34    };
35    YtError::Api {
36        method: method.to_string(),
37        path: path.to_string(),
38        status: status.as_u16(),
39        message,
40        help,
41    }
42    .into()
43}
44
45/// A readable description of a body that is not YouTrack's JSON error.
46pub fn unexpected(status: StatusCode, body: &str) -> String {
47    let trimmed = body.trim_start();
48    if trimmed.starts_with('<') {
49        return format!(
50            "unexpected HTML response (HTTP {status}); a proxy or sign-in page may be in the way"
51        );
52    }
53    let excerpt = crate::redact::redact(&trimmed.chars().take(200).collect::<String>());
54    if excerpt.is_empty() {
55        format!("HTTP {status} with an empty body")
56    } else {
57        format!("HTTP {status}: {excerpt}")
58    }
59}
60
61fn describe(body: &ErrorBody, status: StatusCode) -> String {
62    let mut message = body
63        .error_description
64        .clone()
65        .filter(|description| !description.is_empty())
66        .or_else(|| body.error.clone())
67        .unwrap_or_else(|| format!("HTTP {status}"));
68    let children: Vec<&str> = body
69        .error_children
70        .iter()
71        .filter_map(|child| child.error.as_deref())
72        .filter(|error| !error.is_empty())
73        .collect();
74    if !children.is_empty() {
75        message = format!("{message}: {}", children.join("; "));
76    }
77    if let Some(field) = &body.error_field {
78        message = format!("{message} (field: {field})");
79    }
80    if let Some(rule) = &body.error_rule_name {
81        message = format!("{message} (workflow rule: {rule})");
82    }
83    message
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn an_echoed_token_is_redacted_from_the_excerpt() {
92        crate::redact::register("perm-ZWNobw==.dGVzdA==.echoed123456");
93        let text = unexpected(
94            StatusCode::BAD_REQUEST,
95            "rejected: Authorization: Bearer perm-ZWNobw==.dGVzdA==.echoed123456",
96        );
97        assert!(!text.contains("echoed123456"), "{text}");
98    }
99}