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
45pub 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}