zen-engine 2.0.0

Business rules engine
Documentation
use std::sync::Arc;

use serde::{Deserialize, Deserializer, Serialize, Serializer};

use crate::policy::blocks::{AssertionDoc, DecisionTableDoc, ExpressionDoc, MatchDoc};

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PolicyDocument {
    #[serde(default)]
    pub imports: Vec<Arc<str>>,
    pub blocks: Vec<BlockDoc>,
}

#[derive(Debug, Clone)]
pub enum BlockDoc {
    Assertion {
        id: Arc<str>,
        data: AssertionDoc,
    },
    DecisionTable {
        id: Arc<str>,
        data: DecisionTableDoc,
    },
    Expression {
        id: Arc<str>,
        data: ExpressionDoc,
    },
    Match {
        id: Arc<str>,
        data: MatchDoc,
    },
    DataModel {
        id: Arc<str>,
        data: DataModelDoc,
    },
    Dictionary {
        id: Arc<str>,
        data: DictionaryDoc,
    },
    Ignored(serde_json::Value),
}

impl BlockDoc {
    pub fn id(&self) -> Option<&str> {
        match self {
            Self::Assertion { id, .. }
            | Self::DecisionTable { id, .. }
            | Self::Expression { id, .. }
            | Self::Match { id, .. }
            | Self::DataModel { id, .. }
            | Self::Dictionary { id, .. } => Some(id),
            Self::Ignored(value) => value.get("id").and_then(serde_json::Value::as_str),
        }
    }

    fn decode_known(tag: BlockTag, value: serde_json::Value) -> Result<Self, serde_json::Error> {
        use serde::de::Error;

        let BlockEnvelope { id, props } = serde_json::from_value(value)?;
        let data = props
            .data
            .ok_or_else(|| serde_json::Error::missing_field("data"))?;

        match tag {
            BlockTag::Assertion => Ok(Self::Assertion {
                id,
                data: serde_json::from_value(data)?,
            }),
            BlockTag::DecisionTable => Ok(Self::DecisionTable {
                id,
                data: DecisionTableDoc::decode_wire(data).map_err(serde_json::Error::custom)?,
            }),
            BlockTag::Expression => Ok(Self::Expression {
                id,
                data: serde_json::from_value(data)?,
            }),
            BlockTag::Match => Ok(Self::Match {
                id,
                data: serde_json::from_value(data)?,
            }),
            BlockTag::DataModel => Ok(Self::DataModel {
                id,
                data: serde_json::from_value(data)?,
            }),
            BlockTag::Dictionary => Ok(Self::Dictionary {
                id,
                data: serde_json::from_value(data)?,
            }),
        }
    }
}

impl<'de> Deserialize<'de> for BlockDoc {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        use serde::de::Error;

        let value = serde_json::Value::deserialize(deserializer)?;
        let tag = match value.get("type") {
            Some(serde_json::Value::String(name)) => BlockTag::from_name(name),
            Some(_) => return Err(Error::custom("block `type` must be a string")),
            None => return Err(Error::missing_field("type")),
        };

        match tag {
            Some(tag) => Self::decode_known(tag, value).map_err(Error::custom),
            None => Ok(Self::Ignored(value)),
        }
    }
}

impl Serialize for BlockDoc {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        match self {
            Self::Assertion { id, data } => {
                TaggedBlockRef::new(BlockTag::Assertion, id, data).serialize(serializer)
            }
            Self::DecisionTable { id, data } => {
                TaggedBlockRef::new(BlockTag::DecisionTable, id, data).serialize(serializer)
            }
            Self::Expression { id, data } => {
                TaggedBlockRef::new(BlockTag::Expression, id, data).serialize(serializer)
            }
            Self::Match { id, data } => {
                TaggedBlockRef::new(BlockTag::Match, id, data).serialize(serializer)
            }
            Self::DataModel { id, data } => {
                TaggedBlockRef::new(BlockTag::DataModel, id, data).serialize(serializer)
            }
            Self::Dictionary { id, data } => {
                TaggedBlockRef::new(BlockTag::Dictionary, id, data).serialize(serializer)
            }
            Self::Ignored(value) => value.serialize(serializer),
        }
    }
}

#[derive(Clone, Copy)]
enum BlockTag {
    Assertion,
    DecisionTable,
    Expression,
    Match,
    DataModel,
    Dictionary,
}

impl BlockTag {
    fn from_name(name: &str) -> Option<Self> {
        match name {
            "assertion" => Some(Self::Assertion),
            "decisionTable" => Some(Self::DecisionTable),
            "expression" => Some(Self::Expression),
            "match" => Some(Self::Match),
            "dataModel" => Some(Self::DataModel),
            "dictionary" => Some(Self::Dictionary),
            _ => None,
        }
    }

    fn name(self) -> &'static str {
        match self {
            Self::Assertion => "assertion",
            Self::DecisionTable => "decisionTable",
            Self::Expression => "expression",
            Self::Match => "match",
            Self::DataModel => "dataModel",
            Self::Dictionary => "dictionary",
        }
    }
}

#[derive(Deserialize)]
struct BlockEnvelope {
    id: Arc<str>,
    props: PropsEnvelope,
}

