1use std::collections::HashMap;
2use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
3use std::sync::{Arc, RwLock};
4
5use serde_json::Value;
6
7use crate::cache::remote_config::RemoteConfigCacheRepo;
8use crate::cache::CacheStore;
9use crate::error::{ErrorKind, RovenueError, RovenueResult};
10use crate::identity::IdentityManager;
11use crate::observer::{ChangeEvent, ObserverBus};
12use crate::time::{Clock, SystemClock};
13use crate::transport::api::ApiEnvelope;
14use crate::transport::http_client::HttpClient;
15use crate::transport::types::HttpRequest;
16
17use super::types::{ConfigResponse, ExperimentAssignment};
18
19const REMOTE_CONFIG_RESOURCE: &str = "remote_config";
21
22#[derive(Default)]
23struct State {
24 flags: serde_json::Map<String, Value>,
25 experiments: HashMap<String, ExperimentAssignment>,
26}
27
28struct Inner {
29 http: Arc<HttpClient>,
30 store: Arc<CacheStore>,
31 identity: Arc<IdentityManager>,
32 clock: Arc<dyn Clock>,
33 bus: Option<Arc<ObserverBus>>,
34 state: RwLock<State>,
35 last_refresh_ms: AtomicU64,
36 refreshing: AtomicBool,
37 hydrated: AtomicBool,
38}
39
40pub struct RemoteConfigReader {
46 inner: Arc<Inner>,
47}
48
49impl RemoteConfigReader {
50 pub fn new(
51 http: Arc<HttpClient>,
52 store: Arc<CacheStore>,
53 identity: Arc<IdentityManager>,
54 ) -> Self {
55 Self {
56 inner: Arc::new(Inner {
57 http,
58 store,
59 identity,
60 clock: Arc::new(SystemClock),
61 bus: None,
62 state: RwLock::new(State::default()),
63 last_refresh_ms: AtomicU64::new(0),
64 refreshing: AtomicBool::new(false),
65 hydrated: AtomicBool::new(false),
66 }),
67 }
68 }
69
70 pub fn with_clock(self, clock: Arc<dyn Clock>) -> Self {
71 let mut inner = Arc::try_unwrap(self.inner).unwrap_or_else(|arc| {
73 Inner {
75 http: Arc::clone(&arc.http),
76 store: Arc::clone(&arc.store),
77 identity: Arc::clone(&arc.identity),
78 clock: Arc::clone(&arc.clock),
79 bus: arc.bus.clone(),
80 state: RwLock::new(State::default()),
81 last_refresh_ms: AtomicU64::new(0),
82 refreshing: AtomicBool::new(false),
83 hydrated: AtomicBool::new(false),
84 }
85 });
86 inner.clock = clock;
87 Self {
88 inner: Arc::new(inner),
89 }
90 }
91
92 pub fn with_observer_bus(self, bus: Arc<ObserverBus>) -> Self {
93 let mut inner = Arc::try_unwrap(self.inner).unwrap_or_else(|arc| Inner {
94 http: Arc::clone(&arc.http),
95 store: Arc::clone(&arc.store),
96 identity: Arc::clone(&arc.identity),
97 clock: Arc::clone(&arc.clock),
98 bus: arc.bus.clone(),
99 state: RwLock::new(State::default()),
100 last_refresh_ms: AtomicU64::new(0),
101 refreshing: AtomicBool::new(false),
102 hydrated: AtomicBool::new(false),
103 });
104 inner.bus = Some(bus);
105 Self {
106 inner: Arc::new(inner),
107 }
108 }
109
110 pub fn refresh(&self) -> RovenueResult<()> {
112 self.inner.refresh()
113 }
114
115 pub fn maybe_refresh_async(&self, staleness_ms: u64) {
119 let now = self.inner.clock.now_unix_ms();
120 let last = self.inner.last_refresh_ms.load(Ordering::SeqCst);
121 if last != 0 && now.saturating_sub(last) < staleness_ms {
122 return;
123 }
124 if self.inner.refreshing.swap(true, Ordering::SeqCst) {
126 return;
127 }
128 let inner = Arc::clone(&self.inner);
129 std::thread::spawn(move || {
130 let _ = inner.refresh();
131 inner.refreshing.store(false, Ordering::SeqCst);
132 });
133 }
134
135 fn flag_value(&self, key: &str) -> Option<Value> {
136 self.inner.ensure_hydrated();
137 let guard = self.inner.state.read().ok()?;
138 guard.flags.get(key).cloned()
139 }
140
141 pub fn bool(&self, key: &str, fallback: bool) -> bool {
142 match self.flag_value(key) {
143 Some(Value::Bool(b)) => b,
144 _ => fallback,
145 }
146 }
147
148 pub fn string(&self, key: &str, fallback: String) -> String {
149 match self.flag_value(key) {
150 Some(Value::String(s)) => s,
151 _ => fallback,
152 }
153 }
154
155 pub fn int(&self, key: &str, fallback: i64) -> i64 {
156 match self.flag_value(key) {
157 Some(Value::Number(n)) => n.as_i64().unwrap_or(fallback),
158 _ => fallback,
159 }
160 }
161
162 pub fn double(&self, key: &str, fallback: f64) -> f64 {
163 match self.flag_value(key) {
164 Some(Value::Number(n)) => n.as_f64().unwrap_or(fallback),
165 _ => fallback,
166 }
167 }
168
169 pub fn json(&self, key: &str) -> Option<String> {
172 self.flag_value(key).map(|v| v.to_string())
173 }
174
175 pub fn keys(&self) -> Vec<String> {
176 self.inner.ensure_hydrated();
177 let guard = match self.inner.state.read() {
178 Ok(g) => g,
179 Err(_) => return Vec::new(),
180 };
181 guard.flags.keys().cloned().collect()
182 }
183
184 pub fn experiment(&self, key: &str) -> Option<ExperimentAssignment> {
185 self.inner.ensure_hydrated();
186 let guard = self.inner.state.read().ok()?;
187 guard.experiments.get(key).cloned()
188 }
189
190 pub fn experiments_all(&self) -> Vec<ExperimentAssignment> {
191 self.inner.ensure_hydrated();
192 let guard = match self.inner.state.read() {
193 Ok(g) => g,
194 Err(_) => return Vec::new(),
195 };
196 guard.experiments.values().cloned().collect()
197 }
198
199 pub fn all_json(&self) -> String {
204 const EMPTY: &str = r#"{"flags":{},"experiments":{}}"#;
205 self.inner.ensure_hydrated();
206 let guard = match self.inner.state.read() {
207 Ok(g) => g,
208 Err(_) => return EMPTY.to_string(),
209 };
210 let experiments: serde_json::Map<String, Value> = guard
211 .experiments
212 .iter()
213 .map(|(k, e)| {
214 (
215 k.clone(),
216 serde_json::json!({
217 "experimentId": e.experiment_id,
218 "key": e.key,
219 "variantId": e.variant_id,
220 "variantName": e.variant_name,
221 "value": serde_json::from_str::<Value>(&e.value_json)
222 .unwrap_or(Value::Null),
223 }),
224 )
225 })
226 .collect();
227 serde_json::json!({
228 "flags": Value::Object(guard.flags.clone()),
229 "experiments": Value::Object(experiments),
230 })
231 .to_string()
232 }
233}
234
235impl Inner {
236 fn ensure_hydrated(&self) {
239 if self.hydrated.swap(true, Ordering::SeqCst) {
240 return;
241 }
242 if let Ok(Some(raw)) = RemoteConfigCacheRepo::new(&self.store).get(REMOTE_CONFIG_RESOURCE) {
243 if let Ok(parsed) = serde_json::from_str::<ConfigResponse>(&raw) {
244 if let Ok(mut guard) = self.state.write() {
245 *guard = build_state(parsed);
246 }
247 }
248 }
249 }
250
251 fn refresh(&self) -> RovenueResult<()> {
252 let wire_id = self.identity.rovenue_id();
256 let req = HttpRequest::new("/v1/config").subscriber_id(&wire_id);
257 match self.http.get_json::<ApiEnvelope<ConfigResponse>>(req) {
258 Ok(resp) => {
259 let body = resp.body.ok_or(RovenueError::Internal())?;
260 if let Ok(raw) = serde_json::to_string(&body.data) {
263 let _ = RemoteConfigCacheRepo::new(&self.store).put(
264 REMOTE_CONFIG_RESOURCE,
265 &raw,
266 self.clock.now_unix_ms(),
267 );
268 }
269 {
270 let mut guard = self.state.write().map_err(|_| RovenueError::Internal())?;
271 *guard = build_state(body.data);
272 }
273 self.hydrated.store(true, Ordering::SeqCst);
274 self.last_refresh_ms
275 .store(self.clock.now_unix_ms(), Ordering::SeqCst);
276 if let Some(bus) = &self.bus {
277 bus.emit(ChangeEvent::RemoteConfigChanged);
278 }
279 Ok(())
280 }
281 Err(e) if matches!(e.kind, ErrorKind::NetworkUnavailable | ErrorKind::Timeout) => {
282 self.ensure_hydrated();
286 Err(e)
287 }
288 Err(e) => Err(e),
289 }
290 }
291}
292
293fn build_state(resp: ConfigResponse) -> State {
294 let experiments = resp
295 .experiments
296 .into_iter()
297 .map(|(k, w)| {
298 (
299 k,
300 ExperimentAssignment {
301 experiment_id: w.experiment_id,
302 key: w.key,
303 variant_id: w.variant_id,
304 variant_name: w.variant_name,
305 value_json: w.value.to_string(),
306 },
307 )
308 })
309 .collect();
310 State {
311 flags: resp.flags,
312 experiments,
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 const BODY: &str = r#"{"data":{
321 "flags":{
322 "new_funnel": true,
323 "max_items": 5,
324 "ratio": 1.5,
325 "welcome_text": "hi",
326 "theme": {"color":"blue"}
327 },
328 "experiments":{
329 "checkout_test":{
330 "experimentId":"exp_1",
331 "key":"checkout_test",
332 "type":"FEATURE",
333 "variantId":"var_b",
334 "variantName":"Treatment",
335 "value":{"layout":"compact"}
336 }
337 }
338 }}"#;
339
340 fn make_reader(http: Arc<HttpClient>) -> (RemoteConfigReader, Arc<CacheStore>) {
341 let store = Arc::new(CacheStore::open_in_memory().unwrap());
342 let bus = Arc::new(ObserverBus::default());
343 let clock: Arc<dyn Clock> = Arc::new(SystemClock);
344 let identity = Arc::new(IdentityManager::new(
345 Arc::clone(&store),
346 Arc::clone(&bus),
347 Arc::clone(&clock),
348 ));
349 let reader =
350 RemoteConfigReader::new(http, Arc::clone(&store), identity).with_observer_bus(bus);
351 (reader, store)
352 }
353
354 #[test]
355 #[serial_test::serial]
356 fn refresh_parses_flags_and_experiments_with_typed_getters() {
357 let mut server = mockito::Server::new();
358 let _m = server
359 .mock("GET", "/v1/config")
360 .with_status(200)
361 .with_header("content-type", "application/json")
362 .with_body(BODY)
363 .create();
364
365 let http = Arc::new(HttpClient::new(server.url(), "pk_test".into()));
366 let (reader, _store) = make_reader(http);
367
368 reader.refresh().expect("refresh ok");
369
370 assert!(reader.bool("new_funnel", false));
371 assert_eq!(reader.int("max_items", 0), 5);
372 assert_eq!(reader.double("ratio", 0.0), 1.5);
373 assert_eq!(reader.string("welcome_text", "x".into()), "hi");
374 assert_eq!(reader.json("theme").as_deref(), Some(r#"{"color":"blue"}"#));
375
376 assert!(reader.bool("missing", true));
378 assert_eq!(reader.int("welcome_text", -1), -1);
379
380 let exp = reader.experiment("checkout_test").expect("assignment");
381 assert_eq!(exp.experiment_id, "exp_1");
382 assert_eq!(exp.variant_name, "Treatment");
383 assert_eq!(exp.value_json, r#"{"layout":"compact"}"#);
384 assert_eq!(reader.experiments_all().len(), 1);
385 assert!(reader.experiment("nope").is_none());
386
387 let all: serde_json::Value = serde_json::from_str(&reader.all_json()).unwrap();
389 assert_eq!(all["flags"]["new_funnel"], serde_json::json!(true));
390 assert_eq!(all["flags"]["max_items"], serde_json::json!(5));
391 assert_eq!(
392 all["experiments"]["checkout_test"]["variantName"],
393 serde_json::json!("Treatment")
394 );
395 assert_eq!(
396 all["experiments"]["checkout_test"]["value"]["layout"],
397 serde_json::json!("compact")
398 );
399 }
400
401 #[test]
402 #[serial_test::serial]
403 fn sends_env_and_subscriber_headers() {
404 let mut server = mockito::Server::new();
405 let m = server
406 .mock("GET", "/v1/config")
407 .match_header("x-rovenue-env", "staging")
408 .match_header("x-rovenue-user-id", mockito::Matcher::Any)
409 .with_status(200)
410 .with_header("content-type", "application/json")
411 .with_body(r#"{"data":{"flags":{},"experiments":{}}}"#)
412 .create();
413
414 let http = Arc::new(
415 HttpClient::new(server.url(), "pk_test".into())
416 .with_environment(Some("staging".into())),
417 );
418 let (reader, _store) = make_reader(http);
419
420 reader.refresh().expect("refresh ok");
421 m.assert();
422 }
423
424 #[test]
425 #[serial_test::serial]
426 fn serves_cached_payload_offline() {
427 let mut server = mockito::Server::new();
429 let _m = server
430 .mock("GET", "/v1/config")
431 .with_status(200)
432 .with_header("content-type", "application/json")
433 .with_body(BODY)
434 .create();
435
436 let store = Arc::new(CacheStore::open_in_memory().unwrap());
437 let bus = Arc::new(ObserverBus::default());
438 let clock: Arc<dyn Clock> = Arc::new(SystemClock);
439 let identity = Arc::new(IdentityManager::new(
440 Arc::clone(&store),
441 Arc::clone(&bus),
442 Arc::clone(&clock),
443 ));
444 let http = Arc::new(HttpClient::new(server.url(), "pk_test".into()));
445 let reader = RemoteConfigReader::new(http, Arc::clone(&store), Arc::clone(&identity));
446 reader.refresh().expect("first refresh ok");
447
448 let dead_http = Arc::new(HttpClient::new(
451 "http://127.0.0.1:1".into(),
452 "pk_test".into(),
453 ));
454 let cold = RemoteConfigReader::new(dead_http, Arc::clone(&store), identity);
455 assert!(cold.bool("new_funnel", false));
456 assert_eq!(cold.int("max_items", 0), 5);
457 }
458}