Skip to main content

greentic_aw_runtime/graph/
http_provider.rs

1//! An HTTP provider that pulls a full [`GraphConfig`] from the
2//! greentic-designer-admin agent-graph registry over HTTP, authed with a
3//! tenant `gtc_live_*` bearer token.
4//!
5//! Mirrors [`crate::http_provider::HttpConfigProvider`] exactly — 10s timeout,
6//! bearer auth, identical error taxonomy:
7//!
8//! | HTTP status | `ConfigError` variant |
9//! |---|---|
10//! | 200 + valid JSON  | `Ok(GraphConfig)` |
11//! | 200 + invalid/unparseable body | `Misconfigured` (corrupt doc — do NOT silently fall back) |
12//! | 401 / 403 | `Misconfigured` (bad token — operator-actionable, do NOT fall back) |
13//! | 404 | `AgentNotFound(graph_id)` |
14//! | other (network, 5xx, …) | `Internal` |
15//!
16//! **Corrupt-doc decision** (mirrors `HttpConfigProvider`): a 200 body that
17//! fails `GraphConfig::from_json` — whether due to a JSON parse error, an
18//! unsupported `schemaVersion`, or a structural graph validation failure — is
19//! classified as `Misconfigured`, not `Internal`. This matches the agent
20//! provider, which calls `.json::<AgentConfig>()` and maps *all* decode errors
21//! to `Misconfigured`. The intent: a corrupt doc that the admin wrote is an
22//! operator configuration problem, not a transient infrastructure failure.
23//! Callers (`LayeredGraphProvider`) never fall back past a `Misconfigured`
24//! error, so a bad graph document surfaces immediately instead of silently
25//! hiding behind stale local pack data.
26
27use crate::error::ConfigError;
28use crate::graph::model::GraphConfig;
29use crate::tenant::TenantContext;
30
31/// Pulls [`GraphConfig`] from `{base}/api/v1/designer/agent-graphs/{graph_id}`.
32///
33/// Construct via [`HttpGraphProvider::new`]; call
34/// [`HttpGraphProvider::graph_config`] per request. The inherent method
35/// (rather than a trait) keeps this struct in `greentic-aw-runtime`, away from
36/// the `GraphConfigSource` trait that lives in `runner-host`. The trait adapter
37/// is a 5-line `impl GraphConfigSource for HttpGraphProvider` in `runner-host`'s
38/// `graph_node.rs`.
39pub struct HttpGraphProvider {
40    base_url: String,
41    token: String,
42    client: reqwest::Client,
43}
44
45impl HttpGraphProvider {
46    /// `base_url` is the admin origin (no trailing slash needed); `token` is a
47    /// tenant `gtc_live_*` key.
48    ///
49    /// Mirrors [`crate::http_provider::HttpConfigProvider::new`] exactly:
50    /// 10s per-request timeout, `unwrap_or_default` fallback on client build.
51    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    /// Fetch the graph document for `graph_id` from the admin registry.
64    ///
65    /// `tenant` is accepted for API symmetry with `ConfigProvider::agent_config`
66    /// but not used in the request URL (the bearer token already scopes the
67    /// request to the correct tenant, matching the agent-config provider's
68    /// behaviour).
69    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                // Read the full body as text, then parse via GraphConfig::from_json.
86                // Any failure — invalid JSON, wrong schemaVersion, structural
87                // validation error — is `Misconfigured` (mirrors HttpConfigProvider's
88                // `.json::<AgentConfig>()` decode-error → Misconfigured mapping).
89                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            // Auth failures are operator-actionable misconfig, not a
98            // transient fault — surface them (Misconfigured is NOT swallowed
99            // by LayeredGraphProvider) rather than masking a bad token behind
100            // a local fallback. Mirrors HttpConfigProvider verbatim.
101            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
112// ---------------------------------------------------------------------------
113// CachingGraphProvider
114// ---------------------------------------------------------------------------
115
116/// In-process TTL cache wrapping any `async fn graph_config(…)` provider.
117///
118/// Mirrors [`crate::config_provider::CachingConfigProvider`] for the graph
119/// path: default TTL is 60 seconds (per spec Decision 13). Use
120/// [`CachingGraphProvider::with_ttl`] for shorter values in tests.
121///
122/// The type parameter `P` must expose an inherent `async fn graph_config(…)`
123/// matching the signature used by [`HttpGraphProvider`]. Concretely only
124/// [`HttpGraphProvider`] needs wrapping today; a generic type parameter avoids
125/// boxing and lets the compiler inline the inner call.
126pub 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    /// Wrap `inner` with the production 60s TTL.
142    pub fn new(inner: P) -> Self {
143        Self::with_ttl(inner, std::time::Duration::from_secs(60))
144    }
145
146    /// Wrap `inner` with a custom TTL (useful for tests).
147    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    /// Fetch with caching: serve a valid cached entry within the TTL, otherwise
158    /// call the inner [`HttpGraphProvider`] and populate the cache on success.
159    ///
160    /// Only `Ok` responses are cached; errors are always forwarded to the
161    /// caller — matching `CachingConfigProvider` behaviour.
162    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// ---------------------------------------------------------------------------
188// Unit tests
189// ---------------------------------------------------------------------------
190
191#[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    /// Minimal valid graph JSON — mirrors the triage fixture used elsewhere.
199    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    // -----------------------------------------------------------------------
223    // HttpGraphProvider tests
224    // -----------------------------------------------------------------------
225
226    #[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        // graph_id of form "{worker}.graph" (as registered by store hand-off PR 3)
249        // — '.' is valid in graph ids; only ':' is forbidden. The URL must
250        // contain the literal dot, not a percent-encoded form.
251        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    // -----------------------------------------------------------------------
369    // CachingGraphProvider tests
370    // -----------------------------------------------------------------------
371
372    #[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        // wiremock counts requests; only 1 should have reached the mock server.
388        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        // Errors must always hit the inner provider; a cached error would
415        // permanently block a graph_id after a transient failure.
416        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        // Both calls must have reached the server — errors are not stored.
429        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        // Two tenants with the same graph_id must receive independent cache
439        // entries — serving tenant-A's graph to tenant-B would be a data leak.
440        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        // Each tenant's first request must hit the inner provider independently.
454        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}