use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Capability {
pub kind: String,
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub maturity: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fields: Vec<FieldSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub children: Vec<Capability>,
}
impl Capability {
#[must_use]
pub fn new(kind: impl Into<String>, name: impl Into<String>) -> Self {
Self {
kind: kind.into(),
name: name.into(),
description: String::new(),
maturity: None,
fields: Vec::new(),
children: Vec::new(),
}
}
#[must_use]
pub fn source(name: impl Into<String>) -> Self {
Self::new("source", name)
}
#[must_use]
pub fn service(name: impl Into<String>) -> Self {
Self::new("service", name)
}
#[must_use]
pub fn transport(name: impl Into<String>) -> Self {
Self::new("transport", name)
}
#[must_use]
pub fn sink(name: impl Into<String>) -> Self {
Self::new("sink", name)
}
#[must_use]
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = description.into();
self
}
#[must_use]
pub fn maturity(mut self, maturity: impl Into<String>) -> Self {
self.maturity = Some(maturity.into());
self
}
#[must_use]
pub fn field(mut self, field: FieldSpec) -> Self {
self.fields.push(field);
self
}
#[must_use]
pub fn fields(mut self, fields: impl IntoIterator<Item = FieldSpec>) -> Self {
self.fields.extend(fields);
self
}
#[must_use]
pub fn child(mut self, child: Capability) -> Self {
self.children.push(child);
self
}
#[must_use]
pub fn children(mut self, children: impl IntoIterator<Item = Capability>) -> Self {
self.children.extend(children);
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FieldSpec {
pub name: String,
#[serde(rename = "type")]
pub type_: FieldType,
pub required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<serde_json::Value>,
#[serde(default)]
pub description: String,
#[serde(default, skip_serializing_if = "is_false")]
pub secret: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub enum_values: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub example: Option<serde_json::Value>,
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_false(b: &bool) -> bool {
!*b
}
impl FieldSpec {
#[must_use]
pub fn new(name: impl Into<String>, type_: FieldType) -> Self {
Self {
name: name.into(),
type_,
required: false,
default: None,
description: String::new(),
secret: false,
enum_values: Vec::new(),
example: None,
}
}
#[must_use]
pub fn string(name: impl Into<String>) -> Self {
Self::new(name, FieldType::String)
}
#[must_use]
pub fn int(name: impl Into<String>) -> Self {
Self::new(name, FieldType::Int)
}
#[must_use]
pub fn float(name: impl Into<String>) -> Self {
Self::new(name, FieldType::Float)
}
#[must_use]
pub fn bool(name: impl Into<String>) -> Self {
Self::new(name, FieldType::Bool)
}
#[must_use]
pub fn secret(name: impl Into<String>) -> Self {
let mut f = Self::new(name, FieldType::Secret);
f.secret = true;
f
}
#[must_use]
pub fn enumeration(
name: impl Into<String>,
values: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
let mut f = Self::new(name, FieldType::Enum);
f.enum_values = values.into_iter().map(Into::into).collect();
f
}
#[must_use]
pub fn duration(name: impl Into<String>) -> Self {
Self::new(name, FieldType::Duration)
}
#[must_use]
pub fn list(name: impl Into<String>) -> Self {
Self::new(name, FieldType::List)
}
#[must_use]
pub fn map(name: impl Into<String>) -> Self {
Self::new(name, FieldType::Map)
}
#[must_use]
pub fn object(name: impl Into<String>) -> Self {
Self::new(name, FieldType::Object)
}
#[must_use]
pub fn required(mut self) -> Self {
self.required = true;
self
}
#[must_use]
pub fn mark_secret(mut self) -> Self {
self.secret = true;
self
}
#[must_use]
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = description.into();
self
}
#[must_use]
pub fn default_value(mut self, value: impl Into<serde_json::Value>) -> Self {
self.default = Some(value.into());
self
}
#[must_use]
pub fn example(mut self, value: impl Into<serde_json::Value>) -> Self {
self.example = Some(value.into());
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum FieldType {
#[default]
String,
Int,
Float,
Bool,
Secret,
Enum,
Duration,
List,
Map,
Object,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn field_type_serialises_snake_case() {
assert_eq!(
serde_json::to_value(FieldType::Secret).unwrap(),
serde_json::json!("secret")
);
assert_eq!(
serde_json::to_value(FieldType::Duration).unwrap(),
serde_json::json!("duration")
);
}
#[test]
fn secret_field_flags_secret_and_type() {
let f = FieldSpec::secret("password");
assert_eq!(f.type_, FieldType::Secret);
assert!(f.secret);
let v = serde_json::to_value(&f).unwrap();
assert_eq!(v["type"], "secret");
assert_eq!(v["secret"], true);
}
#[test]
fn empty_and_default_fields_are_omitted() {
let f = FieldSpec::string("region");
let v = serde_json::to_value(&f).unwrap();
let obj = v.as_object().unwrap();
assert_eq!(obj.len(), 4, "expected name/type/required/description: {v}");
assert!(obj.contains_key("name"));
assert!(obj.contains_key("type"));
assert!(obj.contains_key("required"));
assert!(obj.contains_key("description"));
assert!(!obj.contains_key("secret"));
assert!(!obj.contains_key("default"));
assert!(!obj.contains_key("enum_values"));
assert!(!obj.contains_key("example"));
}
#[test]
fn capability_builder_nests_children() {
let cap = Capability::source("aws")
.description("AWS sources.")
.maturity("stable")
.field(FieldSpec::string("id").required())
.child(Capability::service("cloudtrail").maturity("stable"));
assert_eq!(cap.kind, "source");
assert_eq!(cap.fields.len(), 1);
assert_eq!(cap.children.len(), 1);
assert_eq!(cap.children[0].kind, "service");
let v = serde_json::to_value(&cap).unwrap();
assert_eq!(v["kind"], "source");
assert_eq!(v["maturity"], "stable");
assert_eq!(v["fields"][0]["name"], "id");
assert_eq!(v["fields"][0]["required"], true);
assert_eq!(v["children"][0]["name"], "cloudtrail");
}
#[test]
fn empty_capability_omits_optional_collections() {
let cap = Capability::service("plain");
let v = serde_json::to_value(&cap).unwrap();
let obj = v.as_object().unwrap();
assert!(!obj.contains_key("maturity"));
assert!(!obj.contains_key("fields"));
assert!(!obj.contains_key("children"));
}
#[test]
fn catalog_round_trips_through_json() {
let cap = Capability::source("okta")
.field(FieldSpec::secret("token").description("SSWS token."))
.field(FieldSpec::enumeration("include", ["all", "web", "git"]));
let json = serde_json::to_string(&cap).unwrap();
let back: Capability = serde_json::from_str(&json).unwrap();
assert_eq!(cap, back);
}
}