greentic_aw_runtime/graph/
http_provider.rs1use crate::error::ConfigError;
28use crate::graph::model::GraphConfig;
29use crate::tenant::TenantContext;
30
31pub struct HttpGraphProvider {
40 base_url: String,
41 token: String,
42 client: reqwest::Client,
43}
44
45impl HttpGraphProvider {
46 pub fn new(base_url: impl Into<String>, token: impl Into<String>) -> Self {
52 let client = reqwest::Client::builder()
53 .timeout(std::time::Duration::from_secs(10))
54 .build()
55 .unwrap_or_default();
56 Self {
57 base_url: base_url.into().trim_end_matches('/').to_string(),
58 token: token.into(),
59 client,
60 }
61 }
62
63 pub async fn graph_config(
70 &self,
71 _tenant: &TenantContext,
72 graph_id: &str,
73 ) -> Result<GraphConfig, ConfigError> {
74 let url = format!("{}/api/v1/designer/agent-graphs/{graph_id}", self.base_url);
75 let resp = self
76 .client
77 .get(&url)
78 .bearer_auth(&self.token)
79 .send()
80 .await
81 .map_err(|e| ConfigError::Internal(format!("graph registry request failed: {e}")))?;
82
83 match resp.status().as_u16() {
84 200 => {
85 let body = resp
90 .text()
91 .await
92 .map_err(|e| ConfigError::Misconfigured(format!("graph config read: {e}")))?;
93 GraphConfig::from_json(&body)
94 .map_err(|e| ConfigError::Misconfigured(format!("graph config decode: {e}")))
95 }
96 404 => Err(ConfigError::AgentNotFound(graph_id.to_string())),
97 401 | 403 => Err(ConfigError::Misconfigured(format!(
102 "graph registry auth rejected (status {})",
103 resp.status().as_u16()
104 ))),
105 other => Err(ConfigError::Internal(format!(
106 "graph registry returned status {other}"
107 ))),
108 }
109 }
110}
111
112pub struct CachingGraphProvider<P> {
127 inner: P,
128 ttl: std::time::Duration,
129 cache:
130 tokio::sync::RwLock<std::collections::HashMap<CacheKey, (std::time::Instant, GraphConfig)>>,
131}
132
133#[derive(Clone, Debug, PartialEq, Eq, Hash)]
134struct CacheKey {
135 tenant_id: String,
136 env_id: String,
137 graph_id: String,
138}
139
140impl<P: Send + Sync> CachingGraphProvider<P> {
141 pub fn new(inner: P) -> Self {
143 Self::with_ttl(inner, std::time::Duration::from_secs(60))
144 }
145
146 pub fn with_ttl(inner: P, ttl: std::time::Duration) -> Self {
148 Self {
149 inner,
150 ttl,
151 cache: tokio::sync::RwLock::new(std::collections::HashMap::new()),
152 }
153 }
154}
155
156impl CachingGraphProvider<HttpGraphProvider> {
157 pub async fn graph_config(
163 &self,
164 tenant: &TenantContext,
165 graph_id: &str,
166 ) -> Result<GraphConfig, ConfigError> {
167 let key = CacheKey {
168 tenant_id: tenant.tenant_id.clone(),
169 env_id: tenant.env_id.clone(),
170 graph_id: graph_id.to_string(),
171 };
172 {
173 let cache = self.cache.read().await;
174 if let Some((stored_at, cfg)) = cache.get(&key)
175 && stored_at.elapsed() < self.ttl
176 {
177 return Ok(cfg.clone());
178 }
179 }
180 let fresh = self.inner.graph_config(tenant, graph_id).await?;
181 let mut cache = self.cache.write().await;
182 cache.insert(key, (std::time::Instant::now(), fresh.clone()));
183 Ok(fresh)
184 }
185}
186
187#[cfg(test)]
192#[allow(clippy::unwrap_used, clippy::expect_used)]
193mod tests {
194 use super::*;
195 use wiremock::matchers::{header, method, path};
196 use wiremock::{Mock, MockServer, ResponseTemplate};
197
198 fn valid_graph_json() -> serde_json::Value {
200 serde_json::json!({
201 "schemaVersion": 1,
202 "entry": "agent",
203 "nodes": [
204 {"id": "agent", "kind": "agent", "systemPrompt": "You triage.", "model": "gpt-4o-mini", "tools": []},
205 {"id": "lookup", "kind": "tool", "toolName": "kb/search"},
206 {"id": "router", "kind": "router", "maxIterations": 3},
207 {"id": "respond", "kind": "respond"}
208 ],
209 "edges": [
210 {"from": "agent", "to": "lookup"},
211 {"from": "lookup", "to": "router"},
212 {"from": "router", "to": "agent", "branch": "loop"},
213 {"from": "router", "to": "respond", "branch": "resolved"}
214 ]
215 })
216 }
217
218 fn tenant() -> TenantContext {
219 TenantContext::new("t", "e")
220 }
221
222 #[tokio::test]
227 async fn fetches_and_parses_graph_config() {
228 let server = MockServer::start().await;
229 Mock::given(method("GET"))
230 .and(path("/api/v1/designer/agent-graphs/triage.graph"))
231 .and(header("authorization", "Bearer gtc_live_x"))
232 .respond_with(ResponseTemplate::new(200).set_body_json(valid_graph_json()))
233 .mount(&server)
234 .await;
235
236 let provider = HttpGraphProvider::new(server.uri(), "gtc_live_x");
237 let cfg = provider
238 .graph_config(&tenant(), "triage.graph")
239 .await
240 .unwrap();
241 assert_eq!(cfg.schema_version, 1);
242 assert_eq!(cfg.graph.entry, "agent");
243 assert_eq!(cfg.graph.nodes.len(), 4);
244 }
245
246 #[tokio::test]
247 async fn namespaced_graph_id_with_dot_is_fetched_at_correct_url() {
248 let server = MockServer::start().await;
252 Mock::given(method("GET"))
253 .and(path("/api/v1/designer/agent-graphs/my-worker.graph"))
254 .respond_with(ResponseTemplate::new(200).set_body_json(valid_graph_json()))
255 .mount(&server)
256 .await;
257
258 let provider = HttpGraphProvider::new(server.uri(), "tok");
259 let result = provider.graph_config(&tenant(), "my-worker.graph").await;
260 assert!(result.is_ok(), "dotted graph_id must resolve: {:?}", result);
261 }
262
263 #[tokio::test]
264 async fn maps_404_to_agent_not_found() {
265 let server = MockServer::start().await;
266 Mock::given(method("GET"))
267 .respond_with(ResponseTemplate::new(404))
268 .mount(&server)
269 .await;
270
271 let provider = HttpGraphProvider::new(server.uri(), "gtc_live_x");
272 let result = provider.graph_config(&tenant(), "ghost.graph").await;
273 assert!(
274 matches!(result, Err(ConfigError::AgentNotFound(_))),
275 "404 must map to AgentNotFound: {result:?}"
276 );
277 }
278
279 #[tokio::test]
280 async fn maps_401_to_misconfigured() {
281 let server = MockServer::start().await;
282 Mock::given(method("GET"))
283 .respond_with(ResponseTemplate::new(401))
284 .mount(&server)
285 .await;
286
287 let provider = HttpGraphProvider::new(server.uri(), "gtc_live_bad");
288 let result = provider.graph_config(&tenant(), "triage.graph").await;
289 assert!(
290 matches!(result, Err(ConfigError::Misconfigured(_))),
291 "401 must map to Misconfigured: {result:?}"
292 );
293 }
294
295 #[tokio::test]
296 async fn maps_403_to_misconfigured() {
297 let server = MockServer::start().await;
298 Mock::given(method("GET"))
299 .respond_with(ResponseTemplate::new(403))
300 .mount(&server)
301 .await;
302
303 let provider = HttpGraphProvider::new(server.uri(), "gtc_live_bad");
304 let result = provider.graph_config(&tenant(), "triage.graph").await;
305 assert!(
306 matches!(result, Err(ConfigError::Misconfigured(_))),
307 "403 must map to Misconfigured: {result:?}"
308 );
309 }
310
311 #[tokio::test]
312 async fn maps_5xx_to_internal() {
313 let server = MockServer::start().await;
314 Mock::given(method("GET"))
315 .respond_with(ResponseTemplate::new(503))
316 .mount(&server)
317 .await;
318
319 let provider = HttpGraphProvider::new(server.uri(), "gtc_live_x");
320 let result = provider.graph_config(&tenant(), "triage.graph").await;
321 assert!(
322 matches!(result, Err(ConfigError::Internal(_))),
323 "5xx must map to Internal: {result:?}"
324 );
325 }
326
327 #[tokio::test]
328 async fn maps_malformed_json_to_misconfigured() {
329 let server = MockServer::start().await;
330 Mock::given(method("GET"))
331 .respond_with(ResponseTemplate::new(200).set_body_string("{not json"))
332 .mount(&server)
333 .await;
334
335 let provider = HttpGraphProvider::new(server.uri(), "gtc_live_x");
336 let result = provider.graph_config(&tenant(), "triage.graph").await;
337 assert!(
338 matches!(result, Err(ConfigError::Misconfigured(_))),
339 "invalid JSON body must map to Misconfigured: {result:?}"
340 );
341 }
342
343 #[tokio::test]
344 async fn maps_unsupported_schema_version_to_misconfigured() {
345 let server = MockServer::start().await;
346 let bad_doc = serde_json::json!({
347 "schemaVersion": 99,
348 "entry": "agent",
349 "nodes": [
350 {"id": "agent", "kind": "agent", "systemPrompt": "x", "model": "gpt-4o-mini", "tools": []},
351 {"id": "respond", "kind": "respond"}
352 ],
353 "edges": [{"from": "agent", "to": "respond"}]
354 });
355 Mock::given(method("GET"))
356 .respond_with(ResponseTemplate::new(200).set_body_json(bad_doc))
357 .mount(&server)
358 .await;
359
360 let provider = HttpGraphProvider::new(server.uri(), "gtc_live_x");
361 let result = provider.graph_config(&tenant(), "triage.graph").await;
362 assert!(
363 matches!(result, Err(ConfigError::Misconfigured(_))),
364 "unsupported schemaVersion must map to Misconfigured: {result:?}"
365 );
366 }
367
368 #[tokio::test]
373 async fn caching_provider_hits_inner_once_within_ttl() {
374 let server = MockServer::start().await;
375 Mock::given(method("GET"))
376 .respond_with(ResponseTemplate::new(200).set_body_json(valid_graph_json()))
377 .mount(&server)
378 .await;
379
380 let provider = CachingGraphProvider::new(HttpGraphProvider::new(server.uri(), "tok"));
381 let tc = tenant();
382
383 let _ = provider.graph_config(&tc, "g1").await.unwrap();
384 let _ = provider.graph_config(&tc, "g1").await.unwrap();
385 let _ = provider.graph_config(&tc, "g1").await.unwrap();
386
387 assert_eq!(server.received_requests().await.unwrap().len(), 1);
389 }
390
391 #[tokio::test]
392 async fn caching_provider_expires_after_ttl() {
393 let server = MockServer::start().await;
394 Mock::given(method("GET"))
395 .respond_with(ResponseTemplate::new(200).set_body_json(valid_graph_json()))
396 .mount(&server)
397 .await;
398
399 let provider = CachingGraphProvider::with_ttl(
400 HttpGraphProvider::new(server.uri(), "tok"),
401 std::time::Duration::from_millis(50),
402 );
403 let tc = tenant();
404
405 let _ = provider.graph_config(&tc, "g1").await.unwrap();
406 tokio::time::sleep(std::time::Duration::from_millis(80)).await;
407 let _ = provider.graph_config(&tc, "g1").await.unwrap();
408
409 assert_eq!(server.received_requests().await.unwrap().len(), 2);
410 }
411
412 #[tokio::test]
413 async fn caching_provider_does_not_cache_errors() {
414 let server = MockServer::start().await;
417 Mock::given(method("GET"))
418 .respond_with(ResponseTemplate::new(503))
419 .mount(&server)
420 .await;
421
422 let provider = CachingGraphProvider::new(HttpGraphProvider::new(server.uri(), "tok"));
423 let tc = tenant();
424
425 let _ = provider.graph_config(&tc, "g1").await.unwrap_err();
426 let _ = provider.graph_config(&tc, "g1").await.unwrap_err();
427
428 assert_eq!(
430 server.received_requests().await.unwrap().len(),
431 2,
432 "errors must not be cached; every error call hits the inner provider"
433 );
434 }
435
436 #[tokio::test]
437 async fn caching_provider_isolates_tenants() {
438 let server = MockServer::start().await;
441 Mock::given(method("GET"))
442 .respond_with(ResponseTemplate::new(200).set_body_json(valid_graph_json()))
443 .mount(&server)
444 .await;
445
446 let provider = CachingGraphProvider::new(HttpGraphProvider::new(server.uri(), "tok"));
447 let tc_a = TenantContext::new("tenant-a", "prod");
448 let tc_b = TenantContext::new("tenant-b", "prod");
449
450 let _ = provider.graph_config(&tc_a, "g1").await.unwrap();
451 let _ = provider.graph_config(&tc_b, "g1").await.unwrap();
452
453 assert_eq!(
455 server.received_requests().await.unwrap().len(),
456 2,
457 "separate tenants must not share a cache entry for the same graph_id"
458 );
459 }
460}