Skip to main content

smooai_config/
client.rs

1//! Runtime configuration client for fetching values from the Smoo AI server.
2//!
3//! # Authentication
4//!
5//! SMOODEV-975: The runtime client mints a JWT via an OAuth2
6//! `client_credentials` exchange against `{auth_url}/token` before every
7//! call, and caches it via [`TokenProvider`](crate::token_provider::TokenProvider).
8//! Previously the SDK sent the raw API key as `Authorization: Bearer
9//! <api_key>`, which the backend rejects with 401.
10//!
11//! # Environment Variables
12//!
13//! The client can be configured via environment variables when using [`ConfigClient::from_env`]:
14//! - `SMOOAI_CONFIG_API_URL` — Base URL of the config API
15//! - `SMOOAI_CONFIG_AUTH_URL` — OAuth issuer base URL (default
16//!   `https://auth.smoo.ai`; legacy `SMOOAI_AUTH_URL` also accepted)
17//! - `SMOOAI_CONFIG_CLIENT_ID` — OAuth client ID
18//! - `SMOOAI_CONFIG_CLIENT_SECRET` — OAuth client secret (legacy
19//!   `SMOOAI_CONFIG_API_KEY` accepted as a deprecated alias)
20//! - `SMOOAI_CONFIG_ORG_ID` — Organization ID
21//! - `SMOOAI_CONFIG_ENV` — Default environment name (e.g. "production")
22
23use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
24use reqwest::{Client, Response};
25use serde::{Deserialize, Serialize};
26use std::collections::HashMap;
27use std::env;
28use std::sync::Arc;
29use std::time::{Duration, Instant};
30use thiserror::Error;
31
32use crate::token_provider::{SharedTokenProvider, TokenProvider, TokenProviderError};
33
34/// Characters to percent-encode in URL path segments.
35/// Encodes everything except unreserved characters (RFC 3986): A-Z a-z 0-9 - . _ ~
36const PATH_SEGMENT_ENCODE_SET: &AsciiSet = &CONTROLS
37    .add(b' ')
38    .add(b'"')
39    .add(b'#')
40    .add(b'%')
41    .add(b'/')
42    .add(b':')
43    .add(b'<')
44    .add(b'>')
45    .add(b'?')
46    .add(b'@')
47    .add(b'[')
48    .add(b'\\')
49    .add(b']')
50    .add(b'^')
51    .add(b'`')
52    .add(b'{')
53    .add(b'|')
54    .add(b'}');
55
56/// Client for reading configuration values from the Smoo AI config server.
57///
58/// SMOODEV-975: now uses an [`Arc<TokenProvider>`](crate::token_provider::TokenProvider)
59/// to mint a JWT via OAuth2 client_credentials before each request. Pass
60/// `client_id` + `client_secret` (or call [`ConfigClient::with_token_provider`])
61/// on construction.
62pub struct ConfigClient {
63    base_url: String,
64    org_id: String,
65    default_environment: String,
66    cache_ttl: Option<Duration>,
67    client: Client,
68    token_provider: SharedTokenProvider,
69    cache: HashMap<String, CacheEntry>,
70}
71
72/// Unified error type for [`ConfigClient`] requests (SMOODEV-975).
73///
74/// Combines transport, OAuth, and decode failures so callers don't have
75/// to discriminate between `reqwest::Error` and [`TokenProviderError`]
76/// at the call site.
77#[derive(Debug, Error)]
78pub enum ConfigClientError {
79    /// Underlying HTTP / JSON failure.
80    #[error(transparent)]
81    Request(#[from] reqwest::Error),
82    /// OAuth handshake or refresh failure.
83    #[error(transparent)]
84    TokenProvider(#[from] TokenProviderError),
85    /// Server returned a non-success status. Use
86    /// [`ConfigClientError::status`] to branch on the code.
87    #[error("config request failed: HTTP {status} {body}")]
88    HttpStatus { status: u16, body: String },
89}
90
91impl ConfigClientError {
92    /// Returns the HTTP status code when the error was an `HttpStatus`.
93    pub fn status(&self) -> Option<u16> {
94        match self {
95            Self::HttpStatus { status, .. } => Some(*status),
96            _ => None,
97        }
98    }
99}
100
101struct CacheEntry {
102    value: serde_json::Value,
103    expires_at: Option<Instant>,
104}
105
106#[derive(Deserialize)]
107struct ValueResponse {
108    value: serde_json::Value,
109}
110
111#[derive(Deserialize)]
112struct ValuesResponse {
113    values: HashMap<String, serde_json::Value>,
114}
115
116/// Response from the server-side feature-flag evaluator.
117///
118/// Matches the wire contract defined by the TS / Python / Go clients and
119/// the `/organizations/{org_id}/config/feature-flags/{key}/evaluate` endpoint.
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
121pub struct EvaluateFeatureFlagResponse {
122    /// The resolved flag value (post rules + rollout).
123    pub value: serde_json::Value,
124    /// Id of the rule that fired, if any.
125    #[serde(rename = "matchedRuleId", skip_serializing_if = "Option::is_none")]
126    pub matched_rule_id: Option<String>,
127    /// 0–99 bucket the context was assigned to, if a rollout ran.
128    #[serde(rename = "rolloutBucket", skip_serializing_if = "Option::is_none")]
129    pub rollout_bucket: Option<u32>,
130    /// Which branch the evaluator returned from: `"raw"`, `"rule"`,
131    /// `"rollout"`, or `"default"`.
132    pub source: String,
133}
134
135/// Errors produced by [`ConfigClient::evaluate_feature_flag`].
136///
137/// Mirrors the TS `FeatureFlagEvaluationError` hierarchy: callers can match
138/// on `NotFound` / `ContextError` / `Evaluation` without parsing messages.
139/// `Request` wraps underlying transport / deserialization failures.
140#[derive(Debug, Error)]
141pub enum FeatureFlagEvaluationError {
142    /// Server returned 404 — the flag key is not defined in the org's schema.
143    #[error("Feature flag \"{key}\" evaluation failed: HTTP 404 — flag not defined in schema")]
144    NotFound { key: String },
145    /// Server returned 400 — invalid context or environment.
146    #[error("Feature flag \"{key}\" evaluation failed: HTTP 400 — {message}")]
147    ContextError { key: String, message: String },
148    /// Server returned a non-success status other than 400 / 404.
149    #[error("Feature flag \"{key}\" evaluation failed: HTTP {status}{}", if .message.is_empty() { String::new() } else { format!(" — {}", .message) })]
150    Evaluation { key: String, status: u16, message: String },
151    /// Underlying HTTP transport or JSON deserialization failure.
152    #[error("Feature flag \"{key}\" evaluation failed: {source}")]
153    Request {
154        key: String,
155        #[source]
156        source: reqwest::Error,
157    },
158}
159
160impl FeatureFlagEvaluationError {
161    /// The flag key the failed evaluation was for.
162    pub fn key(&self) -> &str {
163        match self {
164            Self::NotFound { key } => key,
165            Self::ContextError { key, .. } => key,
166            Self::Evaluation { key, .. } => key,
167            Self::Request { key, .. } => key,
168        }
169    }
170
171    /// The HTTP status code, if the failure came from a server response.
172    /// Returns `None` for transport / parse errors.
173    pub fn status_code(&self) -> Option<u16> {
174        match self {
175            Self::NotFound { .. } => Some(404),
176            Self::ContextError { .. } => Some(400),
177            Self::Evaluation { status, .. } => Some(*status),
178            Self::Request { .. } => None,
179        }
180    }
181}
182
183impl ConfigClient {
184    /// Create a new config client with explicit parameters.
185    ///
186    /// SMOODEV-975: takes both `client_id` and `client_secret` to mint
187    /// OAuth tokens. The OAuth issuer URL is read from the
188    /// `SMOOAI_CONFIG_AUTH_URL` env var (or `SMOOAI_AUTH_URL`, or the
189    /// default `https://auth.smoo.ai`). Use [`Self::with_token_provider`]
190    /// for tests where you want to inject a stub provider.
191    pub fn new(base_url: &str, client_id: &str, client_secret: &str, org_id: &str) -> Self {
192        let default_env = env::var("SMOOAI_CONFIG_ENV").unwrap_or_else(|_| "development".to_string());
193        Self::with_environment(base_url, client_id, client_secret, org_id, &default_env)
194    }
195
196    /// Create a new config client with an explicit default environment.
197    pub fn with_environment(
198        base_url: &str,
199        client_id: &str,
200        client_secret: &str,
201        org_id: &str,
202        environment: &str,
203    ) -> Self {
204        let auth_url = env::var("SMOOAI_CONFIG_AUTH_URL")
205            .or_else(|_| env::var("SMOOAI_AUTH_URL"))
206            .unwrap_or_else(|_| "https://auth.smoo.ai".to_string());
207
208        let provider = TokenProvider::new(&auth_url, client_id, client_secret)
209            .expect("TokenProvider construction with non-empty credentials");
210
211        Self::with_token_provider(base_url, Arc::new(provider), org_id, environment)
212    }
213
214    /// Construct a client that uses the provided [`TokenProvider`].
215    ///
216    /// Useful in tests to inject a stub provider that returns a fixed
217    /// JWT without performing a real OAuth handshake, and for callers
218    /// that want to share a single provider across multiple clients.
219    pub fn with_token_provider(
220        base_url: &str,
221        token_provider: SharedTokenProvider,
222        org_id: &str,
223        environment: &str,
224    ) -> Self {
225        let client = Client::builder().build().expect("reqwest client builder");
226
227        Self {
228            base_url: base_url.trim_end_matches('/').to_string(),
229            org_id: org_id.to_string(),
230            default_environment: environment.to_string(),
231            cache_ttl: None,
232            client,
233            token_provider,
234            cache: HashMap::new(),
235        }
236    }
237
238    /// Set the cache TTL duration. `None` means cache never expires (manual invalidation only).
239    pub fn set_cache_ttl(&mut self, ttl: Option<Duration>) {
240        self.cache_ttl = ttl;
241    }
242
243    /// Create a config client from environment variables.
244    ///
245    /// SMOODEV-975: Reads `SMOOAI_CONFIG_API_URL`, `SMOOAI_CONFIG_CLIENT_ID`,
246    /// `SMOOAI_CONFIG_CLIENT_SECRET` (or the legacy `SMOOAI_CONFIG_API_KEY`),
247    /// `SMOOAI_CONFIG_ORG_ID`, and optionally `SMOOAI_CONFIG_ENV`
248    /// (defaults to "development") and `SMOOAI_CONFIG_AUTH_URL`.
249    ///
250    /// # Panics
251    /// Panics if any required environment variable is missing.
252    pub fn from_env() -> Self {
253        let base_url = env::var("SMOOAI_CONFIG_API_URL").expect("SMOOAI_CONFIG_API_URL must be set");
254        let client_id = env::var("SMOOAI_CONFIG_CLIENT_ID").expect("SMOOAI_CONFIG_CLIENT_ID must be set");
255        let client_secret = env::var("SMOOAI_CONFIG_CLIENT_SECRET")
256            .or_else(|_| env::var("SMOOAI_CONFIG_API_KEY"))
257            .expect("SMOOAI_CONFIG_CLIENT_SECRET (or legacy SMOOAI_CONFIG_API_KEY) must be set");
258        let org_id = env::var("SMOOAI_CONFIG_ORG_ID").expect("SMOOAI_CONFIG_ORG_ID must be set");
259
260        Self::new(&base_url, &client_id, &client_secret, &org_id)
261    }
262
263    /// Build an Authorization header value via the TokenProvider.
264    async fn bearer_header(&self) -> Result<String, ConfigClientError> {
265        let token = self.token_provider.get_access_token().await?;
266        Ok(format!("Bearer {}", token))
267    }
268
269    /// Send a request with auth, retrying once after invalidating the
270    /// cached token on a 401 (handles server-side rotation / revocation).
271    async fn send_with_retry(
272        &self,
273        method: reqwest::Method,
274        url: &str,
275        with_body: Option<&serde_json::Value>,
276        query: &[(&str, &str)],
277    ) -> Result<Response, ConfigClientError> {
278        // First attempt.
279        let auth = self.bearer_header().await?;
280        let mut req = self
281            .client
282            .request(method.clone(), url)
283            .header(reqwest::header::AUTHORIZATION, auth)
284            .query(query);
285        if let Some(body) = with_body {
286            req = req.header(reqwest::header::CONTENT_TYPE, "application/json").json(body);
287        }
288        let resp = req.send().await?;
289        if resp.status().as_u16() != 401 {
290            return Ok(resp);
291        }
292        // 401 — invalidate and retry once with a fresh token.
293        self.token_provider.invalidate().await;
294        let auth = self.bearer_header().await?;
295        let mut req2 = self
296            .client
297            .request(method, url)
298            .header(reqwest::header::AUTHORIZATION, auth)
299            .query(query);
300        if let Some(body) = with_body {
301            req2 = req2
302                .header(reqwest::header::CONTENT_TYPE, "application/json")
303                .json(body);
304        }
305        Ok(req2.send().await?)
306    }
307
308    fn resolve_env<'a>(&'a self, environment: Option<&'a str>) -> &'a str {
309        match environment {
310            Some(e) if !e.is_empty() => e,
311            _ => &self.default_environment,
312        }
313    }
314
315    fn compute_expires_at(&self) -> Option<Instant> {
316        self.cache_ttl.map(|ttl| Instant::now() + ttl)
317    }
318
319    fn get_cached(&self, cache_key: &str) -> Option<serde_json::Value> {
320        let entry = self.cache.get(cache_key)?;
321        if let Some(expires_at) = entry.expires_at {
322            if Instant::now() > expires_at {
323                return None;
324            }
325        }
326        Some(entry.value.clone())
327    }
328
329    /// Get a single config value.
330    /// Pass `None` for environment to use the default.
331    pub async fn get_value(
332        &mut self,
333        key: &str,
334        environment: Option<&str>,
335    ) -> Result<serde_json::Value, ConfigClientError> {
336        let env = self.resolve_env(environment).to_string();
337        let cache_key = format!("{}:{}", env, key);
338
339        if let Some(cached) = self.get_cached(&cache_key) {
340            return Ok(cached);
341        }
342
343        // Remove expired entry if still in map
344        if self.cache.contains_key(&cache_key) {
345            self.cache.remove(&cache_key);
346        }
347
348        let encoded_key = utf8_percent_encode(key, PATH_SEGMENT_ENCODE_SET).to_string();
349        let url = format!(
350            "{}/organizations/{}/config/values/{}",
351            self.base_url, self.org_id, encoded_key
352        );
353
354        let resp = self
355            .send_with_retry(reqwest::Method::GET, &url, None, &[("environment", env.as_str())])
356            .await?;
357        let status = resp.status();
358        if !status.is_success() {
359            let body = resp.text().await.unwrap_or_default();
360            return Err(ConfigClientError::HttpStatus {
361                status: status.as_u16(),
362                body,
363            });
364        }
365        let response: ValueResponse = resp.json().await?;
366
367        let expires_at = self.compute_expires_at();
368        self.cache.insert(
369            cache_key,
370            CacheEntry {
371                value: response.value.clone(),
372                expires_at,
373            },
374        );
375        Ok(response.value)
376    }
377
378    /// Get all config values for an environment.
379    /// Pass `None` for environment to use the default.
380    pub async fn get_all_values(
381        &mut self,
382        environment: Option<&str>,
383    ) -> Result<HashMap<String, serde_json::Value>, ConfigClientError> {
384        let env = self.resolve_env(environment).to_string();
385        let url = format!("{}/organizations/{}/config/values", self.base_url, self.org_id);
386
387        let resp = self
388            .send_with_retry(reqwest::Method::GET, &url, None, &[("environment", env.as_str())])
389            .await?;
390        let status = resp.status();
391        if !status.is_success() {
392            let body = resp.text().await.unwrap_or_default();
393            return Err(ConfigClientError::HttpStatus {
394                status: status.as_u16(),
395                body,
396            });
397        }
398        let response: ValuesResponse = resp.json().await?;
399
400        let expires_at = self.compute_expires_at();
401        for (key, value) in &response.values {
402            self.cache.insert(
403                format!("{}:{}", env, key),
404                CacheEntry {
405                    value: value.clone(),
406                    expires_at,
407                },
408            );
409        }
410
411        Ok(response.values)
412    }
413
414    /// Evaluate a segment-aware feature flag on the server.
415    ///
416    /// Unlike [`get_value`](Self::get_value), this is always a network call —
417    /// segment rules (percentage rollout, attribute matching, bucketing) live
418    /// server-side and the response depends on the `context` you pass. Callers
419    /// that don't need segment evaluation should keep using `get_value` for the
420    /// static flag value.
421    ///
422    /// # Arguments
423    /// * `key` — Feature-flag key. URL-encoded before being placed in the path.
424    /// * `context` — Attributes the server's segment rules may reference
425    ///   (e.g. `{ "userId": ..., "plan": ... }`). `None` is equivalent to an
426    ///   empty map. Values must be JSON-serializable — the server hashes
427    ///   `bucketBy` values by their string representation, so numbers and
428    ///   booleans bucket stably across client rebuilds.
429    /// * `environment` — Environment name (defaults to the client's default
430    ///   environment when `None`).
431    ///
432    /// # Errors
433    /// * [`FeatureFlagEvaluationError::NotFound`] — 404, flag not defined.
434    /// * [`FeatureFlagEvaluationError::ContextError`] — 400, bad context.
435    /// * [`FeatureFlagEvaluationError::Evaluation`] — other non-2xx status.
436    /// * [`FeatureFlagEvaluationError::Request`] — transport / parse failure.
437    pub async fn evaluate_feature_flag(
438        &self,
439        key: &str,
440        context: Option<HashMap<String, serde_json::Value>>,
441        environment: Option<&str>,
442    ) -> Result<EvaluateFeatureFlagResponse, FeatureFlagEvaluationError> {
443        let env = self.resolve_env(environment).to_string();
444        let encoded_key = utf8_percent_encode(key, PATH_SEGMENT_ENCODE_SET).to_string();
445        let url = format!(
446            "{}/organizations/{}/config/feature-flags/{}/evaluate",
447            self.base_url, self.org_id, encoded_key
448        );
449
450        let body = serde_json::json!({
451            "environment": env,
452            "context": context.unwrap_or_default(),
453        });
454
455        let response = self
456            .send_with_retry(reqwest::Method::POST, &url, Some(&body), &[])
457            .await
458            .map_err(|err| match err {
459                ConfigClientError::Request(source) => FeatureFlagEvaluationError::Request {
460                    key: key.to_string(),
461                    source,
462                },
463                // OAuth / HTTP-status errors surface as a generic evaluation
464                // failure with status=0 so callers can branch on the variant
465                // without losing the original message.
466                other => FeatureFlagEvaluationError::Evaluation {
467                    key: key.to_string(),
468                    status: 0,
469                    message: other.to_string(),
470                },
471            })?;
472
473        let status = response.status();
474        if status.is_success() {
475            return response.json::<EvaluateFeatureFlagResponse>().await.map_err(|source| {
476                FeatureFlagEvaluationError::Request {
477                    key: key.to_string(),
478                    source,
479                }
480            });
481        }
482
483        // Non-2xx — read body as text (best-effort) and map to typed error.
484        let status_code = status.as_u16();
485        let message = response.text().await.unwrap_or_default();
486
487        Err(match status_code {
488            404 => FeatureFlagEvaluationError::NotFound { key: key.to_string() },
489            400 => FeatureFlagEvaluationError::ContextError {
490                key: key.to_string(),
491                message,
492            },
493            _ => FeatureFlagEvaluationError::Evaluation {
494                key: key.to_string(),
495                status: status_code,
496                message,
497            },
498        })
499    }
500
501    /// Read a value from the local cache only, without hitting the server.
502    ///
503    /// Returns `None` when the key is absent or its TTL has expired. Used by
504    /// container mode's sync getters and last-good fallback (SMOODEV-1494): on
505    /// a background HTTP refresh failure the previously-fetched value is served
506    /// from cache until the TTL hard-expires.
507    pub fn get_cached_value(&self, key: &str, environment: Option<&str>) -> Option<serde_json::Value> {
508        let env = self.resolve_env(environment);
509        let cache_key = format!("{}:{}", env, key);
510        self.get_cached(&cache_key)
511    }
512
513    /// Seed a single value into the local cache (subject to the configured TTL)
514    /// without a network round-trip.
515    ///
516    /// Used by container mode's env tier (SMOODEV-1494): when an explicit
517    /// process env override wins, the value is mirrored into the cache so a
518    /// later `get_cached_value` / sync read sees it.
519    pub fn seed_cache(&mut self, key: &str, value: serde_json::Value, environment: Option<&str>) {
520        let env = self.resolve_env(environment).to_string();
521        let cache_key = format!("{}:{}", env, key);
522        let expires_at = self.compute_expires_at();
523        self.cache.insert(cache_key, CacheEntry { value, expires_at });
524    }
525
526    /// Clear the entire local cache.
527    pub fn invalidate_cache(&mut self) {
528        self.cache.clear();
529    }
530
531    /// Clear cached values for a specific environment.
532    pub fn invalidate_cache_for_environment(&mut self, environment: &str) {
533        let prefix = format!("{}:", environment);
534        self.cache.retain(|key, _| !key.starts_with(&prefix));
535    }
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    #[test]
543    fn test_new_trims_trailing_slash() {
544        let client = ConfigClient::new("https://api.example.com/", "key", "key", "org-id");
545        assert_eq!(client.base_url, "https://api.example.com");
546    }
547
548    #[test]
549    fn test_new_preserves_url_without_trailing_slash() {
550        let client = ConfigClient::new("https://api.example.com", "key", "key", "org-id");
551        assert_eq!(client.base_url, "https://api.example.com");
552    }
553
554    #[test]
555    fn test_new_stores_org_id() {
556        let client = ConfigClient::new("https://api.example.com", "key", "key", "my-org-123");
557        assert_eq!(client.org_id, "my-org-123");
558    }
559
560    #[test]
561    fn test_new_initializes_empty_cache() {
562        let client = ConfigClient::new("https://api.example.com", "key", "key", "org");
563        assert!(client.cache.is_empty());
564    }
565
566    #[test]
567    fn test_invalidate_cache_clears_all() {
568        let mut client = ConfigClient::new("https://api.example.com", "key", "key", "org");
569        client.cache.insert(
570            "prod:KEY".to_string(),
571            CacheEntry {
572                value: serde_json::json!("value"),
573                expires_at: None,
574            },
575        );
576        client.cache.insert(
577            "staging:KEY".to_string(),
578            CacheEntry {
579                value: serde_json::json!(42),
580                expires_at: None,
581            },
582        );
583
584        assert_eq!(client.cache.len(), 2);
585        client.invalidate_cache();
586        assert!(client.cache.is_empty());
587    }
588
589    #[test]
590    fn test_invalidate_empty_cache_is_noop() {
591        let mut client = ConfigClient::new("https://api.example.com", "key", "key", "org");
592        client.invalidate_cache();
593        assert!(client.cache.is_empty());
594    }
595
596    #[test]
597    fn test_invalidate_cache_for_environment() {
598        let mut client = ConfigClient::new("https://api.example.com", "key", "key", "org");
599        client.cache.insert(
600            "prod:KEY1".to_string(),
601            CacheEntry {
602                value: serde_json::json!("v1"),
603                expires_at: None,
604            },
605        );
606        client.cache.insert(
607            "prod:KEY2".to_string(),
608            CacheEntry {
609                value: serde_json::json!("v2"),
610                expires_at: None,
611            },
612        );
613        client.cache.insert(
614            "staging:KEY1".to_string(),
615            CacheEntry {
616                value: serde_json::json!("sv1"),
617                expires_at: None,
618            },
619        );
620
621        client.invalidate_cache_for_environment("prod");
622        assert_eq!(client.cache.len(), 1);
623        assert!(client.cache.contains_key("staging:KEY1"));
624    }
625
626    #[test]
627    fn test_cache_ttl_none_by_default() {
628        let client = ConfigClient::new("https://api.example.com", "key", "key", "org");
629        assert!(client.cache_ttl.is_none());
630    }
631
632    #[test]
633    fn test_set_cache_ttl() {
634        let mut client = ConfigClient::new("https://api.example.com", "key", "key", "org");
635        client.set_cache_ttl(Some(Duration::from_secs(60)));
636        assert_eq!(client.cache_ttl, Some(Duration::from_secs(60)));
637    }
638
639    #[test]
640    fn test_value_response_deserialization() {
641        let json = r#"{"value": "hello"}"#;
642        let resp: ValueResponse = serde_json::from_str(json).unwrap();
643        assert_eq!(resp.value, serde_json::json!("hello"));
644    }
645
646    #[test]
647    fn test_value_response_complex_value() {
648        let json = r#"{"value": {"nested": true, "count": 42}}"#;
649        let resp: ValueResponse = serde_json::from_str(json).unwrap();
650        assert_eq!(resp.value["nested"], true);
651        assert_eq!(resp.value["count"], 42);
652    }
653
654    #[test]
655    fn test_values_response_deserialization() {
656        let json = r#"{"values": {"KEY1": "val1", "KEY2": 42}}"#;
657        let resp: ValuesResponse = serde_json::from_str(json).unwrap();
658        assert_eq!(resp.values.len(), 2);
659        assert_eq!(resp.values["KEY1"], serde_json::json!("val1"));
660        assert_eq!(resp.values["KEY2"], serde_json::json!(42));
661    }
662
663    #[test]
664    fn test_values_response_empty() {
665        let json = r#"{"values": {}}"#;
666        let resp: ValuesResponse = serde_json::from_str(json).unwrap();
667        assert!(resp.values.is_empty());
668    }
669
670    #[test]
671    fn test_default_environment() {
672        let client = ConfigClient::with_environment("https://api.example.com", "key", "key", "org", "production");
673        assert_eq!(client.default_environment, "production");
674    }
675}
676
677#[cfg(test)]
678mod integration_tests {
679    use super::*;
680    use std::time::Duration;
681    use wiremock::matchers::{header, method, path_regex, query_param};
682    use wiremock::{Mock, MockServer, ResponseTemplate};
683
684    // SMOODEV-975: stub TokenProvider helper for these in-file tests.
685    // The runtime client now mints a JWT via OAuth before each call;
686    // tests register a /token mock that returns a fixed token via
687    // `mock_token` and then assert against `Bearer <token>` downstream.
688    async fn mock_token(server: &MockServer, token: &str) {
689        Mock::given(method("POST"))
690            .and(path_regex(r"^/token$"))
691            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
692                "access_token": token,
693                "expires_in": 3600
694            })))
695            .mount(server)
696            .await;
697    }
698
699    /// Build a ConfigClient pointed at the mock server with a stub
700    /// TokenProvider whose access_token comes from the server's /token
701    /// mock. Asserts in the test should use the same token string.
702    async fn test_client(server: &MockServer, token: &str, environment: &str) -> ConfigClient {
703        mock_token(server, token).await;
704        let tp = TokenProvider::with_options(
705            &server.uri(),
706            "test-client-id",
707            "test-client-secret",
708            Duration::from_secs(60),
709            Client::new(),
710        )
711        .expect("valid token provider");
712        ConfigClient::with_token_provider(&server.uri(), Arc::new(tp), "test-org", environment)
713    }
714
715    // --- Test 1: get_value fetches a single value correctly ---
716    #[tokio::test]
717    async fn test_get_value_fetches_single_value() {
718        let mock_server = MockServer::start().await;
719
720        Mock::given(method("GET"))
721            .and(path_regex(r"/organizations/.+/config/values/.+"))
722            .and(query_param("environment", "production"))
723            .and(header("Authorization", "Bearer test-api-key"))
724            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"value": "hello-world"})))
725            .expect(1)
726            .mount(&mock_server)
727            .await;
728
729        let mut client = test_client(&mock_server, "test-api-key", "production").await;
730        let value = client.get_value("MY_KEY", None).await.unwrap();
731        assert_eq!(value, serde_json::json!("hello-world"));
732    }
733
734    // --- Test 2: get_all_values fetches all values correctly ---
735    #[tokio::test]
736    async fn test_get_all_values_fetches_all() {
737        let mock_server = MockServer::start().await;
738
739        Mock::given(method("GET"))
740            .and(path_regex(r"/organizations/.+/config/values$"))
741            .and(query_param("environment", "staging"))
742            .and(header("Authorization", "Bearer test-api-key"))
743            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
744                "values": {
745                    "DB_HOST": "db.example.com",
746                    "DB_PORT": 5432,
747                    "FEATURE_FLAG": true
748                }
749            })))
750            .expect(1)
751            .mount(&mock_server)
752            .await;
753
754        let mut client = test_client(&mock_server, "test-api-key", "staging").await;
755        let values = client.get_all_values(None).await.unwrap();
756
757        assert_eq!(values.len(), 3);
758        assert_eq!(values["DB_HOST"], serde_json::json!("db.example.com"));
759        assert_eq!(values["DB_PORT"], serde_json::json!(5432));
760        assert_eq!(values["FEATURE_FLAG"], serde_json::json!(true));
761    }
762
763    // --- Test 3: Authorization header is sent correctly ---
764    #[tokio::test]
765    async fn test_auth_header_verification() {
766        let mock_server = MockServer::start().await;
767
768        // Mock expects a specific bearer token
769        Mock::given(method("GET"))
770            .and(path_regex(r"/organizations/.+/config/values/.+"))
771            .and(header("Authorization", "Bearer my-secret-token-xyz"))
772            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"value": "authenticated"})))
773            .expect(1)
774            .mount(&mock_server)
775            .await;
776
777        let mut client = test_client(&mock_server, "my-secret-token-xyz", "production").await;
778        let value = client.get_value("SECRET_KEY", None).await.unwrap();
779        assert_eq!(value, serde_json::json!("authenticated"));
780    }
781
782    // --- Test 4: Caching — second call to same key doesn't hit server ---
783    #[tokio::test]
784    async fn test_caching_prevents_duplicate_requests() {
785        let mock_server = MockServer::start().await;
786
787        Mock::given(method("GET"))
788            .and(path_regex(r"/organizations/.+/config/values/.+"))
789            .and(query_param("environment", "production"))
790            .and(header("Authorization", "Bearer test-api-key"))
791            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"value": "cached-value"})))
792            .expect(1) // Server should only be hit once
793            .mount(&mock_server)
794            .await;
795
796        let mut client = test_client(&mock_server, "test-api-key", "production").await;
797
798        // First call — hits the server
799        let value1 = client.get_value("CACHE_KEY", None).await.unwrap();
800        assert_eq!(value1, serde_json::json!("cached-value"));
801
802        // Second call — served from cache, no server hit
803        let value2 = client.get_value("CACHE_KEY", None).await.unwrap();
804        assert_eq!(value2, serde_json::json!("cached-value"));
805    }
806
807    // --- Test 5: TTL expiration causes re-fetch from server ---
808    #[tokio::test]
809    async fn test_ttl_expiration_refetches() {
810        let mock_server = MockServer::start().await;
811
812        Mock::given(method("GET"))
813            .and(path_regex(r"/organizations/.+/config/values/.+"))
814            .and(query_param("environment", "production"))
815            .and(header("Authorization", "Bearer test-api-key"))
816            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"value": "fresh-value"})))
817            .expect(2) // Server should be hit twice: initial + after TTL expiry
818            .mount(&mock_server)
819            .await;
820
821        let mut client = test_client(&mock_server, "test-api-key", "production").await;
822        // Set a very short TTL so it expires quickly
823        client.set_cache_ttl(Some(Duration::from_millis(1)));
824
825        // First call — hits the server
826        let value1 = client.get_value("TTL_KEY", None).await.unwrap();
827        assert_eq!(value1, serde_json::json!("fresh-value"));
828
829        // Wait for TTL to expire
830        tokio::time::sleep(Duration::from_millis(50)).await;
831
832        // Second call — cache expired, hits the server again
833        let value2 = client.get_value("TTL_KEY", None).await.unwrap();
834        assert_eq!(value2, serde_json::json!("fresh-value"));
835    }
836
837    // --- Test 6: invalidate_cache forces re-fetch ---
838    #[tokio::test]
839    async fn test_invalidate_cache_forces_refetch() {
840        let mock_server = MockServer::start().await;
841
842        Mock::given(method("GET"))
843            .and(path_regex(r"/organizations/.+/config/values/.+"))
844            .and(query_param("environment", "production"))
845            .and(header("Authorization", "Bearer test-api-key"))
846            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"value": "refetched"})))
847            .expect(2) // Server hit twice: initial + after invalidation
848            .mount(&mock_server)
849            .await;
850
851        let mut client = test_client(&mock_server, "test-api-key", "production").await;
852
853        // First call — hits the server
854        let value1 = client.get_value("INVAL_KEY", None).await.unwrap();
855        assert_eq!(value1, serde_json::json!("refetched"));
856
857        // Invalidate cache
858        client.invalidate_cache();
859
860        // Second call — cache cleared, hits the server again
861        let value2 = client.get_value("INVAL_KEY", None).await.unwrap();
862        assert_eq!(value2, serde_json::json!("refetched"));
863    }
864
865    // --- Test 7: Error handling — server returns 401 ---
866    #[tokio::test]
867    async fn test_error_handling_401_unauthorized() {
868        let mock_server = MockServer::start().await;
869
870        // SMOODEV-975: ConfigClient invalidates the cached token on 401
871        // and retries once with a fresh JWT, so the GET fires twice.
872        Mock::given(method("GET"))
873            .and(path_regex(r"/organizations/.+/config/values/.+"))
874            .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
875                "error": "Unauthorized"
876            })))
877            .expect(2)
878            .mount(&mock_server)
879            .await;
880
881        let mut client = test_client(&mock_server, "bad-api-key", "production").await;
882
883        let result = client.get_value("SOME_KEY", None).await;
884        assert!(result.is_err(), "Expected error for 401 response");
885    }
886
887    // --- Test 8: Error handling — server returns 404 ---
888    #[tokio::test]
889    async fn test_error_handling_404_not_found() {
890        let mock_server = MockServer::start().await;
891
892        Mock::given(method("GET"))
893            .and(path_regex(r"/organizations/.+/config/values/.+"))
894            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
895                "error": "Not found"
896            })))
897            .expect(1)
898            .mount(&mock_server)
899            .await;
900
901        let mut client = test_client(&mock_server, "test-api-key", "production").await;
902
903        let result = client.get_value("NONEXISTENT_KEY", None).await;
904        assert!(result.is_err(), "Expected error for 404 response");
905    }
906
907    // --- Test 9: Per-environment caching — different envs are separate cache entries ---
908    #[tokio::test]
909    async fn test_per_environment_caching() {
910        let mock_server = MockServer::start().await;
911
912        // Mock for production environment
913        Mock::given(method("GET"))
914            .and(path_regex(r"/organizations/.+/config/values/.+"))
915            .and(query_param("environment", "production"))
916            .and(header("Authorization", "Bearer test-api-key"))
917            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"value": "prod-value"})))
918            .expect(1)
919            .mount(&mock_server)
920            .await;
921
922        // Mock for staging environment
923        Mock::given(method("GET"))
924            .and(path_regex(r"/organizations/.+/config/values/.+"))
925            .and(query_param("environment", "staging"))
926            .and(header("Authorization", "Bearer test-api-key"))
927            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"value": "staging-value"})))
928            .expect(1)
929            .mount(&mock_server)
930            .await;
931
932        let mut client = test_client(&mock_server, "test-api-key", "production").await;
933
934        // Fetch for production (default env)
935        let prod_value = client.get_value("SHARED_KEY", None).await.unwrap();
936        assert_eq!(prod_value, serde_json::json!("prod-value"));
937
938        // Fetch for staging (explicit env override)
939        let staging_value = client.get_value("SHARED_KEY", Some("staging")).await.unwrap();
940        assert_eq!(staging_value, serde_json::json!("staging-value"));
941
942        // Fetch production again — should come from cache (mock expects only 1 call)
943        let prod_cached = client.get_value("SHARED_KEY", None).await.unwrap();
944        assert_eq!(prod_cached, serde_json::json!("prod-value"));
945
946        // Fetch staging again — should come from cache (mock expects only 1 call)
947        let staging_cached = client.get_value("SHARED_KEY", Some("staging")).await.unwrap();
948        assert_eq!(staging_cached, serde_json::json!("staging-value"));
949    }
950
951    // -----------------------------------------------------------------------
952    // evaluate_feature_flag
953    // -----------------------------------------------------------------------
954
955    use wiremock::matchers::{body_json, path as path_matcher};
956
957    // --- Evaluate: POST with environment + context, returns parsed response ---
958    #[tokio::test]
959    async fn test_evaluate_feature_flag_posts_body_and_returns_response() {
960        let mock_server = MockServer::start().await;
961
962        Mock::given(method("POST"))
963            .and(path_matcher(
964                "/organizations/test-org/config/feature-flags/aboutPage/evaluate",
965            ))
966            .and(header("Authorization", "Bearer test-api-key"))
967            .and(header("content-type", "application/json"))
968            .and(body_json(serde_json::json!({
969                "environment": "production",
970                "context": { "userId": "u-1", "plan": "pro" }
971            })))
972            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
973                "value": true,
974                "source": "rule",
975                "matchedRuleId": "rule-123"
976            })))
977            .expect(1)
978            .mount(&mock_server)
979            .await;
980
981        let client = test_client(&mock_server, "test-api-key", "production").await;
982        let mut ctx = HashMap::new();
983        ctx.insert("userId".to_string(), serde_json::json!("u-1"));
984        ctx.insert("plan".to_string(), serde_json::json!("pro"));
985
986        let result = client
987            .evaluate_feature_flag("aboutPage", Some(ctx), None)
988            .await
989            .expect("evaluator returns 200");
990
991        assert_eq!(result.value, serde_json::json!(true));
992        assert_eq!(result.source, "rule");
993        assert_eq!(result.matched_rule_id.as_deref(), Some("rule-123"));
994        assert_eq!(result.rollout_bucket, None);
995    }
996
997    // --- Evaluate: None context defaults to empty object ---
998    #[tokio::test]
999    async fn test_evaluate_feature_flag_defaults_context_to_empty() {
1000        let mock_server = MockServer::start().await;
1001
1002        Mock::given(method("POST"))
1003            .and(path_matcher(
1004                "/organizations/test-org/config/feature-flags/aboutPage/evaluate",
1005            ))
1006            .and(body_json(serde_json::json!({
1007                "environment": "production",
1008                "context": {}
1009            })))
1010            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1011                "value": false,
1012                "source": "default"
1013            })))
1014            .expect(1)
1015            .mount(&mock_server)
1016            .await;
1017
1018        let client = test_client(&mock_server, "test-api-key", "production").await;
1019        let result = client
1020            .evaluate_feature_flag("aboutPage", None, None)
1021            .await
1022            .expect("evaluator returns 200");
1023        assert_eq!(result.value, serde_json::json!(false));
1024        assert_eq!(result.source, "default");
1025    }
1026
1027    // --- Evaluate: explicit environment override wins over default ---
1028    #[tokio::test]
1029    async fn test_evaluate_feature_flag_honors_environment_override() {
1030        let mock_server = MockServer::start().await;
1031
1032        Mock::given(method("POST"))
1033            .and(path_matcher(
1034                "/organizations/test-org/config/feature-flags/aboutPage/evaluate",
1035            ))
1036            .and(body_json(serde_json::json!({
1037                "environment": "staging",
1038                "context": {}
1039            })))
1040            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1041                "value": true,
1042                "source": "raw"
1043            })))
1044            .expect(1)
1045            .mount(&mock_server)
1046            .await;
1047
1048        let client = test_client(&mock_server, "test-api-key", "production").await;
1049        let result = client
1050            .evaluate_feature_flag("aboutPage", None, Some("staging"))
1051            .await
1052            .expect("evaluator returns 200");
1053        assert_eq!(result.source, "raw");
1054    }
1055
1056    // --- Evaluate: flag keys with special chars are percent-encoded in path ---
1057    // Uses the same `PATH_SEGMENT_ENCODE_SET` as `get_value` — RFC 3986 unreserved
1058    // chars pass through, reserved chars (space, slash, ? etc.) are percent-encoded.
1059    #[tokio::test]
1060    async fn test_evaluate_feature_flag_url_encodes_key() {
1061        let mock_server = MockServer::start().await;
1062
1063        Mock::given(method("POST"))
1064            .and(path_matcher(
1065                "/organizations/test-org/config/feature-flags/with%20spaces%2Fand%3Fquestion/evaluate",
1066            ))
1067            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1068                "value": null,
1069                "source": "default"
1070            })))
1071            .expect(1)
1072            .mount(&mock_server)
1073            .await;
1074
1075        let client = test_client(&mock_server, "test-api-key", "production").await;
1076        let result = client
1077            .evaluate_feature_flag("with spaces/and?question", None, None)
1078            .await
1079            .expect("evaluator returns 200");
1080        assert_eq!(result.value, serde_json::Value::Null);
1081    }
1082
1083    // --- Evaluate: 404 → NotFound ---
1084    #[tokio::test]
1085    async fn test_evaluate_feature_flag_404_not_found() {
1086        let mock_server = MockServer::start().await;
1087
1088        Mock::given(method("POST"))
1089            .and(path_matcher(
1090                "/organizations/test-org/config/feature-flags/unknown/evaluate",
1091            ))
1092            .respond_with(ResponseTemplate::new(404).set_body_string("flag not defined"))
1093            .expect(1)
1094            .mount(&mock_server)
1095            .await;
1096
1097        let client = test_client(&mock_server, "test-api-key", "production").await;
1098        let err = client
1099            .evaluate_feature_flag("unknown", None, None)
1100            .await
1101            .expect_err("expected NotFound");
1102
1103        match &err {
1104            FeatureFlagEvaluationError::NotFound { key } => assert_eq!(key, "unknown"),
1105            other => panic!("expected NotFound, got {:?}", other),
1106        }
1107        assert_eq!(err.status_code(), Some(404));
1108        assert_eq!(err.key(), "unknown");
1109    }
1110
1111    // --- Evaluate: 400 → ContextError with server message ---
1112    #[tokio::test]
1113    async fn test_evaluate_feature_flag_400_context_error() {
1114        let mock_server = MockServer::start().await;
1115
1116        Mock::given(method("POST"))
1117            .and(path_matcher(
1118                "/organizations/test-org/config/feature-flags/aboutPage/evaluate",
1119            ))
1120            .respond_with(ResponseTemplate::new(400).set_body_string("context missing required key"))
1121            .expect(1)
1122            .mount(&mock_server)
1123            .await;
1124
1125        let client = test_client(&mock_server, "test-api-key", "production").await;
1126        let err = client
1127            .evaluate_feature_flag("aboutPage", None, None)
1128            .await
1129            .expect_err("expected ContextError");
1130
1131        match &err {
1132            FeatureFlagEvaluationError::ContextError { key, message } => {
1133                assert_eq!(key, "aboutPage");
1134                assert_eq!(message, "context missing required key");
1135            }
1136            other => panic!("expected ContextError, got {:?}", other),
1137        }
1138        assert_eq!(err.status_code(), Some(400));
1139    }
1140
1141    // --- Evaluate: 5xx → Evaluation ---
1142    #[tokio::test]
1143    async fn test_evaluate_feature_flag_5xx_evaluation_error() {
1144        let mock_server = MockServer::start().await;
1145
1146        Mock::given(method("POST"))
1147            .and(path_matcher(
1148                "/organizations/test-org/config/feature-flags/aboutPage/evaluate",
1149            ))
1150            .respond_with(ResponseTemplate::new(503).set_body_string("evaluator overloaded"))
1151            .expect(1)
1152            .mount(&mock_server)
1153            .await;
1154
1155        let client = test_client(&mock_server, "test-api-key", "production").await;
1156        let err = client
1157            .evaluate_feature_flag("aboutPage", None, None)
1158            .await
1159            .expect_err("expected Evaluation");
1160
1161        match &err {
1162            FeatureFlagEvaluationError::Evaluation { key, status, message } => {
1163                assert_eq!(key, "aboutPage");
1164                assert_eq!(*status, 503);
1165                assert_eq!(message, "evaluator overloaded");
1166            }
1167            other => panic!("expected Evaluation, got {:?}", other),
1168        }
1169        assert_eq!(err.status_code(), Some(503));
1170    }
1171}
1172
1173#[cfg(test)]
1174mod evaluate_response_tests {
1175    use super::*;
1176
1177    #[test]
1178    fn test_response_deserializes_full_payload() {
1179        let json = r#"{"value": true, "matchedRuleId": "r-1", "rolloutBucket": 42, "source": "rollout"}"#;
1180        let resp: EvaluateFeatureFlagResponse = serde_json::from_str(json).unwrap();
1181        assert_eq!(resp.value, serde_json::json!(true));
1182        assert_eq!(resp.matched_rule_id.as_deref(), Some("r-1"));
1183        assert_eq!(resp.rollout_bucket, Some(42));
1184        assert_eq!(resp.source, "rollout");
1185    }
1186
1187    #[test]
1188    fn test_response_deserializes_minimal_payload() {
1189        let json = r#"{"value": "x", "source": "raw"}"#;
1190        let resp: EvaluateFeatureFlagResponse = serde_json::from_str(json).unwrap();
1191        assert_eq!(resp.matched_rule_id, None);
1192        assert_eq!(resp.rollout_bucket, None);
1193    }
1194
1195    #[test]
1196    fn test_response_serializes_with_camel_case_fields() {
1197        let resp = EvaluateFeatureFlagResponse {
1198            value: serde_json::json!(true),
1199            matched_rule_id: Some("r-1".to_string()),
1200            rollout_bucket: Some(7),
1201            source: "rule".to_string(),
1202        };
1203        let s = serde_json::to_string(&resp).unwrap();
1204        assert!(s.contains("\"matchedRuleId\":\"r-1\""));
1205        assert!(s.contains("\"rolloutBucket\":7"));
1206    }
1207
1208    #[test]
1209    fn test_response_skips_none_optional_fields_on_serialize() {
1210        let resp = EvaluateFeatureFlagResponse {
1211            value: serde_json::json!(false),
1212            matched_rule_id: None,
1213            rollout_bucket: None,
1214            source: "default".to_string(),
1215        };
1216        let s = serde_json::to_string(&resp).unwrap();
1217        assert!(!s.contains("matchedRuleId"));
1218        assert!(!s.contains("rolloutBucket"));
1219    }
1220
1221    #[test]
1222    fn test_error_helpers() {
1223        let err = FeatureFlagEvaluationError::NotFound { key: "k".into() };
1224        assert_eq!(err.key(), "k");
1225        assert_eq!(err.status_code(), Some(404));
1226
1227        let err = FeatureFlagEvaluationError::ContextError {
1228            key: "k".into(),
1229            message: "bad".into(),
1230        };
1231        assert_eq!(err.status_code(), Some(400));
1232
1233        let err = FeatureFlagEvaluationError::Evaluation {
1234            key: "k".into(),
1235            status: 502,
1236            message: "bg".into(),
1237        };
1238        assert_eq!(err.status_code(), Some(502));
1239    }
1240}