use thiserror::Error as ThisError;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, ThisError)]
pub enum Error {
#[error("DIMESCHEDULER_API_KEY environment variable is not set")]
MissingApiKey,
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("API error: status {status}, body: {body}")]
Api { status: u16, body: String },
}
pub(crate) fn extract_error_message(body: &str) -> String {
let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else {
return body.to_string();
};
if let Some(text) = value.as_str() {
return text.to_string();
}
if let Some(message) = non_empty(&value, "Description").or_else(|| non_empty(&value, "Error")) {
return match non_empty(&value, "Code") {
Some(code) => format!("{message} (code: {code})"),
None => message,
};
}
if let Some(errors) = value.get("errors").and_then(|e| e.as_object()) {
let mut messages: Vec<String> = errors
.iter()
.flat_map(|(field, msgs)| {
msgs.as_array()
.into_iter()
.flatten()
.filter_map(|m| m.as_str())
.map(move |m| format!("{field}: {m}"))
})
.collect();
if !messages.is_empty() {
messages.sort();
return messages.join("; ");
}
}
for key in ["detail", "title"] {
if let Some(text) = non_empty(&value, key) {
return text;
}
}
body.to_string()
}
fn non_empty(value: &serde_json::Value, key: &str) -> Option<String> {
value
.get(key)
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::extract_error_message;
#[test]
fn web_api_exception_prefers_description_and_appends_code() {
let body = r#"{"Error":"Validation failed","Description":"jobNo is required","Code":"E1001","Link":null}"#;
assert_eq!(
extract_error_message(body),
"jobNo is required (code: E1001)"
);
}
#[test]
fn web_api_exception_falls_back_to_error_field() {
let body = r#"{"Error":"Validation failed","Description":"","Code":""}"#;
assert_eq!(extract_error_message(body), "Validation failed");
}
#[test]
fn validation_problem_details_flattens_field_errors() {
let body = r#"{"title":"One or more validation errors occurred.","status":400,
"errors":{"Resources":["At least one resource is required."],"EndDate":["must be after StartDate"]}}"#;
assert_eq!(
extract_error_message(body),
"EndDate: must be after StartDate; Resources: At least one resource is required."
);
}
#[test]
fn problem_details_prefers_detail_then_title() {
assert_eq!(
extract_error_message(
r#"{"title":"Forbidden","detail":"API key lacks scope","status":403}"#
),
"API key lacks scope"
);
assert_eq!(
extract_error_message(r#"{"title":"Forbidden","status":403}"#),
"Forbidden"
);
}
#[test]
fn bare_json_string_is_unquoted() {
assert_eq!(
extract_error_message(r#""startDate is required""#),
"startDate is required"
);
}
#[test]
fn falls_back_to_raw_body() {
assert_eq!(
extract_error_message("<html>502 Bad Gateway</html>"),
"<html>502 Bad Gateway</html>"
);
assert_eq!(
extract_error_message(r#"{"unexpected":true}"#),
r#"{"unexpected":true}"#
);
assert_eq!(extract_error_message(""), "");
}
#[test]
fn display_formats_are_stable() {
let e = super::Error::Api {
status: 404,
body: "not found".into(),
};
assert_eq!(e.to_string(), "API error: status 404, body: not found");
assert_eq!(
super::Error::MissingApiKey.to_string(),
"DIMESCHEDULER_API_KEY environment variable is not set"
);
}
}