#[derive(Deserialize)]
struct PropsEnvelope {
    #[serde(default)]
    data: Option<serde_json::Value>,
}

#[derive(Serialize)]
struct TaggedBlockRef<'a, T> {
    #[serde(rename = "type")]
    kind: &'static str,
    id: &'a Arc<str>,
    props: PropsRef<'a, T>,
}

impl<'a, T> TaggedBlockRef<'a, T> {
    fn new(tag: BlockTag, id: &'a Arc<str>, data: &'a T) -> Self {
        Self {
            kind: tag.name(),
            id,
            props: PropsRef { data },
        }
    }
}

#[derive(Serialize)]
struct PropsRef<'a, T> {
    data: &'a T,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DataModelDoc {
    pub name: Arc<str>,
    #[serde(default)]
    pub scope: ScopeDoc,
    #[serde(default)]
    pub properties: Vec<PropertyDoc>,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum ScopeDoc {
    #[default]
    Entity,
    Global,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PropertyDoc {
    pub id: Arc<str>,
    pub name: Arc<str>,
    #[serde(flatten)]
    pub property_type: PropertyTypeDoc,
    #[serde(default)]
    pub array: bool,
    #[serde(default)]
    pub optional: bool,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DictionaryDoc {
    pub name: Arc<str>,
    #[serde(default)]
    pub entries: Vec<DictionaryEntryDoc>,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DictionaryEntryDoc {
    #[serde(default)]
    pub id: Arc<str>,
    pub value: Arc<str>,
    #[serde(default)]
    pub label: Arc<str>,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum PropertyTypeDoc {
    String {
        #[serde(default, rename = "enum", skip_serializing_if = "Option::is_none")]
        values: Option<Vec<Arc<str>>>,
    },
    Number,
    Boolean,
    Date,
    Relationship {
        target: Arc<str>,
    },
    Reference {
        target: Arc<str>,
    },
}

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

    #[test]
    fn unknown_block_round_trips_losslessly() {
        let doc_json = serde_json::json!({
            "blocks": [
                {"type": "someLayoutBlock", "foo": 1}
            ]
        });

        let doc: PolicyDocument = serde_json::from_value(doc_json).unwrap();
        assert!(matches!(doc.blocks.as_slice(), [BlockDoc::Ignored(_)]));

        let serialized = serde_json::to_value(&doc).unwrap();
        assert_eq!(
            serialized["blocks"][0],
            serde_json::json!({"type": "someLayoutBlock", "foo": 1})
        );
    }

    #[test]
    fn known_block_round_trips() {
        let block_json = serde_json::json!({
            "type": "expression",
            "id": "b1",
            "props": {"data": {"key": "a.b", "value": "1 + 1"}}
        });

        let block: BlockDoc = serde_json::from_value(block_json.clone()).unwrap();
        assert!(matches!(block, BlockDoc::Expression { .. }));

        let serialized = serde_json::to_value(&block).unwrap();
        assert_eq!(serialized, block_json);
    }

    #[test]
    fn ignored_block_exposes_id() {
        let with_id: BlockDoc =
            serde_json::from_value(serde_json::json!({"type": "someLayoutBlock", "id": "b1"}))
                .unwrap();
        assert_eq!(with_id.id(), Some("b1"));

        let without_id: BlockDoc =
            serde_json::from_value(serde_json::json!({"type": "someLayoutBlock"})).unwrap();
        assert_eq!(without_id.id(), None);

        let non_string_id: BlockDoc =
            serde_json::from_value(serde_json::json!({"type": "someLayoutBlock", "id": 1}))
                .unwrap();
        assert_eq!(non_string_id.id(), None);
    }

    #[test]
    fn upsert_by_id_replaces_ignored_block() {
        let mut doc: PolicyDocument = serde_json::from_value(serde_json::json!({
            "blocks": [
                {"type": "someLayoutBlock", "id": "b1"}
            ]
        }))
        .unwrap();

        let new_block: BlockDoc = serde_json::from_value(serde_json::json!({
            "type": "expression",
            "id": "b1",
            "props": {"data": {"key": "a.b", "value": "1 + 1"}}
        }))
        .unwrap();
        let new_id = new_block.id().unwrap().to_string();

        match doc
            .blocks
            .iter()
            .position(|b| b.id() == Some(new_id.as_str()))
        {
            Some(pos) => doc.blocks[pos] = new_block,
            None => doc.blocks.push(new_block),
        }

        assert_eq!(doc.blocks.len(), 1);
        assert!(matches!(doc.blocks[0], BlockDoc::Expression { .. }));
    }

    #[test]
    fn block_without_type_errors() {
        let missing = serde_json::json!({"id": "b1", "props": {"data": {}}});
        let non_string = serde_json::json!({"type": 1, "id": "b1"});

        assert!(serde_json::from_value::<BlockDoc>(missing).is_err());
        assert!(serde_json::from_value::<BlockDoc>(non_string).is_err());
    }

    #[test]
    fn known_block_with_bad_payload_errors() {
        let block_json = serde_json::json!({
            "type": "expression",
            "id": "b1",
            "props": {}
        });

        assert!(serde_json::from_value::<BlockDoc>(block_json).is_err());
    }
}