eventuary-core 0.1.0

Core event model and async IO traits for eventuary
Documentation
use serde::{Deserialize, Serialize};

use crate::error::{Error, Result};
use crate::field_map::FieldMap;

#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct ContextError {
    kind: String,
    message: String,
}

impl ContextError {
    pub fn new(kind: impl Into<String>, message: impl Into<String>) -> Self {
        Self {
            kind: kind.into(),
            message: message.into(),
        }
    }

    pub fn kind(&self) -> &str {
        &self.kind
    }

    pub fn message(&self) -> &str {
        &self.message
    }
}

impl From<Error> for ContextError {
    fn from(err: Error) -> Self {
        Self::from(&err)
    }
}

impl From<&Error> for ContextError {
    fn from(err: &Error) -> Self {
        let (kind, message) = match err {
            Error::InvalidTopic(m) => ("invalid_topic", m.clone()),
            Error::InvalidNamespace(m) => ("invalid_namespace", m.clone()),
            Error::InvalidOrganization(m) => ("invalid_organization", m.clone()),
            Error::InvalidMetadataKey(m) => ("invalid_metadata_key", m.clone()),
            Error::InvalidPayload(m) => ("invalid_payload", m.clone()),
            Error::InvalidEventKey(m) => ("invalid_event_key", m.clone()),
            Error::InvalidConsumerGroupId(m) => ("invalid_consumer_group_id", m.clone()),
            Error::InvalidOwnerId(m) => ("invalid_owner_id", m.clone()),
            Error::OwnershipLost(m) => ("ownership_lost", m.clone()),
            Error::InvalidStartFrom(m) => ("invalid_start_from", m.clone()),
            Error::InvalidCursor(m) => ("invalid_cursor", m.clone()),
            Error::Serialization(m) => ("serialization", m.clone()),
            Error::Store(m) => ("store", m.clone()),
            Error::Handler(m) => ("handler", m.clone()),
            Error::Timeout(m) => ("timeout", m.clone()),
            Error::Config(m) => ("config", m.clone()),
        };
        Self::new(kind, message)
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", content = "value", rename_all = "snake_case")]
pub enum ContextValue {
    String(String),
    Bool(bool),
    U64(u64),
    I64(i64),
    F64(f64),
    Error(ContextError),
    Json(serde_json::Value),
}

impl ContextValue {
    fn should_store(&self) -> bool {
        match self {
            ContextValue::F64(v) => v.is_finite(),
            _ => true,
        }
    }
}

impl From<String> for ContextValue {
    fn from(value: String) -> Self {
        ContextValue::String(value)
    }
}

impl From<&str> for ContextValue {
    fn from(value: &str) -> Self {
        ContextValue::String(value.to_owned())
    }
}

impl From<bool> for ContextValue {
    fn from(value: bool) -> Self {
        ContextValue::Bool(value)
    }
}

impl From<u8> for ContextValue {
    fn from(value: u8) -> Self {
        ContextValue::U64(value.into())
    }
}

impl From<u16> for ContextValue {
    fn from(value: u16) -> Self {
        ContextValue::U64(value.into())
    }
}

impl From<u32> for ContextValue {
    fn from(value: u32) -> Self {
        ContextValue::U64(value.into())
    }
}

impl From<u64> for ContextValue {
    fn from(value: u64) -> Self {
        ContextValue::U64(value)
    }
}

impl From<usize> for ContextValue {
    fn from(value: usize) -> Self {
        ContextValue::U64(value as u64)
    }
}

impl From<i8> for ContextValue {
    fn from(value: i8) -> Self {
        ContextValue::I64(value.into())
    }
}

impl From<i16> for ContextValue {
    fn from(value: i16) -> Self {
        ContextValue::I64(value.into())
    }
}

impl From<i32> for ContextValue {
    fn from(value: i32) -> Self {
        ContextValue::I64(value.into())
    }
}

impl From<i64> for ContextValue {
    fn from(value: i64) -> Self {
        ContextValue::I64(value)
    }
}

impl From<isize> for ContextValue {
    fn from(value: isize) -> Self {
        ContextValue::I64(value as i64)
    }
}

impl From<f32> for ContextValue {
    fn from(value: f32) -> Self {
        ContextValue::F64(value.into())
    }
}

impl From<f64> for ContextValue {
    fn from(value: f64) -> Self {
        ContextValue::F64(value)
    }
}

impl From<ContextError> for ContextValue {
    fn from(value: ContextError) -> Self {
        ContextValue::Error(value)
    }
}

impl From<Error> for ContextValue {
    fn from(value: Error) -> Self {
        ContextValue::Error(ContextError::from(value))
    }
}

impl From<&Error> for ContextValue {
    fn from(value: &Error) -> Self {
        ContextValue::Error(ContextError::from(value))
    }
}

impl From<serde_json::Value> for ContextValue {
    fn from(value: serde_json::Value) -> Self {
        ContextValue::Json(value)
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Context {
    message: String,
    fields: FieldMap<ContextValue>,
}

impl Context {
    pub fn new(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
            fields: FieldMap::new(),
        }
    }

    pub fn with<V: Into<ContextValue>>(mut self, key: impl Into<String>, value: V) -> Result<Self> {
        let key = key.into();
        FieldMap::<ContextValue>::validate_key(&key)?;
        let value = value.into();
        if !value.should_store() {
            return Ok(self);
        }
        self.fields = self.fields.with(key, value)?;
        Ok(self)
    }

    pub fn message(&self) -> &str {
        &self.message
    }

    pub fn get(&self, key: &str) -> Option<&ContextValue> {
        self.fields.get(key)
    }

    pub fn has(&self, key: &str) -> bool {
        self.fields.has(key)
    }

    pub fn fields(&self) -> &FieldMap<ContextValue> {
        &self.fields
    }
}

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

    #[test]
    fn context_with_accepts_common_value_types() {
        let ctx = Context::new("processing failed")
            .with("handler_id", "billing")
            .unwrap()
            .with("retry", true)
            .unwrap()
            .with("attempt", 3u32)
            .unwrap()
            .with("offset", -5i64)
            .unwrap()
            .with("ratio", 0.5f64)
            .unwrap()
            .with("source_error", Error::Handler("boom".to_owned()))
            .unwrap();

        assert_eq!(ctx.message(), "processing failed");
        assert_eq!(
            ctx.get("handler_id"),
            Some(&ContextValue::String("billing".to_owned()))
        );
        assert_eq!(ctx.get("retry"), Some(&ContextValue::Bool(true)));
        assert_eq!(ctx.get("attempt"), Some(&ContextValue::U64(3)));
        assert_eq!(ctx.get("offset"), Some(&ContextValue::I64(-5)));
        assert_eq!(ctx.get("ratio"), Some(&ContextValue::F64(0.5)));
        assert!(matches!(
            ctx.get("source_error"),
            Some(ContextValue::Error(e)) if e.kind() == "handler" && e.message() == "boom"
        ));
        assert!(ctx.has("handler_id"));
    }

    #[test]
    fn serde_json_value_keeps_json_variant_for_any_shape() {
        let ctx = Context::new("payload")
            .with(
                "json_string",
                serde_json::Value::String("billing".to_owned()),
            )
            .unwrap()
            .with(
                "json_object",
                serde_json::json!({"nested": {"k": [1, 2, 3]}}),
            )
            .unwrap();

        assert!(matches!(
            ctx.get("json_string"),
            Some(ContextValue::Json(serde_json::Value::String(s))) if s == "billing"
        ));
        assert!(matches!(
            ctx.get("json_object"),
            Some(ContextValue::Json(serde_json::Value::Object(_)))
        ));

        let json = serde_json::to_value(&ctx).unwrap();
        assert_eq!(
            json["fields"]["json_string"],
            serde_json::json!({"type": "json", "value": "billing"})
        );
    }

    #[test]
    fn non_finite_f64_values_are_omitted() {
        let ctx = Context::new("nan")
            .with("nan_value", f64::NAN)
            .unwrap()
            .with("inf_value", f64::INFINITY)
            .unwrap()
            .with("neg_inf", f64::NEG_INFINITY)
            .unwrap()
            .with("finite", 1.5f64)
            .unwrap();

        assert!(!ctx.has("nan_value"));
        assert!(!ctx.has("inf_value"));
        assert!(!ctx.has("neg_inf"));
        assert_eq!(ctx.get("finite"), Some(&ContextValue::F64(1.5)));
        assert_eq!(ctx.get("nan_value"), None);
    }

    #[test]
    fn context_value_serialization_is_tagged() {
        let ctx = Context::new("processing failed")
            .with("handler_id", "billing")
            .unwrap()
            .with("attempt", 3u32)
            .unwrap();

        let json = serde_json::to_value(&ctx).unwrap();
        assert_eq!(
            json["message"],
            serde_json::Value::String("processing failed".to_owned())
        );
        assert_eq!(
            json["fields"]["handler_id"],
            serde_json::json!({"type": "string", "value": "billing"})
        );
        assert_eq!(
            json["fields"]["attempt"],
            serde_json::json!({"type": "u64", "value": 3})
        );
    }

    #[test]
    fn context_roundtrips_through_json() {
        let ctx = Context::new("processing failed")
            .with("handler_id", "billing")
            .unwrap()
            .with("attempt", 3u32)
            .unwrap()
            .with("ratio", 0.25f64)
            .unwrap()
            .with("source_error", Error::Store("disk full".to_owned()))
            .unwrap()
            .with("payload", serde_json::json!({"k": "v"}))
            .unwrap();

        let serialized = serde_json::to_string(&ctx).unwrap();
        let deserialized: Context = serde_json::from_str(&serialized).unwrap();

        assert_eq!(deserialized.message(), ctx.message());
        assert_eq!(deserialized.get("handler_id"), ctx.get("handler_id"));
        assert_eq!(deserialized.get("attempt"), ctx.get("attempt"));
        assert_eq!(deserialized.get("ratio"), ctx.get("ratio"));
        assert_eq!(deserialized.get("source_error"), ctx.get("source_error"));
        assert_eq!(deserialized.get("payload"), ctx.get("payload"));
    }
}