dimescheduler 0.1.0

Official Rust client library for the Dime.Scheduler API.
Documentation
use thiserror::Error as ThisError;

/// Convenience alias for results returned by this crate.
pub type Result<T> = std::result::Result<T, Error>;

/// Errors that can occur when using the Dime.Scheduler client.
#[derive(Debug, ThisError)]
pub enum Error {
    /// `DimeSchedulerClient::from_env` found no `DIMESCHEDULER_API_KEY`.
    #[error("DIMESCHEDULER_API_KEY environment variable is not set")]
    MissingApiKey,

    /// The underlying HTTP transport failed (connect, timeout, TLS, or the
    /// response body could not be decoded as the expected JSON type).
    #[error("HTTP request failed: {0}")]
    Http(#[from] reqwest::Error),

    /// The API answered with a non-2xx status. `body` is the most useful
    /// message extracted from the response (see `extract_error_message`),
    /// or the raw body when its shape is unknown.
    #[error("API error: status {status}, body: {body}")]
    Api { status: u16, body: String },
}

/// Reduces an error response body to one human-readable line.
///
/// Recognised shapes, in order:
/// 1. This API's `Core.WebApiException`: `{Error, Description, Code, Link}` —
///    `Description`, else `Error`, with ` (code: X)` appended when `Code` is set.
/// 2. ASP.NET `ValidationProblemDetails`: `errors` keyed by field, flattened
///    to `field: message; …` sorted by field.
/// 3. ASP.NET `ProblemDetails`: `detail`, else `title`.
/// 4. A bare JSON string.
///
/// Anything else is returned unchanged so no information is lost.
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();
    }

    // 1. WebApiException
    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,
        };
    }

    // 2. ValidationProblemDetails
    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("; ");
        }
    }

    // 3. ProblemDetails
    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"
        );
    }
}