use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::validation::{
CedarIpAddr, CedarTypeName, EntityId, ValidationError, validate_attribute_name,
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(tag = "type", content = "value")]
pub enum AttrValue {
String(String),
Bool(bool),
Long(i64),
Ip(CedarIpAddr),
Set(Vec<AttrValue>),
}
impl AttrValue {
pub fn ip(value: impl Into<String>) -> Result<Self, ValidationError> {
CedarIpAddr::new(value).map(Self::Ip)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(try_from = "ResourceWire")]
pub struct Resource {
kind: CedarTypeName,
id: EntityId,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
attrs: BTreeMap<String, AttrValue>,
}
#[derive(Deserialize)]
struct ResourceWire {
kind: CedarTypeName,
id: EntityId,
#[serde(default)]
attrs: BTreeMap<String, AttrValue>,
}
impl TryFrom<ResourceWire> for Resource {
type Error = ValidationError;
fn try_from(wire: ResourceWire) -> Result<Self, Self::Error> {
let resource = Self {
kind: wire.kind,
id: wire.id,
attrs: wire.attrs,
};
resource.validate()?;
Ok(resource)
}
}
impl Resource {
pub fn new(kind: impl Into<String>, id: impl Into<String>) -> Result<Self, ValidationError> {
let resource = Self {
kind: CedarTypeName::new(kind),
id: EntityId::new(id),
attrs: BTreeMap::new(),
};
resource.validate()?;
Ok(resource)
}
pub fn with_attr(
mut self,
key: impl Into<String>,
value: AttrValue,
) -> Result<Self, ValidationError> {
let key = key.into();
validate_attribute_name(&key, "resource.attrs")?;
self.attrs.insert(key, value);
Ok(self)
}
pub fn kind(&self) -> &str {
self.kind.as_str()
}
pub fn id(&self) -> &str {
self.id.as_str()
}
pub fn attrs(&self) -> &BTreeMap<String, AttrValue> {
&self.attrs
}
pub fn attr(&self, key: &str) -> Option<&AttrValue> {
self.attrs.get(key)
}
pub fn validate(&self) -> Result<(), ValidationError> {
self.kind.validate("resource.kind")?;
self.id.validate("resource.id")?;
for key in self.attrs.keys() {
validate_attribute_name(key, "resource.attrs")?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[test]
fn resource_without_attrs() {
let resource = Resource::new("Host", "web-01").unwrap();
let json = serde_json::to_value(&resource).unwrap();
assert_eq!(json["kind"], "Host");
assert_eq!(json["id"], "web-01");
assert!(json.get("attrs").is_none());
}
#[test]
fn resource_with_attrs() {
let resource = Resource::new("Document", "doc1")
.unwrap()
.with_attr("owner", AttrValue::String("alice".to_string()))
.unwrap()
.with_attr("public", AttrValue::Bool(false))
.unwrap()
.with_attr("priority", AttrValue::Long(5))
.unwrap()
.with_attr("ip", AttrValue::ip("10.0.0.1").unwrap());
let resource = resource.unwrap();
let json = serde_json::to_value(&resource).unwrap();
assert!(json["attrs"].is_object());
assert_eq!(json["attrs"]["owner"]["type"], "String");
assert_eq!(json["attrs"]["owner"]["value"], "alice");
assert_eq!(json["attrs"]["public"]["type"], "Bool");
}
#[rstest]
#[case::string(AttrValue::String("hello".to_string()))]
#[case::bool_true(AttrValue::Bool(true))]
#[case::bool_false(AttrValue::Bool(false))]
#[case::long_positive(AttrValue::Long(42))]
#[case::long_negative(AttrValue::Long(-1))]
#[case::long_zero(AttrValue::Long(0))]
#[case::ip_v4(AttrValue::ip("192.168.1.1").unwrap())]
#[case::ip_cidr(AttrValue::ip("10.0.0.0/8").unwrap())]
#[case::set(AttrValue::Set(vec![AttrValue::String("a".to_string()), AttrValue::String("b".to_string())]))]
#[case::empty_set(AttrValue::Set(vec![]))]
#[case::nested_set(AttrValue::Set(vec![AttrValue::Set(vec![AttrValue::Long(1)])]))]
fn attrvalue_roundtrip(#[case] val: AttrValue) {
let json = serde_json::to_value(&val).unwrap();
let deserialized: AttrValue = serde_json::from_value(json).unwrap();
assert_eq!(val, deserialized);
}
#[test]
fn resource_roundtrip() {
let resource = Resource::new("Host", "web-01")
.unwrap()
.with_attr("ip", AttrValue::ip("10.0.0.1").unwrap())
.unwrap();
let json = serde_json::to_value(&resource).unwrap();
let deserialized: Resource = serde_json::from_value(json).unwrap();
assert_eq!(resource, deserialized);
}
}