use serde::de::Deserializer;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::fmt;
use crate::error::CapabilityError;
use crate::id::CapabilityId;
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapabilityRef {
#[serde(rename = "ref")]
id: CapabilityId,
#[serde(default = "empty_object", deserialize_with = "object_or_default")]
config: Value,
}
fn empty_object() -> Value {
Value::Object(Map::new())
}
fn object_or_default<'de, D>(deserializer: D) -> Result<Value, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
Ok(match value {
Value::Null => empty_object(),
other => other,
})
}
impl fmt::Debug for CapabilityRef {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CapabilityRef")
.field("id", &self.id)
.field("config", &"<redacted>")
.finish()
}
}
impl CapabilityRef {
pub fn new(id: impl Into<CapabilityId>) -> Self {
Self {
id: id.into(),
config: empty_object(),
}
}
pub fn with_config(id: impl Into<CapabilityId>, config: Value) -> Self {
Self {
id: id.into(),
config,
}
}
pub fn config(mut self, config: impl Into<Value>) -> Self {
self.config = config.into();
self
}
pub fn id(&self) -> &str {
self.id.as_str()
}
pub fn capability_id(&self) -> &str {
self.id.as_str()
}
pub fn typed_id(&self) -> &CapabilityId {
&self.id
}
pub fn config_value(&self) -> &Value {
&self.config
}
pub fn config_mut(&mut self) -> &mut Value {
&mut self.config
}
pub fn set_config(&mut self, config: impl Into<Value>) {
self.config = config.into();
}
pub fn set_id(&mut self, id: impl Into<CapabilityId>) {
self.id = id.into();
}
pub fn into_parts(self) -> (CapabilityId, Value) {
(self.id, self.config)
}
pub fn validate(&self) -> Result<(), CapabilityError> {
self.id.validate()?;
validate_capability_config(self.id.as_str(), &self.config)
}
}
impl From<CapabilityId> for CapabilityRef {
fn from(id: CapabilityId) -> Self {
Self::new(id)
}
}
impl From<&str> for CapabilityRef {
fn from(id: &str) -> Self {
Self::new(id)
}
}
impl From<String> for CapabilityRef {
fn from(id: String) -> Self {
Self::new(id)
}
}
pub fn validate_capability_config(id: &str, config: &Value) -> Result<(), CapabilityError> {
if config.is_object() {
Ok(())
} else {
Err(CapabilityError::InvalidConfig {
id: id.to_string(),
reason: "capability config must be a JSON object".to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn new_defaults_to_empty_object() {
let cap = CapabilityRef::new("current_time");
assert_eq!(cap.capability_id(), "current_time");
assert_eq!(cap.config_value(), &json!({}));
}
#[test]
fn serializes_as_persisted_attachment_shape() {
let cap = CapabilityRef::with_config("current_time", json!({"timezone": "UTC"}));
let value = serde_json::to_value(&cap).unwrap();
assert_eq!(
value,
json!({"ref": "current_time", "config": {"timezone": "UTC"}})
);
let parsed: CapabilityRef = serde_json::from_value(value).unwrap();
assert_eq!(parsed, cap);
}
#[test]
fn missing_and_null_config_deserialize_to_empty_object() {
let missing: CapabilityRef = serde_json::from_str(r#"{"ref":"noop"}"#).unwrap();
assert_eq!(missing.config_value(), &json!({}));
let null: CapabilityRef = serde_json::from_str(r#"{"ref":"noop","config":null}"#).unwrap();
assert_eq!(null.config_value(), &json!({}));
}
#[test]
fn debug_redacts_config_values() {
let cap = CapabilityRef::with_config(
"vendor.search",
json!({"api_key": "sk-super-secret", "index": "prod"}),
);
let debug = format!("{cap:?}");
assert!(debug.contains("vendor.search"));
assert!(!debug.contains("sk-super-secret"));
assert!(!debug.contains("api_key"));
assert!(debug.contains("<redacted>"));
}
#[test]
fn validate_enforces_id_and_object_boundary() {
CapabilityRef::new("current_time").validate().unwrap();
assert!(CapabilityRef::new("2fast").validate().is_err());
let err = CapabilityRef::with_config("noop", json!("string"))
.validate()
.unwrap_err();
assert!(err.reason().contains("JSON object"));
}
#[test]
fn builder_and_mutation() {
let mut cap = CapabilityRef::new("noop").config(json!({"a": 1}));
assert_eq!(cap.config_value(), &json!({"a": 1}));
cap.set_config(json!({"b": 2}));
assert_eq!(cap.config_value(), &json!({"b": 2}));
let (id, config) = cap.into_parts();
assert_eq!(id.as_str(), "noop");
assert_eq!(config, json!({"b": 2}));
}
}