use schemars::{JsonSchema, schema_for};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use plexus_auth_core::{
AttachmentSite, CredentialFieldMarker, CredentialIssuer, CredentialKind, CredentialMetadata,
Scope,
};
use super::bidirectional::{StandardRequest, StandardResponse};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum MethodRole {
Rpc,
StaticChild,
DynamicChild {
#[serde(default, skip_serializing_if = "Option::is_none")]
list_method: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
search_method: Option<String>,
},
}
impl Default for MethodRole {
fn default() -> Self {
MethodRole::Rpc
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct DeprecationInfo {
pub since: String,
pub removed_in: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ParamSchema {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deprecation: Option<DeprecationInfo>,
}
impl ParamSchema {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
deprecation: None,
}
}
pub fn with_deprecation(mut self, info: DeprecationInfo) -> Self {
self.deprecation = Some(info);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct CredentialFieldDecl {
pub field_path: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub variant_tag: Option<String>,
pub metadata: CredentialMetadata,
}
impl CredentialFieldDecl {
pub fn from_marker(
marker: &CredentialFieldMarker,
path_prefix: &[&str],
issuer: CredentialIssuer,
) -> Self {
let mut field_path: Vec<String> = path_prefix.iter().map(|s| (*s).to_owned()).collect();
field_path.push(marker.field.to_owned());
let variant_tag = marker.variant.map(|v| v.to_owned());
let metadata = marker.to_metadata(None, issuer);
Self {
field_path,
variant_tag,
metadata,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct RequiredCredential {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<CredentialKind>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scopes: Vec<Scope>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub site_hint: Option<AttachmentSite>,
}
impl RequiredCredential {
pub fn from_method_scope(scope: Scope) -> Self {
Self {
kind: None,
scopes: vec![scope],
site_hint: None,
}
}
pub fn from_refresh_revoke_target(kind: CredentialKind, scopes: Vec<Scope>) -> Self {
Self {
kind: Some(kind),
scopes,
site_hint: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ReturnShape {
Bare,
Option,
Result,
Vec,
Stream,
ResultOption,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "UPPERCASE")]
pub enum HttpMethod {
Get,
Post,
Put,
Delete,
Patch,
}
impl Default for HttpMethod {
fn default() -> Self {
HttpMethod::Post
}
}
impl HttpMethod {
pub fn from_str(s: &str) -> Option<Self> {
match s.to_uppercase().as_str() {
"GET" => Some(HttpMethod::Get),
"POST" => Some(HttpMethod::Post),
"PUT" => Some(HttpMethod::Put),
"DELETE" => Some(HttpMethod::Delete),
"PATCH" => Some(HttpMethod::Patch),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
HttpMethod::Get => "GET",
HttpMethod::Post => "POST",
HttpMethod::Put => "PUT",
HttpMethod::Delete => "DELETE",
HttpMethod::Patch => "PATCH",
}
}
}
#[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")]
#[deprecated(
since = "0.5",
note = "Derive from MethodRole on MethodSchema. Field will be removed in 0.7."
)]
pub children: Option<Vec<ChildSummary>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deprecation: Option<DeprecationInfo>,
}
#[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(default)]
pub http_method: HttpMethod,
#[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>,
#[serde(default)]
pub role: MethodRole,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deprecation: Option<DeprecationInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub return_shape: Option<ReturnShape>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub params_meta: Vec<ParamSchema>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub credentials: Vec<CredentialFieldDecl>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requires_credential: Option<RequiredCredential>,
}
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 warn_on_credentials_field_collisions(
namespace: &str,
methods: &[MethodSchema],
) -> usize {
let mut count = 0;
for m in methods {
let Some(returns_schema) = &m.returns else { continue };
let Ok(returns_json) = serde_json::to_value(returns_schema) else { continue };
if super::credential_envelope::check_returns_schema_for_credentials_collision(
namespace,
&m.name,
&returns_json,
) {
count += 1;
}
}
count
}
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 colliding_method =
methods.iter().find(|m| m.name == c.namespace);
if let Some(m) = colliding_method {
if matches!(
m.role,
MethodRole::StaticChild | MethodRole::DynamicChild { .. }
) {
continue;
}
}
let collision_type = if colliding_method.is_some() {
"method/child collision"
} else {
"duplicate child"
};
panic!(
"Name collision in plugin '{}': {} for '{}'",
namespace, collision_type, c.namespace
);
}
}
}
}
pub fn derive_legacy_fields(
methods: &[MethodSchema],
) -> (Vec<ChildSummary>, bool) {
let children: Vec<ChildSummary> = methods
.iter()
.filter(|m| {
matches!(
m.role,
MethodRole::StaticChild | MethodRole::DynamicChild { .. }
)
})
.map(|m| ChildSummary {
namespace: m.name.clone(),
description: m.description.clone(),
hash: String::new(),
})
.collect();
let is_hub = !children.is_empty();
(children, is_hub)
}
#[allow(deprecated)]
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::warn_on_credentials_field_collisions(&namespace, &methods);
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,
request: None,
deprecation: None,
}
}
#[allow(deprecated)]
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::warn_on_credentials_field_collisions(&namespace, &methods);
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,
request: None,
deprecation: None,
}
}
#[allow(deprecated)]
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::warn_on_credentials_field_collisions(&namespace, &methods);
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),
request: None,
deprecation: None,
}
}
#[allow(deprecated)]
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::warn_on_credentials_field_collisions(&namespace, &methods);
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),
request: None,
deprecation: None,
}
}
#[deprecated(
since = "0.5",
note = "Use `PluginSchema::is_hub_by_role()` which reads MethodRole from methods. This method will be removed in 0.7."
)]
#[allow(deprecated)]
pub fn is_hub(&self) -> bool {
self.is_hub_by_role() || self.children.is_some()
}
pub fn is_hub_by_role(&self) -> bool {
self.methods.iter().any(|m| {
matches!(
m.role,
MethodRole::StaticChild | MethodRole::DynamicChild { .. }
)
})
}
#[allow(deprecated)]
pub fn is_leaf(&self) -> bool {
self.children.is_none()
}
pub fn with_deprecation(mut self, info: DeprecationInfo) -> Self {
self.deprecation = Some(info);
self
}
}
#[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,
http_method: HttpMethod::default(),
request_type: None,
response_type: None,
role: MethodRole::Rpc,
deprecation: None,
return_shape: None,
params_meta: Vec::new(),
credentials: Vec::new(),
requires_credential: 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_http_method(mut self, http_method: HttpMethod) -> Self {
self.http_method = http_method;
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())
}
pub fn with_role(mut self, role: MethodRole) -> Self {
self.role = role;
self
}
pub fn with_deprecation(mut self, info: DeprecationInfo) -> Self {
self.deprecation = Some(info);
self
}
pub fn with_return_shape(mut self, shape: ReturnShape) -> Self {
self.return_shape = Some(shape);
self
}
pub fn with_params_meta(mut self, entries: Vec<ParamSchema>) -> Self {
self.params_meta = entries;
self
}
pub fn with_credentials(mut self, credentials: Vec<CredentialFieldDecl>) -> Self {
self.credentials = credentials;
self
}
pub fn with_requires_credential(mut self, req: RequiredCredential) -> Self {
self.requires_credential = Some(req);
self
}
}
#[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)]
#[allow(deprecated)]
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");
}
#[test]
fn ir2_default_role_is_rpc_on_deserialize() {
let pre_ir_json = serde_json::json!({
"name": "ping",
"description": "pong",
"hash": "abc"
});
let schema: MethodSchema = serde_json::from_value(pre_ir_json).unwrap();
assert_eq!(schema.role, MethodRole::Rpc);
assert!(schema.deprecation.is_none());
assert!(schema.return_shape.is_none());
}
#[test]
fn ir2_plugin_schema_pre_ir_json_deserializes() {
let pre_ir_json = serde_json::json!({
"namespace": "test",
"version": "1.0",
"description": "legacy schema",
"self_hash": "s1",
"hash": "h1",
"methods": [
{ "name": "a", "description": "alpha", "hash": "ah" },
{ "name": "b", "description": "beta", "hash": "bh" }
]
});
let schema: PluginSchema = serde_json::from_value(pre_ir_json).unwrap();
assert_eq!(schema.methods.len(), 2);
for m in &schema.methods {
assert_eq!(m.role, MethodRole::Rpc);
assert!(m.deprecation.is_none());
}
}
#[test]
fn ir2_method_role_roundtrip_all_variants() {
let original = PluginSchema::leaf(
"rt",
"1.0",
"round-trip coverage",
vec![
MethodSchema::new("plain", "rpc", "h1"),
MethodSchema::new("child_a", "static", "h2")
.with_role(MethodRole::StaticChild),
MethodSchema::new("child_b", "dynamic", "h3").with_role(
MethodRole::DynamicChild {
list_method: Some("list_x".into()),
search_method: Some("search_x".into()),
},
),
],
);
let json = serde_json::to_string(&original).unwrap();
let decoded: PluginSchema = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.methods[0].role, MethodRole::Rpc);
assert_eq!(decoded.methods[1].role, MethodRole::StaticChild);
assert_eq!(
decoded.methods[2].role,
MethodRole::DynamicChild {
list_method: Some("list_x".into()),
search_method: Some("search_x".into()),
}
);
let bare_dyn = MethodSchema::new("child_c", "dynamic-bare", "h4").with_role(
MethodRole::DynamicChild {
list_method: None,
search_method: None,
},
);
let j2 = serde_json::to_string(&bare_dyn).unwrap();
let d2: MethodSchema = serde_json::from_str(&j2).unwrap();
assert_eq!(
d2.role,
MethodRole::DynamicChild {
list_method: None,
search_method: None,
}
);
}
#[test]
fn ir2_deprecation_info_roundtrip() {
let info = DeprecationInfo {
since: "0.5".into(),
removed_in: "0.6".into(),
message: "use MethodRole".into(),
};
let method = MethodSchema::new("old", "legacy method", "hx")
.with_deprecation(info.clone());
let json = serde_json::to_string(&method).unwrap();
let decoded: MethodSchema = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.deprecation, Some(info));
}
#[test]
fn ir2_is_hub_by_role_derived_query() {
let all_rpc = PluginSchema::leaf(
"p",
"1.0",
"all rpc",
vec![
MethodSchema::new("a", "d", "h1"),
MethodSchema::new("b", "d", "h2"),
],
);
assert!(!all_rpc.is_hub_by_role());
assert!(!all_rpc.is_hub());
let static_child = PluginSchema::leaf(
"p",
"1.0",
"has static child",
vec![
MethodSchema::new("a", "d", "h1"),
MethodSchema::new("kid", "d", "h2").with_role(MethodRole::StaticChild),
],
);
assert!(static_child.is_hub_by_role());
assert!(static_child.is_hub());
let dyn_child = PluginSchema::leaf(
"p",
"1.0",
"has dynamic child",
vec![MethodSchema::new("find", "d", "h1").with_role(
MethodRole::DynamicChild {
list_method: None,
search_method: None,
},
)],
);
assert!(dyn_child.is_hub_by_role());
assert!(dyn_child.is_hub());
let mixed = PluginSchema::leaf(
"p",
"1.0",
"mixed",
vec![
MethodSchema::new("a", "d", "h1"),
MethodSchema::new("b", "d", "h2"),
MethodSchema::new("k", "d", "h3").with_role(MethodRole::StaticChild),
],
);
assert!(mixed.is_hub_by_role());
assert!(mixed.is_hub());
let empty = PluginSchema::leaf("p", "1.0", "empty", vec![]);
assert!(!empty.is_hub_by_role());
assert!(!empty.is_hub());
}
#[test]
fn ir2_is_hub_by_role_ignores_children_field() {
let hub_with_rpc_only = PluginSchema::hub(
"h",
"1.0",
"transition",
vec![MethodSchema::new("a", "d", "ah")],
vec![ChildSummary {
namespace: "kid".into(),
description: "child".into(),
hash: "kh".into(),
}],
);
assert!(!hub_with_rpc_only.is_hub_by_role());
assert!(hub_with_rpc_only.is_hub());
}
#[test]
fn ir2_return_shape_roundtrip() {
for shape in [
ReturnShape::Bare,
ReturnShape::Option,
ReturnShape::Result,
ReturnShape::Vec,
ReturnShape::Stream,
ReturnShape::ResultOption,
] {
let m = MethodSchema::new("m", "d", "h").with_return_shape(shape.clone());
let j = serde_json::to_string(&m).unwrap();
let d: MethodSchema = serde_json::from_str(&j).unwrap();
assert_eq!(d.return_shape, Some(shape));
}
}
#[test]
fn ir4_derive_empty_methods() {
let (children, is_hub) = PluginSchema::derive_legacy_fields(&[]);
assert!(children.is_empty());
assert!(!is_hub);
}
#[test]
fn ir4_derive_single_rpc_method() {
let methods = vec![MethodSchema::new("ping", "rpc method", "h1")];
let (children, is_hub) = PluginSchema::derive_legacy_fields(&methods);
assert!(children.is_empty());
assert!(!is_hub);
}
#[test]
fn ir4_derive_single_static_child() {
let methods = vec![
MethodSchema::new("body", "static child", "h1")
.with_role(MethodRole::StaticChild),
];
let (children, is_hub) = PluginSchema::derive_legacy_fields(&methods);
assert_eq!(children.len(), 1);
assert_eq!(children[0].namespace, "body");
assert_eq!(children[0].description, "static child");
assert_eq!(children[0].hash, "");
assert!(is_hub);
}
#[test]
fn ir4_derive_single_dynamic_child() {
let methods = vec![
MethodSchema::new("planet", "dynamic child", "h1").with_role(
MethodRole::DynamicChild {
list_method: Some("list_planets".into()),
search_method: None,
},
),
];
let (children, is_hub) = PluginSchema::derive_legacy_fields(&methods);
assert_eq!(children.len(), 1);
assert_eq!(children[0].namespace, "planet");
assert!(is_hub);
}
#[test]
fn ir4_derive_mixed_roles_preserves_order() {
let methods = vec![
MethodSchema::new("ping", "rpc", "h1"),
MethodSchema::new("kid_a", "static a", "h2")
.with_role(MethodRole::StaticChild),
MethodSchema::new("describe", "rpc too", "h3"),
MethodSchema::new("kid_b", "static b", "h4")
.with_role(MethodRole::StaticChild),
];
let (children, is_hub) = PluginSchema::derive_legacy_fields(&methods);
assert_eq!(children.len(), 2);
assert_eq!(children[0].namespace, "kid_a");
assert_eq!(children[1].namespace, "kid_b");
assert!(is_hub);
}
#[test]
fn ir4_derive_is_hub_matches_is_hub_by_role() {
let empty_schema = PluginSchema::leaf("t", "1.0", "d", vec![]);
let (_, is_hub) = PluginSchema::derive_legacy_fields(&empty_schema.methods);
assert_eq!(is_hub, empty_schema.is_hub_by_role());
let rpc_schema = PluginSchema::leaf(
"t",
"1.0",
"d",
vec![
MethodSchema::new("a", "d", "h1"),
MethodSchema::new("b", "d", "h2"),
],
);
let (_, is_hub) = PluginSchema::derive_legacy_fields(&rpc_schema.methods);
assert_eq!(is_hub, rpc_schema.is_hub_by_role());
let static_schema = PluginSchema::leaf(
"t",
"1.0",
"d",
vec![
MethodSchema::new("a", "d", "h1"),
MethodSchema::new("kid", "d", "h2").with_role(MethodRole::StaticChild),
],
);
let (_, is_hub) = PluginSchema::derive_legacy_fields(&static_schema.methods);
assert_eq!(is_hub, static_schema.is_hub_by_role());
assert!(is_hub);
let dyn_schema = PluginSchema::leaf(
"t",
"1.0",
"d",
vec![MethodSchema::new("find", "d", "h1").with_role(
MethodRole::DynamicChild {
list_method: None,
search_method: None,
},
)],
);
let (_, is_hub) = PluginSchema::derive_legacy_fields(&dyn_schema.methods);
assert_eq!(is_hub, dyn_schema.is_hub_by_role());
assert!(is_hub);
}
#[test]
fn ir4_no_collision_static_child_method_vs_summary() {
let schema = PluginSchema::hub(
"hub",
"1.0",
"has static child",
vec![
MethodSchema::new("ping", "rpc", "h1"),
MethodSchema::new("kid", "static child", "h2")
.with_role(MethodRole::StaticChild),
],
vec![ChildSummary {
namespace: "kid".into(),
description: "static child".into(),
hash: "kh".into(),
}],
);
#[allow(deprecated)]
let kids = schema.children.as_ref().expect("hub has children");
assert_eq!(kids.len(), 1);
assert_eq!(kids[0].namespace, "kid");
assert!(matches!(
schema.methods.iter().find(|m| m.name == "kid").unwrap().role,
MethodRole::StaticChild
));
}
#[test]
fn ir4_no_collision_dynamic_child_method_vs_summary() {
let schema = PluginSchema::hub(
"hub",
"1.0",
"has dynamic child",
vec![MethodSchema::new("body", "gate", "h1").with_role(
MethodRole::DynamicChild {
list_method: Some("body_names".into()),
search_method: None,
},
)],
vec![ChildSummary {
namespace: "body".into(),
description: "gate".into(),
hash: "bh".into(),
}],
);
#[allow(deprecated)]
let kids = schema.children.as_ref().unwrap();
assert_eq!(kids.len(), 1);
}
#[test]
#[should_panic(expected = "method/child collision")]
fn ir4_collision_rpc_method_vs_summary_still_panics() {
let _ = PluginSchema::hub(
"hub",
"1.0",
"bad hub",
vec![MethodSchema::new("oops", "rpc", "h1")],
vec![ChildSummary {
namespace: "oops".into(),
description: "shadowed".into(),
hash: "oh".into(),
}],
);
}
#[test]
fn ir4_deprecated_field_access_requires_allow_attribute() {
let schema = PluginSchema::leaf(
"t",
"1.0",
"d",
vec![MethodSchema::new("a", "b", "h")],
);
let _children = schema.children.clone();
let _is_hub = schema.is_hub();
}
#[test]
fn cred_core_2_ac8_leaf_constructor_does_not_panic_on_collision() {
let returns_collision_schema: schemars::Schema = serde_json::from_value(
serde_json::json!({
"type": "object",
"properties": {
"user_id": { "type": "string" },
"_credentials": { "type": "object" }
}
}),
)
.unwrap();
let schema = PluginSchema::leaf(
"auth",
"1.0",
"test",
vec![MethodSchema::new("login", "logs in", "h1").with_returns(returns_collision_schema)],
);
assert_eq!(schema.namespace, "auth");
assert_eq!(schema.methods.len(), 1);
let no_collision_schema: schemars::Schema = serde_json::from_value(
serde_json::json!({ "type": "object", "properties": { "user_id": { "type": "string" } } }),
)
.unwrap();
let schema_clean = PluginSchema::leaf(
"auth",
"1.0",
"test",
vec![MethodSchema::new("ping", "pings", "h2").with_returns(no_collision_schema)],
);
assert_eq!(schema_clean.methods.len(), 1);
}
#[test]
fn ir4_is_hub_and_is_hub_by_role_agree_on_role_tagged_methods() {
let leaf = PluginSchema::leaf(
"t",
"1.0",
"d",
vec![MethodSchema::new("a", "d", "h1")],
);
assert_eq!(leaf.is_hub(), leaf.is_hub_by_role());
assert!(!leaf.is_hub());
let hub_with_roles = PluginSchema::hub(
"h",
"1.0",
"d",
vec![MethodSchema::new("kid", "d", "h1").with_role(MethodRole::StaticChild)],
vec![ChildSummary {
namespace: "kid".into(),
description: "d".into(),
hash: "".into(),
}],
);
assert_eq!(hub_with_roles.is_hub(), hub_with_roles.is_hub_by_role());
assert!(hub_with_roles.is_hub());
}
use plexus_auth_core::{
AttachmentSite, CredentialFieldMarker, CredentialIssuer, CredentialKind,
CredentialMetadata, CredentialScheme, HeaderName, MethodPath, Origin, Scope,
};
fn sample_issuer() -> CredentialIssuer {
CredentialIssuer::new(
Origin::new("ws://localhost:4444"),
MethodPath::try_new("auth.login").unwrap(),
)
}
fn sample_marker_single() -> CredentialFieldMarker {
CredentialFieldMarker::new(
None,
"session",
CredentialKind::Bearer,
AttachmentSite::Header {
name: HeaderName::try_new("authorization").unwrap(),
},
Some(CredentialScheme::new("Bearer ")),
vec![Scope::new("cone.send_message")],
Some(MethodPath::try_new("auth.refresh").unwrap()),
Some(MethodPath::try_new("auth.logout").unwrap()),
)
}
#[test]
fn cred_core_3_ac1_single_credential_projection() {
let marker = sample_marker_single();
let decl = CredentialFieldDecl::from_marker(&marker, &[], sample_issuer());
assert_eq!(decl.field_path, vec!["session".to_string()]);
assert!(decl.variant_tag.is_none());
assert_eq!(decl.metadata.kind, CredentialKind::Bearer);
assert_eq!(
decl.metadata.attach_as,
AttachmentSite::Header {
name: HeaderName::try_new("authorization").unwrap(),
}
);
assert_eq!(decl.metadata.scheme, Some(CredentialScheme::new("Bearer ")));
assert_eq!(decl.metadata.scopes, vec![Scope::new("cone.send_message")]);
assert_eq!(
decl.metadata.refresh_via,
Some(MethodPath::try_new("auth.refresh").unwrap())
);
assert_eq!(
decl.metadata.revoke_via,
Some(MethodPath::try_new("auth.logout").unwrap())
);
assert_eq!(decl.metadata.issuer, sample_issuer());
let method = MethodSchema::new("login", "logs in", "h_login").with_credentials(vec![decl]);
assert_eq!(method.credentials.len(), 1);
assert_eq!(method.credentials[0].field_path, vec!["session".to_string()]);
}
#[test]
fn cred_core_3_ac2_multiple_credentials_stable_order() {
let m1 = CredentialFieldMarker::new(
None,
"access",
CredentialKind::OauthAccess,
AttachmentSite::Header {
name: HeaderName::try_new("authorization").unwrap(),
},
Some(CredentialScheme::new("Bearer ")),
vec![Scope::new("cone.send")],
Some(MethodPath::try_new("auth.refresh").unwrap()),
None,
);
let m2 = CredentialFieldMarker::new(
None,
"refresh",
CredentialKind::OauthRefresh,
AttachmentSite::Header {
name: HeaderName::try_new("authorization").unwrap(),
},
None,
vec![Scope::new("auth.refresh")],
None,
None,
);
let decls = vec![
CredentialFieldDecl::from_marker(&m1, &[], sample_issuer()),
CredentialFieldDecl::from_marker(&m2, &[], sample_issuer()),
];
let method = MethodSchema::new("login", "logs in", "h_oauth").with_credentials(decls);
assert_eq!(method.credentials.len(), 2);
assert_eq!(method.credentials[0].field_path, vec!["access".to_string()]);
assert_eq!(method.credentials[0].metadata.kind, CredentialKind::OauthAccess);
assert_eq!(method.credentials[1].field_path, vec!["refresh".to_string()]);
assert_eq!(method.credentials[1].metadata.kind, CredentialKind::OauthRefresh);
}
#[test]
fn cred_core_3_ac3_enum_variant_tag_set() {
let marker = CredentialFieldMarker::new(
Some("Issued"),
"session",
CredentialKind::Bearer,
AttachmentSite::Header {
name: HeaderName::try_new("authorization").unwrap(),
},
Some(CredentialScheme::new("Bearer ")),
vec![Scope::new("cone.send_message")],
None,
None,
);
let decl = CredentialFieldDecl::from_marker(&marker, &[], sample_issuer());
assert_eq!(decl.variant_tag, Some("Issued".to_string()));
assert_eq!(decl.field_path, vec!["session".to_string()]);
let method = MethodSchema::new("login", "logs in", "h_login").with_credentials(vec![decl]);
assert_eq!(method.credentials[0].variant_tag, Some("Issued".to_string()));
}
#[test]
fn cred_core_3_from_marker_with_nested_path_prefix() {
let marker = sample_marker_single();
let decl = CredentialFieldDecl::from_marker(&marker, &["auth"], sample_issuer());
assert_eq!(decl.field_path, vec!["auth".to_string(), "session".to_string()]);
}
#[test]
fn cred_core_3_ac4_public_method_requires_credential_none() {
let method = MethodSchema::new("auth.login", "logs in", "h_login");
assert!(method.requires_credential.is_none());
let json = serde_json::to_value(&method).unwrap();
assert!(
!json
.as_object()
.unwrap()
.contains_key("requires_credential"),
"requires_credential must be omitted from wire JSON when None, got {json}"
);
let decoded: MethodSchema = serde_json::from_value(json).unwrap();
assert!(decoded.requires_credential.is_none());
}
#[test]
fn cred_core_3_ac5_scoped_method_implicit_requires_credential() {
let req = RequiredCredential::from_method_scope(Scope::new("cone.send_message"));
assert!(req.kind.is_none());
assert_eq!(req.scopes, vec![Scope::new("cone.send_message")]);
assert!(req.site_hint.is_none());
let method = MethodSchema::new("send", "sends a message", "h_send")
.with_requires_credential(req.clone());
assert_eq!(method.requires_credential.as_ref().unwrap(), &req);
}
#[test]
fn cred_core_3_ac6_refresh_target_narrows_kind() {
let req = RequiredCredential::from_refresh_revoke_target(
CredentialKind::OauthRefresh,
vec![Scope::new("auth.refresh")],
);
assert_eq!(req.kind, Some(CredentialKind::OauthRefresh));
assert_eq!(req.scopes, vec![Scope::new("auth.refresh")]);
let refresh_method =
MethodSchema::new("refresh", "refreshes a token", "h_refresh")
.with_requires_credential(req.clone());
assert_eq!(
refresh_method.requires_credential.as_ref().unwrap().kind,
Some(CredentialKind::OauthRefresh)
);
}
#[test]
fn cred_core_3_required_credential_site_hint_preserved() {
let mut req = RequiredCredential::from_refresh_revoke_target(
CredentialKind::OauthRefresh,
vec![Scope::new("auth.refresh")],
);
let hint = AttachmentSite::Header {
name: HeaderName::try_new("authorization").unwrap(),
};
req.site_hint = Some(hint.clone());
let method = MethodSchema::new("refresh", "refreshes", "h_refresh")
.with_requires_credential(req);
let json = serde_json::to_string(&method).unwrap();
let decoded: MethodSchema = serde_json::from_str(&json).unwrap();
assert_eq!(
decoded.requires_credential.as_ref().unwrap().site_hint,
Some(hint)
);
}
#[test]
fn cred_core_3_ac7_pre_ir_json_deserializes_with_empty_defaults() {
let pre_ir_json = serde_json::json!({
"name": "ping",
"description": "pong",
"hash": "abc"
});
let schema: MethodSchema = serde_json::from_value(pre_ir_json).unwrap();
assert!(schema.credentials.is_empty());
assert!(schema.requires_credential.is_none());
}
#[test]
fn cred_core_3_ac7_pre_ir_plugin_schema_deserializes() {
let pre_ir_json = serde_json::json!({
"namespace": "legacy",
"version": "1.0",
"description": "no credentials",
"self_hash": "s1",
"hash": "h1",
"methods": [
{ "name": "a", "description": "alpha", "hash": "ah" },
{ "name": "b", "description": "beta", "hash": "bh" }
]
});
let plugin: PluginSchema = serde_json::from_value(pre_ir_json).unwrap();
for m in &plugin.methods {
assert!(m.credentials.is_empty());
assert!(m.requires_credential.is_none());
}
}
#[test]
fn cred_core_3_ac8_info_advertisement_carries_populated_fields() {
let marker = sample_marker_single();
let decl = CredentialFieldDecl::from_marker(&marker, &[], sample_issuer());
let req = RequiredCredential::from_method_scope(Scope::new("cone.send_message"));
let method = MethodSchema::new("login", "logs in", "h_login")
.with_credentials(vec![decl])
.with_requires_credential(req);
let json = serde_json::to_value(&method).unwrap();
let obj = json.as_object().unwrap();
assert!(
obj.contains_key("credentials"),
"populated credentials must appear in wire JSON"
);
assert!(
obj.contains_key("requires_credential"),
"populated requires_credential must appear in wire JSON"
);
let decoded: MethodSchema = serde_json::from_value(json).unwrap();
assert_eq!(decoded.credentials.len(), 1);
assert_eq!(decoded.credentials[0].field_path, vec!["session".to_string()]);
assert_eq!(
decoded.requires_credential.as_ref().unwrap().scopes,
vec![Scope::new("cone.send_message")]
);
}
#[test]
fn cred_core_3_ac11_methods_without_credentials_have_unchanged_wire_shape() {
let method = MethodSchema::new("ping", "pong", "h_ping");
let json = serde_json::to_value(&method).unwrap();
let obj = json.as_object().unwrap();
assert!(!obj.contains_key("credentials"));
assert!(!obj.contains_key("requires_credential"));
let decoded: MethodSchema = serde_json::from_value(json).unwrap();
assert!(decoded.credentials.is_empty());
assert!(decoded.requires_credential.is_none());
}
#[test]
fn cred_core_3_full_method_roundtrip_preserves_credentials_and_requires() {
let marker = sample_marker_single();
let decl = CredentialFieldDecl::from_marker(&marker, &[], sample_issuer());
let req = RequiredCredential::from_method_scope(Scope::new("cone.send_message"));
let method = MethodSchema::new("login", "logs in", "h_login")
.with_credentials(vec![decl.clone()])
.with_requires_credential(req.clone());
let json = serde_json::to_string(&method).unwrap();
let decoded: MethodSchema = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.credentials.len(), 1);
assert_eq!(decoded.credentials[0], decl);
assert_eq!(decoded.requires_credential.as_ref().unwrap(), &req);
}
#[test]
fn cred_core_3_credential_field_decl_roundtrip() {
let marker = sample_marker_single();
let original = CredentialFieldDecl::from_marker(&marker, &["envelope"], sample_issuer());
let json = serde_json::to_string(&original).unwrap();
let parsed: CredentialFieldDecl = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, original);
}
#[test]
fn cred_core_3_required_credential_roundtrip() {
let req = RequiredCredential {
kind: Some(CredentialKind::OauthRefresh),
scopes: vec![Scope::new("auth.refresh")],
site_hint: Some(AttachmentSite::Header {
name: HeaderName::try_new("authorization").unwrap(),
}),
};
let json = serde_json::to_string(&req).unwrap();
let parsed: RequiredCredential = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, req);
}
#[test]
fn cred_core_3_required_credential_compact_wire_shape() {
let req = RequiredCredential::from_method_scope(Scope::new("cone.send"));
let json = serde_json::to_value(&req).unwrap();
let obj = json.as_object().unwrap();
assert!(!obj.contains_key("kind"));
assert!(obj.contains_key("scopes"));
assert!(!obj.contains_key("site_hint"));
}
}