sail-rs 0.6.3

Official Rust SDK for Sail: create and drive sailboxes (sandboxed cloud VMs) with lifecycle, streaming exec, file transfer, and ingress.
Documentation
//! Maps the REST API's JSON error envelope onto the typed [`SailError`]
//! taxonomy.
//!
//! Every non-2xx body from the Sail REST hosts carries the same
//! `{"error": {"type", "message"}}` shape, so reading it is one concern shared
//! by every resource module rather than something each one restates.

use serde_json::Value;

use crate::error::SailError;

/// The envelope's human-readable message, or `default` when the body has none.
pub(crate) fn api_error_message(data: &Value, default: &str) -> String {
    data.get("error")
        .and_then(|e| e.get("message"))
        .and_then(Value::as_str)
        .filter(|s| !s.is_empty())
        .unwrap_or(default)
        .to_string()
}

/// A 404 body the API tags as a real missing resource (vs a route-level 404).
pub(crate) fn is_resource_not_found(data: &Value) -> bool {
    data.get("error")
        .and_then(|e| e.get("type"))
        .and_then(Value::as_str)
        == Some("not_found_error")
}

/// Map a non-2xx response onto the canonical taxonomy: resource-miss 404 →
/// NotFound, 401/403 → PermissionDenied, 400 → InvalidArgument, any other →
/// Api (a transient/unexpected API failure).
pub(crate) fn raise_api_error(status: u16, data: &Value, context: &str) -> Result<(), SailError> {
    if status < 300 {
        return Ok(());
    }
    let mut message = api_error_message(data, "request failed");
    if !context.is_empty() {
        message = format!("{context}: {message}");
    }
    if status == 404 && is_resource_not_found(data) {
        return Err(SailError::NotFound { message });
    }
    if status == 401 || status == 403 {
        return Err(SailError::PermissionDenied { message });
    }
    if status == 400 {
        return Err(SailError::InvalidArgument { message });
    }
    Err(SailError::Api {
        status,
        message,
        body: data.clone(),
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn api_error_ladder_maps_statuses() {
        let not_found = json!({"error": {"type": "not_found_error", "message": "gone"}});
        assert!(matches!(
            raise_api_error(404, &not_found, ""),
            Err(SailError::NotFound { .. })
        ));
        // A route-level 404 (no not_found_error type) is transient, not a miss.
        let route_404 = json!({"error": {"message": "no route"}});
        assert!(matches!(
            raise_api_error(404, &route_404, ""),
            Err(SailError::Api { .. })
        ));
        let auth = json!({"error": {"message": "nope"}});
        assert!(matches!(
            raise_api_error(403, &auth, ""),
            Err(SailError::PermissionDenied { .. })
        ));
        assert!(matches!(
            raise_api_error(400, &auth, ""),
            Err(SailError::InvalidArgument { .. })
        ));
        assert!(matches!(
            raise_api_error(503, &auth, ""),
            Err(SailError::Api { status: 503, .. })
        ));
        assert!(raise_api_error(200, &json!({}), "").is_ok());
    }
}