use schemars::{JsonSchema, schema_for};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use super::bidirectional::{StandardRequest, StandardResponse};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PluginSchema {
pub namespace: String,
pub version: String,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub long_description: Option<String>,
pub self_hash: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub children_hash: Option<String>,
pub hash: String,
pub methods: Vec<MethodSchema>,
#[serde(skip_serializing_if = "Option::is_none")]
pub children: Option<Vec<ChildSummary>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum SchemaResult {
Plugin(PluginSchema),
Method(MethodSchema),
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MethodSchema {
pub name: String,
pub description: String,
pub hash: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub params: Option<schemars::Schema>,
#[serde(skip_serializing_if = "Option::is_none")]
pub returns: Option<schemars::Schema>,
#[serde(default)]
pub streaming: bool,
#[serde(default)]
pub bidirectional: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub request_type: Option<schemars::Schema>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_type: Option<schemars::Schema>,
}
impl PluginSchema {
fn compute_hashes(
methods: &[MethodSchema],
children: Option<&[ChildSummary]>,
) -> (String, Option<String>, String) {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut self_hasher = DefaultHasher::new();
for m in methods {
m.hash.hash(&mut self_hasher);
}
let self_hash = format!("{:016x}", self_hasher.finish());
let children_hash = children.map(|kids| {
let mut children_hasher = DefaultHasher::new();
for c in kids {
c.hash.hash(&mut children_hasher);
}
format!("{:016x}", children_hasher.finish())
});
let mut composite_hasher = DefaultHasher::new();
self_hash.hash(&mut composite_hasher);
if let Some(ref ch) = children_hash {
ch.hash(&mut composite_hasher);
}
let hash = format!("{:016x}", composite_hasher.finish());
(self_hash, children_hash, hash)
}
fn validate_no_collisions(
namespace: &str,
methods: &[MethodSchema],
children: Option<&[ChildSummary]>,
) {
use std::collections::HashSet;
let mut seen: HashSet<&str> = HashSet::new();
for m in methods {
if !seen.insert(&m.name) {
panic!(
"Name collision in plugin '{}': duplicate method '{}'",
namespace, m.name
);
}
}
if let Some(kids) = children {
for c in kids {
if !seen.insert(&c.namespace) {
let collision_type = if methods.iter().any(|m| m.name == c.namespace) {
"method/child collision"
} else {
"duplicate child"
};
panic!(
"Name collision in plugin '{}': {} for '{}'",
namespace, collision_type, c.namespace
);
}
}
}
}
pub fn leaf(
namespace: impl Into<String>,
version: impl Into<String>,
description: impl Into<String>,
methods: Vec<MethodSchema>,
) -> Self {
let namespace = namespace.into();
Self::validate_no_collisions(&namespace, &methods, None);
let (self_hash, children_hash, hash) = Self::compute_hashes(&methods, None);
Self {
namespace,
version: version.into(),
description: description.into(),
long_description: None,
self_hash,
children_hash,
hash,
methods,
children: None,
}
}
pub fn leaf_with_long_description(
namespace: impl Into<String>,
version: impl Into<String>,
description: impl Into<String>,
long_description: impl Into<String>,
methods: Vec<MethodSchema>,
) -> Self {
let namespace = namespace.into();
Self::validate_no_collisions(&namespace, &methods, None);
let (self_hash, children_hash, hash) = Self::compute_hashes(&methods, None);
Self {
namespace,
version: version.into(),
description: description.into(),
long_description: Some(long_description.into()),
self_hash,
children_hash,
hash,
methods,
children: None,
}
}
pub fn hub(
namespace: impl Into<String>,
version: impl Into<String>,
description: impl Into<String>,
methods: Vec<MethodSchema>,
children: Vec<ChildSummary>,
) -> Self {
let namespace = namespace.into();
Self::validate_no_collisions(&namespace, &methods, Some(&children));
let (self_hash, children_hash, hash) = Self::compute_hashes(&methods, Some(&children));
Self {
namespace,
version: version.into(),
description: description.into(),
long_description: None,
self_hash,
children_hash,
hash,
methods,
children: Some(children),
}
}
pub fn hub_with_long_description(
namespace: impl Into<String>,
version: impl Into<String>,
description: impl Into<String>,
long_description: impl Into<String>,
methods: Vec<MethodSchema>,
children: Vec<ChildSummary>,
) -> Self {
let namespace = namespace.into();
Self::validate_no_collisions(&namespace, &methods, Some(&children));
let (self_hash, children_hash, hash) = Self::compute_hashes(&methods, Some(&children));
Self {
namespace,
version: version.into(),
description: description.into(),
long_description: Some(long_description.into()),
self_hash,
children_hash,
hash,
methods,
children: Some(children),
}
}
pub fn is_hub(&self) -> bool {
self.children.is_some()
}
pub fn is_leaf(&self) -> bool {
self.children.is_none()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ChildSummary {
pub namespace: String,
pub description: String,
pub hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PluginHashes {
pub namespace: String,
pub self_hash: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub children_hash: Option<String>,
pub hash: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub children: Option<Vec<ChildHashes>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ChildHashes {
pub namespace: String,
pub hash: String,
}
impl MethodSchema {
pub fn new(
name: impl Into<String>,
description: impl Into<String>,
hash: impl Into<String>,
) -> Self {
Self {
name: name.into(),
description: description.into(),
hash: hash.into(),
params: None,
returns: None,
streaming: false,
bidirectional: false,
request_type: None,
response_type: None,
}
}
pub fn with_params(mut self, params: schemars::Schema) -> Self {
self.params = Some(params);
self
}
pub fn with_returns(mut self, returns: schemars::Schema) -> Self {
self.returns = Some(returns);
self
}
pub fn with_streaming(mut self, streaming: bool) -> Self {
self.streaming = streaming;
self
}
pub fn with_bidirectional(mut self, bidirectional: bool) -> Self {
self.bidirectional = bidirectional;
self
}
pub fn with_request_type(mut self, schema: schemars::Schema) -> Self {
self.request_type = Some(schema);
self
}
pub fn with_response_type(mut self, schema: schemars::Schema) -> Self {
self.response_type = Some(schema);
self
}
pub fn with_standard_bidirectional(self) -> Self {
self.with_bidirectional(true)
.with_request_type(schema_for!(StandardRequest).into())
.with_response_type(schema_for!(StandardResponse).into())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Schema {
#[serde(rename = "$schema", skip_serializing_if = "Option::is_none", default)]
pub schema_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub schema_type: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub properties: Option<HashMap<String, SchemaProperty>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub required: Option<Vec<String>>,
#[serde(rename = "oneOf", skip_serializing_if = "Option::is_none")]
pub one_of: Option<Vec<Schema>>,
#[serde(rename = "$defs", skip_serializing_if = "Option::is_none")]
pub defs: Option<HashMap<String, serde_json::Value>>,
#[serde(flatten)]
pub additional: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum SchemaType {
Object,
Array,
String,
Number,
Integer,
Boolean,
Null,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaProperty {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub property_type: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub items: Option<Box<SchemaProperty>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub properties: Option<HashMap<String, SchemaProperty>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub required: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<serde_json::Value>,
#[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
pub enum_values: Option<Vec<serde_json::Value>>,
#[serde(rename = "$ref", skip_serializing_if = "Option::is_none")]
pub reference: Option<String>,
#[serde(flatten)]
pub additional: HashMap<String, serde_json::Value>,
}
impl Schema {
pub fn new(title: impl Into<String>, description: impl Into<String>) -> Self {
Self {
schema_version: Some("http://json-schema.org/draft-07/schema#".to_string()),
title: Some(title.into()),
description: Some(description.into()),
schema_type: None,
properties: None,
required: None,
one_of: None,
defs: None,
additional: HashMap::new(),
}
}
pub fn object() -> Self {
Self {
schema_version: Some("http://json-schema.org/draft-07/schema#".to_string()),
title: None,
description: None,
schema_type: Some(serde_json::json!("object")),
properties: Some(HashMap::new()),
required: None,
one_of: None,
defs: None,
additional: HashMap::new(),
}
}
pub fn with_property(mut self, name: impl Into<String>, property: SchemaProperty) -> Self {
self.properties
.get_or_insert_with(HashMap::new)
.insert(name.into(), property);
self
}
pub fn with_required(mut self, name: impl Into<String>) -> Self {
self.required
.get_or_insert_with(Vec::new)
.push(name.into());
self
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn get_method_schema(&self, method_name: &str) -> Option<Schema> {
let variants = self.one_of.as_ref()?;
for variant in variants {
if let Some(props) = &variant.properties {
if let Some(method_prop) = props.get("method") {
if let Some(const_val) = method_prop.additional.get("const") {
if const_val.as_str() == Some(method_name) {
return Some(variant.clone());
}
}
if let Some(enum_vals) = &method_prop.enum_values {
if enum_vals.first().and_then(|v| v.as_str()) == Some(method_name) {
return Some(variant.clone());
}
}
}
}
}
None
}
pub fn list_methods(&self) -> Vec<String> {
let Some(variants) = &self.one_of else {
return Vec::new();
};
variants
.iter()
.filter_map(|variant| {
let props = variant.properties.as_ref()?;
let method_prop = props.get("method")?;
if let Some(const_val) = method_prop.additional.get("const") {
return const_val.as_str().map(String::from);
}
method_prop
.enum_values
.as_ref()?
.first()?
.as_str()
.map(String::from)
})
.collect()
}
}
impl SchemaProperty {
pub fn string() -> Self {
Self {
property_type: Some(serde_json::json!("string")),
description: None,
format: None,
items: None,
properties: None,
required: None,
default: None,
enum_values: None,
reference: None,
additional: HashMap::new(),
}
}
pub fn uuid() -> Self {
Self {
property_type: Some(serde_json::json!("string")),
description: None,
format: Some("uuid".to_string()),
items: None,
properties: None,
required: None,
default: None,
enum_values: None,
reference: None,
additional: HashMap::new(),
}
}
pub fn integer() -> Self {
Self {
property_type: Some(serde_json::json!("integer")),
description: None,
format: None,
items: None,
properties: None,
required: None,
default: None,
enum_values: None,
reference: None,
additional: HashMap::new(),
}
}
pub fn object() -> Self {
Self {
property_type: Some(serde_json::json!("object")),
description: None,
format: None,
items: None,
properties: Some(HashMap::new()),
required: None,
default: None,
enum_values: None,
reference: None,
additional: HashMap::new(),
}
}
pub fn array(items: SchemaProperty) -> Self {
Self {
property_type: Some(serde_json::json!("array")),
description: None,
format: None,
items: Some(Box::new(items)),
properties: None,
required: None,
default: None,
enum_values: None,
reference: None,
additional: HashMap::new(),
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_default(mut self, default: serde_json::Value) -> Self {
self.default = Some(default);
self
}
pub fn with_property(mut self, name: impl Into<String>, property: SchemaProperty) -> Self {
self.properties
.get_or_insert_with(HashMap::new)
.insert(name.into(), property);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_schema_creation() {
let schema = Schema::object()
.with_property("id", SchemaProperty::uuid().with_description("The unique identifier"))
.with_property("name", SchemaProperty::string().with_description("The name"))
.with_required("id");
assert_eq!(schema.schema_type, Some(serde_json::json!("object")));
assert!(schema.properties.is_some());
assert_eq!(schema.required, Some(vec!["id".to_string()]));
}
#[test]
fn test_serialization() {
let schema = Schema::object()
.with_property("id", SchemaProperty::uuid());
let json = serde_json::to_string_pretty(&schema).unwrap();
assert!(json.contains("uuid"));
}
#[test]
fn test_self_hash_changes_on_method_change() {
let schema1 = PluginSchema::leaf(
"test",
"1.0",
"desc",
vec![MethodSchema::new("foo", "bar", "hash1")],
);
let schema2 = PluginSchema::leaf(
"test",
"1.0",
"desc",
vec![MethodSchema::new("foo", "baz", "hash2")], );
assert_ne!(schema1.self_hash, schema2.self_hash, "self_hash should change when methods change");
assert_eq!(schema1.children_hash, schema2.children_hash, "children_hash should stay same (both None)");
assert_ne!(schema1.hash, schema2.hash, "composite hash should change");
}
#[test]
fn test_children_hash_changes_on_child_change() {
let child1 = ChildSummary {
namespace: "child".into(),
description: "desc".into(),
hash: "old_hash".into(),
};
let child2 = ChildSummary {
namespace: "child".into(),
description: "desc".into(),
hash: "new_hash".into(),
};
let schema1 = PluginSchema::hub(
"parent",
"1.0",
"desc",
vec![],
vec![child1],
);
let schema2 = PluginSchema::hub(
"parent",
"1.0",
"desc",
vec![],
vec![child2],
);
assert_eq!(schema1.self_hash, schema2.self_hash, "self_hash should stay same (no methods changed)");
assert_ne!(schema1.children_hash, schema2.children_hash, "children_hash should change when child hash changes");
assert_ne!(schema1.hash, schema2.hash, "composite hash should change");
}
#[test]
fn test_leaf_has_no_children_hash() {
let schema = PluginSchema::leaf(
"leaf",
"1.0",
"desc",
vec![MethodSchema::new("method", "desc", "hash")],
);
assert!(schema.children_hash.is_none(), "leaf plugin should have None for children_hash");
assert_ne!(schema.self_hash, schema.hash, "leaf plugin's composite hash is hash(self_hash), not equal to self_hash");
}
}