use crate::error::ConfigError;
use crate::graph::model::GraphConfig;
use crate::tenant::TenantContext;
pub struct HttpGraphProvider {
base_url: String,
token: String,
client: reqwest::Client,
}
impl HttpGraphProvider {
pub fn new(base_url: impl Into<String>, token: impl Into<String>) -> Self {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_default();
Self {
base_url: base_url.into().trim_end_matches('/').to_string(),
token: token.into(),
client,
}
}
pub async fn graph_config(
&self,
_tenant: &TenantContext,
graph_id: &str,
) -> Result<GraphConfig, ConfigError> {
let url = format!("{}/api/v1/designer/agent-graphs/{graph_id}", self.base_url);
let resp = self
.client
.get(&url)
.bearer_auth(&self.token)
.send()
.await
.map_err(|e| ConfigError::Internal(format!("graph registry request failed: {e}")))?;
match resp.status().as_u16() {
200 => {
let body = resp
.text()
.await
.map_err(|e| ConfigError::Misconfigured(format!("graph config read: {e}")))?;
GraphConfig::from_json(&body)
.map_err(|e| ConfigError::Misconfigured(format!("graph config decode: {e}")))
}
404 => Err(ConfigError::AgentNotFound(graph_id.to_string())),
401 | 403 => Err(ConfigError::Misconfigured(format!(
"graph registry auth rejected (status {})",
resp.status().as_u16()
))),
other => Err(ConfigError::Internal(format!(
"graph registry returned status {other}"
))),
}
}
}
pub struct CachingGraphProvider<P> {
inner: P,
ttl: std::time::Duration,
cache:
tokio::sync::RwLock<std::collections::HashMap<CacheKey, (std::time::Instant, GraphConfig)>>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct CacheKey {
tenant_id: String,
env_id: String,
graph_id: String,
}
impl<P: Send + Sync> CachingGraphProvider<P> {
pub fn new(inner: P) -> Self {
Self::with_ttl(inner, std::time::Duration::from_secs(60))
}
pub fn with_ttl(inner: P, ttl: std::time::Duration) -> Self {
Self {
inner,
ttl,
cache: tokio::sync::RwLock::new(std::collections::HashMap::new()),
}
}
}
impl CachingGraphProvider<HttpGraphProvider> {
pub async fn graph_config(
&self,
tenant: &TenantContext,
graph_id: &str,
) -> Result<GraphConfig, ConfigError> {
let key = CacheKey {
tenant_id: tenant.tenant_id.clone(),
env_id: tenant.env_id.clone(),
graph_id: graph_id.to_string(),
};
{
let cache = self.cache.read().await;
if let Some((stored_at, cfg)) = cache.get(&key)
&& stored_at.elapsed() < self.ttl
{
return Ok(cfg.clone());
}
}
let fresh = self.inner.graph_config(tenant, graph_id).await?;
let mut cache = self.cache.write().await;
cache.insert(key, (std::time::Instant::now(), fresh.clone()));
Ok(fresh)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn valid_graph_json() -> serde_json::Value {
serde_json::json!({
"schemaVersion": 1,
"entry": "agent",
"nodes": [
{"id": "agent", "kind": "agent", "systemPrompt": "You triage.", "model": "gpt-4o-mini", "tools": []},
{"id": "lookup", "kind": "tool", "toolName": "kb/search"},
{"id": "router", "kind": "router", "maxIterations": 3},
{"id": "respond", "kind": "respond"}
],
"edges": [
{"from": "agent", "to": "lookup"},
{"from": "lookup", "to": "router"},
{"from": "router", "to": "agent", "branch": "loop"},
{"from": "router", "to": "respond", "branch": "resolved"}
]
})
}
fn tenant() -> TenantContext {
TenantContext::new("t", "e")
}
#[tokio::test]
async fn fetches_and_parses_graph_config() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/designer/agent-graphs/triage.graph"))
.and(header("authorization", "Bearer gtc_live_x"))
.respond_with(ResponseTemplate::new(200).set_body_json(valid_graph_json()))
.mount(&server)
.await;
let provider = HttpGraphProvider::new(server.uri(), "gtc_live_x");
let cfg = provider
.graph_config(&tenant(), "triage.graph")
.await
.unwrap();
assert_eq!(cfg.schema_version, 1);
assert_eq!(cfg.graph.entry, "agent");
assert_eq!(cfg.graph.nodes.len(), 4);
}
#[tokio::test]
async fn namespaced_graph_id_with_dot_is_fetched_at_correct_url() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/designer/agent-graphs/my-worker.graph"))
.respond_with(ResponseTemplate::new(200).set_body_json(valid_graph_json()))
.mount(&server)
.await;
let provider = HttpGraphProvider::new(server.uri(), "tok");
let result = provider.graph_config(&tenant(), "my-worker.graph").await;
assert!(result.is_ok(), "dotted graph_id must resolve: {:?}", result);
}
#[tokio::test]
async fn maps_404_to_agent_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let provider = HttpGraphProvider::new(server.uri(), "gtc_live_x");
let result = provider.graph_config(&tenant(), "ghost.graph").await;
assert!(
matches!(result, Err(ConfigError::AgentNotFound(_))),
"404 must map to AgentNotFound: {result:?}"
);
}
#[tokio::test]
async fn maps_401_to_misconfigured() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let provider = HttpGraphProvider::new(server.uri(), "gtc_live_bad");
let result = provider.graph_config(&tenant(), "triage.graph").await;
assert!(
matches!(result, Err(ConfigError::Misconfigured(_))),
"401 must map to Misconfigured: {result:?}"
);
}
#[tokio::test]
async fn maps_403_to_misconfigured() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(403))
.mount(&server)
.await;
let provider = HttpGraphProvider::new(server.uri(), "gtc_live_bad");
let result = provider.graph_config(&tenant(), "triage.graph").await;
assert!(
matches!(result, Err(ConfigError::Misconfigured(_))),
"403 must map to Misconfigured: {result:?}"
);
}
#[tokio::test]
async fn maps_5xx_to_internal() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let provider = HttpGraphProvider::new(server.uri(), "gtc_live_x");
let result = provider.graph_config(&tenant(), "triage.graph").await;
assert!(
matches!(result, Err(ConfigError::Internal(_))),
"5xx must map to Internal: {result:?}"
);
}
#[tokio::test]
async fn maps_malformed_json_to_misconfigured() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_string("{not json"))
.mount(&server)
.await;
let provider = HttpGraphProvider::new(server.uri(), "gtc_live_x");
let result = provider.graph_config(&tenant(), "triage.graph").await;
assert!(
matches!(result, Err(ConfigError::Misconfigured(_))),
"invalid JSON body must map to Misconfigured: {result:?}"
);
}
#[tokio::test]
async fn maps_unsupported_schema_version_to_misconfigured() {
let server = MockServer::start().await;
let bad_doc = serde_json::json!({
"schemaVersion": 99,
"entry": "agent",
"nodes": [
{"id": "agent", "kind": "agent", "systemPrompt": "x", "model": "gpt-4o-mini", "tools": []},
{"id": "respond", "kind": "respond"}
],
"edges": [{"from": "agent", "to": "respond"}]
});
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_json(bad_doc))
.mount(&server)
.await;
let provider = HttpGraphProvider::new(server.uri(), "gtc_live_x");
let result = provider.graph_config(&tenant(), "triage.graph").await;
assert!(
matches!(result, Err(ConfigError::Misconfigured(_))),
"unsupported schemaVersion must map to Misconfigured: {result:?}"
);
}
#[tokio::test]
async fn caching_provider_hits_inner_once_within_ttl() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_json(valid_graph_json()))
.mount(&server)
.await;
let provider = CachingGraphProvider::new(HttpGraphProvider::new(server.uri(), "tok"));
let tc = tenant();
let _ = provider.graph_config(&tc, "g1").await.unwrap();
let _ = provider.graph_config(&tc, "g1").await.unwrap();
let _ = provider.graph_config(&tc, "g1").await.unwrap();
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
#[tokio::test]
async fn caching_provider_expires_after_ttl() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_json(valid_graph_json()))
.mount(&server)
.await;
let provider = CachingGraphProvider::with_ttl(
HttpGraphProvider::new(server.uri(), "tok"),
std::time::Duration::from_millis(50),
);
let tc = tenant();
let _ = provider.graph_config(&tc, "g1").await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(80)).await;
let _ = provider.graph_config(&tc, "g1").await.unwrap();
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}
#[tokio::test]
async fn caching_provider_does_not_cache_errors() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let provider = CachingGraphProvider::new(HttpGraphProvider::new(server.uri(), "tok"));
let tc = tenant();
let _ = provider.graph_config(&tc, "g1").await.unwrap_err();
let _ = provider.graph_config(&tc, "g1").await.unwrap_err();
assert_eq!(
server.received_requests().await.unwrap().len(),
2,
"errors must not be cached; every error call hits the inner provider"
);
}
#[tokio::test]
async fn caching_provider_isolates_tenants() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_json(valid_graph_json()))
.mount(&server)
.await;
let provider = CachingGraphProvider::new(HttpGraphProvider::new(server.uri(), "tok"));
let tc_a = TenantContext::new("tenant-a", "prod");
let tc_b = TenantContext::new("tenant-b", "prod");
let _ = provider.graph_config(&tc_a, "g1").await.unwrap();
let _ = provider.graph_config(&tc_b, "g1").await.unwrap();
assert_eq!(
server.received_requests().await.unwrap().len(),
2,
"separate tenants must not share a cache entry for the same graph_id"
);
}
}