spiral-contract 0.1.10

Language-neutral service wire values
Documentation
//! Stable, extensible service error envelopes.

use std::collections::BTreeMap;
use std::error::Error;
use std::fmt::{self, Display, Formatter};

use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;

/// A declared service error that transports encode using the shared envelope.
pub trait ServiceError {
    /// Encode declared parameters with the supplied correlation identity.
    fn to_envelope(&self, instance: String) -> ErrorEnvelope;

    /// Decode this declared error from a generic envelope.
    fn from_envelope(envelope: &ErrorEnvelope) -> Option<Self>
    where
        Self: Sized;
}

/// Return the canonical HTTP status for a service error code.
///
/// Unknown codes are server failures until this runtime learns their transport
/// semantics. Clients still retain an independently observed response status.
pub fn canonical_http_status(code: &str) -> u16 {
    match code {
        "UNAUTHENTICATED" => 401,
        "PERMISSION_DENIED" => 403,
        "INVALID_ARGUMENT" => 400,
        "NOT_FOUND" => 404,
        "CONFLICT" => 409,
        "FAILED_PRECONDITION" => 412,
        "REQUEST_ENTITY_TOO_LARGE" => 413,
        "UNAVAILABLE" => 503,
        "TIMEOUT" => 504,
        "INTERNAL" => 500,
        _ => 500,
    }
}

/// Generate a time-ordered correlation identity for a service error.
pub fn new_error_instance_id() -> String {
    Uuid::now_v7().to_string()
}

/// A wire error that preserves unknown names, parameters, and envelope fields.
///
/// Parameters are returned to callers. Internal diagnostics belong in server logs
/// or the underlying error cause.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ErrorEnvelope {
    /// Canonical transport code.
    pub error_code: String,
    /// Namespaced service error name.
    pub error_name: String,
    /// Identifier shared with server logs.
    pub error_instance_id: String,
    /// Contract-declared parameters returned to callers.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub parameters: BTreeMap<String, String>,
    /// Future envelope fields, retained for round trips.
    #[serde(default, flatten)]
    pub extra_fields: BTreeMap<String, Value>,
}

impl ErrorEnvelope {
    /// Construct an envelope without parameters or extension fields.
    pub fn new(
        code: impl Into<String>,
        name: impl Into<String>,
        instance: impl Into<String>,
    ) -> Self {
        Self {
            error_code: code.into(),
            error_name: name.into(),
            error_instance_id: instance.into(),
            parameters: BTreeMap::new(),
            extra_fields: BTreeMap::new(),
        }
    }
}

impl Display for ErrorEnvelope {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{} ({}; instance {})",
            self.error_name, self.error_code, self.error_instance_id
        )
    }
}

impl Error for ErrorEnvelope {}

#[cfg(test)]
mod tests {
    use serde_json::json;
    use uuid::Uuid;

    use super::{ErrorEnvelope, new_error_instance_id};

    #[test]
    fn future_errors_preserve_parameters_and_envelope_fields() {
        let value = json!({
            "errorCode": "CONFLICT", "errorName": "Future:Conflict", "errorInstanceId": "instance",
            "parameters": {"path": "input", "token": "secret"},
            "futureField": {"reason": true}
        });
        let envelope: ErrorEnvelope = serde_json::from_value(value.clone()).unwrap();
        assert_eq!(serde_json::to_value(&envelope).unwrap(), value);
        assert!(!envelope.to_string().contains("secret"));
    }

    #[test]
    fn error_instance_ids_are_uuid_v7() {
        let id = Uuid::parse_str(&new_error_instance_id()).unwrap();
        assert_eq!(id.get_version_num(), 7);
    }
}