use std::collections::BTreeMap;
use futures::stream::{self, StreamExt, TryStreamExt};
use serde_json::Value;
use super::client::{DiscoveredAction, OpenConnectorClient};
use super::error::OpenConnectorError;
use crate::util::json::blake3_hex;
use crate::util::json::canonical_json;
const DISCOVERY_CONCURRENCY: usize = 8;
#[derive(Debug, Clone)]
pub struct ActionMetadata {
action_id: String,
input_schema: Option<Value>,
output_schema: Option<Value>,
read_only: Option<bool>,
fingerprint: String,
}
impl ActionMetadata {
fn from_discovered(action_id: &str, discovered: DiscoveredAction) -> Self {
let fingerprint = fingerprint_schema(discovered.output_schema.as_ref());
Self {
action_id: action_id.to_string(),
input_schema: discovered.input_schema,
output_schema: discovered.output_schema,
read_only: discovered.read_only,
fingerprint,
}
}
pub fn action_id(&self) -> &str {
&self.action_id
}
pub fn input_schema(&self) -> Option<&Value> {
self.input_schema.as_ref()
}
pub fn output_schema(&self) -> Option<&Value> {
self.output_schema.as_ref()
}
pub fn read_only(&self) -> Option<bool> {
self.read_only
}
pub fn fingerprint(&self) -> &str {
&self.fingerprint
}
}
#[derive(Debug, Default)]
pub struct ActionRegistry {
actions: BTreeMap<String, ActionMetadata>,
}
impl ActionRegistry {
pub async fn load(
client: &OpenConnectorClient,
action_ids: &[String],
) -> Result<Self, OpenConnectorError> {
let mut ids: Vec<&str> = action_ids.iter().map(String::as_str).collect();
ids.sort_unstable();
ids.dedup();
let discovered = stream::iter(ids.into_iter().map(|action_id| async move {
let action = client.discover_action(action_id).await?;
match action.locally_executable {
Some(true) => {}
Some(false) => {
return Err(OpenConnectorError::ActionNotLocallyExecutable {
action_id: action_id.to_string(),
});
}
None => {
return Err(OpenConnectorError::ActionExecutabilityUnknown {
action_id: action_id.to_string(),
});
}
}
Ok(ActionMetadata::from_discovered(action_id, action))
}))
.buffer_unordered(DISCOVERY_CONCURRENCY)
.try_collect::<Vec<_>>()
.await?;
let actions = discovered
.into_iter()
.map(|meta| (meta.action_id.clone(), meta))
.collect();
Ok(Self { actions })
}
pub fn get(&self, action_id: &str) -> Option<&ActionMetadata> {
self.actions.get(action_id)
}
pub fn len(&self) -> usize {
self.actions.len()
}
pub fn is_empty(&self) -> bool {
self.actions.is_empty()
}
}
pub(crate) fn fingerprint_schema(output_schema: Option<&Value>) -> String {
let canonical = match output_schema {
Some(schema) => canonical_json(schema),
None => "null".to_string(),
};
blake3_hex(canonical.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sources::providers::open_connector::client::OpenConnectorClient;
use crate::sources::providers::open_connector::testutil::{
MockGateway, MockResponse, discovery_ok, envelope_ok,
};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
fn action_response(output_schema: &str) -> String {
discovery_ok("{}", output_schema, true, None)
}
fn client(gateway: &MockGateway) -> OpenConnectorClient {
OpenConnectorClient::new(&gateway.url, "test-token", Duration::from_secs(2))
.expect("build client")
}
#[tokio::test]
async fn load_discovers_dedupes_and_registers_all() {
let hits = Arc::new(AtomicUsize::new(0));
let hits2 = Arc::clone(&hits);
let gateway = MockGateway::start(move |_req| {
hits2.fetch_add(1, Ordering::SeqCst);
let schema = r#"{"type": "object"}"#;
MockResponse::ok(&action_response(schema))
})
.await;
let ids = vec![
"github.b".to_string(),
"github.a".to_string(),
"github.a".to_string(), ];
let registry = ActionRegistry::load(&client(&gateway), &ids)
.await
.expect("load");
assert_eq!(registry.len(), 2);
assert!(!registry.is_empty());
assert!(registry.get("github.a").is_some());
assert!(registry.get("github.b").is_some());
assert_eq!(
hits.load(Ordering::SeqCst),
2,
"duplicates are deduplicated"
);
}
#[tokio::test]
async fn load_empty_allowlist_yields_empty_registry() {
let gateway = MockGateway::start(|_| MockResponse::new(500, "{}")).await;
let registry = ActionRegistry::load(&client(&gateway), &[])
.await
.expect("load empty");
assert!(registry.is_empty());
assert_eq!(registry.len(), 0);
assert!(gateway.requests().is_empty(), "no discovery calls at all");
}
#[tokio::test]
async fn load_rejects_non_executable_action() {
let gateway =
MockGateway::start(|_| MockResponse::ok(&discovery_ok("{}", "{}", false, None))).await;
let err = ActionRegistry::load(&client(&gateway), &["github.x".to_string()])
.await
.unwrap_err();
assert!(matches!(
err,
OpenConnectorError::ActionNotLocallyExecutable { ref action_id }
if action_id == "github.x"
));
}
#[tokio::test]
async fn load_rejects_missing_executability_flag() {
let gateway = MockGateway::start(|_| {
MockResponse::ok(&envelope_ok(r#"{"inputSchema": {}, "outputSchema": {}}"#))
})
.await;
let err = ActionRegistry::load(&client(&gateway), &["github.x".to_string()])
.await
.unwrap_err();
assert!(matches!(
err,
OpenConnectorError::ActionExecutabilityUnknown { ref action_id }
if action_id == "github.x"
));
}
#[tokio::test]
async fn load_propagates_discovery_errors() {
let gateway = MockGateway::start(|_| MockResponse::new(404, "{}")).await;
let err = ActionRegistry::load(&client(&gateway), &["github.missing".to_string()])
.await
.unwrap_err();
assert!(matches!(err, OpenConnectorError::ActionNotFound { .. }));
}
#[tokio::test]
async fn metadata_exposes_discovered_fields() {
let gateway =
MockGateway::start(|_| MockResponse::ok(&action_response(r#"{"type": "array"}"#)))
.await;
let registry = ActionRegistry::load(&client(&gateway), &["github.x".to_string()])
.await
.expect("load");
let meta = registry.get("github.x").expect("present");
assert_eq!(meta.action_id(), "github.x");
assert_eq!(
meta.output_schema(),
Some(&serde_json::json!({"type": "array"}))
);
assert_eq!(meta.fingerprint().len(), 64, "BLAKE3 hash as hex");
assert_eq!(
meta.read_only(),
None,
"an absent read_only flag must stay absent (default-deny input)"
);
}
#[tokio::test]
async fn metadata_carries_explicit_read_only_classification() {
let gateway =
MockGateway::start(|_| MockResponse::ok(&discovery_ok("{}", "{}", true, Some(true))))
.await;
let registry = ActionRegistry::load(&client(&gateway), &["github.x".to_string()])
.await
.expect("load");
assert_eq!(registry.get("github.x").unwrap().read_only(), Some(true));
}
#[test]
fn fingerprint_is_stable_across_key_order() {
let a = serde_json::json!({
"type": "object",
"properties": {
"id": {"type": "integer"},
"title": {"type": "string"}
}
});
let b = serde_json::json!({
"properties": {
"title": {"type": "string"},
"id": {"type": "integer"}
},
"type": "object"
});
assert_eq!(fingerprint_schema(Some(&a)), fingerprint_schema(Some(&b)));
}
#[test]
fn fingerprint_changes_with_schema() {
let a = serde_json::json!({"type": "object"});
let b = serde_json::json!({"type": "array"});
assert_ne!(fingerprint_schema(Some(&a)), fingerprint_schema(Some(&b)));
}
#[test]
fn fingerprint_distinguishes_missing_schema() {
let a = serde_json::json!({"type": "object"});
assert_ne!(fingerprint_schema(None), fingerprint_schema(Some(&a)));
assert_eq!(fingerprint_schema(None), fingerprint_schema(None));
}
}