1use 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
34const 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
56pub 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#[derive(Debug, Error)]
78pub enum ConfigClientError {
79 #[error(transparent)]
81 Request(#[from] reqwest::Error),
82 #[error(transparent)]
84 TokenProvider(#[from] TokenProviderError),
85 #[error("config request failed: HTTP {status} {body}")]
88 HttpStatus { status: u16, body: String },
89}
90
91impl ConfigClientError {
92 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
121pub struct EvaluateFeatureFlagResponse {
122 pub value: serde_json::Value,
124 #[serde(rename = "matchedRuleId", skip_serializing_if = "Option::is_none")]
126 pub matched_rule_id: Option<String>,
127 #[serde(rename = "rolloutBucket", skip_serializing_if = "Option::is_none")]
129 pub rollout_bucket: Option<u32>,
130 pub source: String,
133}
134
135#[derive(Debug, Error)]
141pub enum FeatureFlagEvaluationError {
142 #[error("Feature flag \"{key}\" evaluation failed: HTTP 404 — flag not defined in schema")]
144 NotFound { key: String },
145 #[error("Feature flag \"{key}\" evaluation failed: HTTP 400 — {message}")]
147 ContextError { key: String, message: String },
148 #[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 #[error("Feature flag \"{key}\" evaluation failed: {source}")]
153 Request {
154 key: String,
155 #[source]
156 source: reqwest::Error,
157 },
158}
159
160impl FeatureFlagEvaluationError {
161 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 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 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 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 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 pub fn set_cache_ttl(&mut self, ttl: Option<Duration>) {
240 self.cache_ttl = ttl;
241 }
242
243 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn invalidate_cache(&mut self) {
528 self.cache.clear();
529 }
530
531 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 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 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 #[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 #[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 #[tokio::test]
765 async fn test_auth_header_verification() {
766 let mock_server = MockServer::start().await;
767
768 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 #[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) .mount(&mock_server)
794 .await;
795
796 let mut client = test_client(&mock_server, "test-api-key", "production").await;
797
798 let value1 = client.get_value("CACHE_KEY", None).await.unwrap();
800 assert_eq!(value1, serde_json::json!("cached-value"));
801
802 let value2 = client.get_value("CACHE_KEY", None).await.unwrap();
804 assert_eq!(value2, serde_json::json!("cached-value"));
805 }
806
807 #[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) .mount(&mock_server)
819 .await;
820
821 let mut client = test_client(&mock_server, "test-api-key", "production").await;
822 client.set_cache_ttl(Some(Duration::from_millis(1)));
824
825 let value1 = client.get_value("TTL_KEY", None).await.unwrap();
827 assert_eq!(value1, serde_json::json!("fresh-value"));
828
829 tokio::time::sleep(Duration::from_millis(50)).await;
831
832 let value2 = client.get_value("TTL_KEY", None).await.unwrap();
834 assert_eq!(value2, serde_json::json!("fresh-value"));
835 }
836
837 #[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) .mount(&mock_server)
849 .await;
850
851 let mut client = test_client(&mock_server, "test-api-key", "production").await;
852
853 let value1 = client.get_value("INVAL_KEY", None).await.unwrap();
855 assert_eq!(value1, serde_json::json!("refetched"));
856
857 client.invalidate_cache();
859
860 let value2 = client.get_value("INVAL_KEY", None).await.unwrap();
862 assert_eq!(value2, serde_json::json!("refetched"));
863 }
864
865 #[tokio::test]
867 async fn test_error_handling_401_unauthorized() {
868 let mock_server = MockServer::start().await;
869
870 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 #[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 #[tokio::test]
909 async fn test_per_environment_caching() {
910 let mock_server = MockServer::start().await;
911
912 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::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 let prod_value = client.get_value("SHARED_KEY", None).await.unwrap();
936 assert_eq!(prod_value, serde_json::json!("prod-value"));
937
938 let staging_value = client.get_value("SHARED_KEY", Some("staging")).await.unwrap();
940 assert_eq!(staging_value, serde_json::json!("staging-value"));
941
942 let prod_cached = client.get_value("SHARED_KEY", None).await.unwrap();
944 assert_eq!(prod_cached, serde_json::json!("prod-value"));
945
946 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 use wiremock::matchers::{body_json, path as path_matcher};
956
957 #[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 #[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 #[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 #[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 #[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 #[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 #[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}