1use std::collections::HashMap;
8use std::sync::Arc;
9#[cfg(not(target_arch = "wasm32"))]
10use std::time::Duration;
11
12use crate::installations::Installations;
13use crate::installations::InstallationsResult;
14use crate::remote_config::error::{internal_error, RemoteConfigResult};
15use serde::Deserialize;
16use serde_json::{json, Map as JsonMap, Value as JsonValue};
17
18#[cfg(not(target_arch = "wasm32"))]
19use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE, IF_NONE_MATCH};
20#[cfg(not(target_arch = "wasm32"))]
21use reqwest::{Client, StatusCode};
22#[cfg(all(target_arch = "wasm32", feature = "wasm-web"))]
23use reqwest::{Client, StatusCode};
24
25#[derive(Clone, Debug, PartialEq)]
27pub struct FetchRequest {
28 pub cache_max_age_millis: u64,
30 pub timeout_millis: u64,
32 pub e_tag: Option<String>,
34 pub custom_signals: Option<HashMap<String, JsonValue>>,
36}
37
38#[derive(Clone, Debug, Default, PartialEq)]
40pub struct FetchResponse {
41 pub status: u16,
42 pub etag: Option<String>,
43 pub config: Option<HashMap<String, String>>,
44 pub template_version: Option<u64>,
45}
46
47#[cfg_attr(
49 all(feature = "wasm-web", target_arch = "wasm32"),
50 async_trait::async_trait(?Send)
51)]
52#[cfg_attr(
53 not(all(feature = "wasm-web", target_arch = "wasm32")),
54 async_trait::async_trait
55)]
56pub trait RemoteConfigFetchClient: Send + Sync {
57 async fn fetch(&self, request: FetchRequest) -> RemoteConfigResult<FetchResponse>;
58}
59
60#[derive(Default)]
62pub struct NoopFetchClient;
63
64#[cfg_attr(
65 all(feature = "wasm-web", target_arch = "wasm32"),
66 async_trait::async_trait(?Send)
67)]
68#[cfg_attr(
69 not(all(feature = "wasm-web", target_arch = "wasm32")),
70 async_trait::async_trait
71)]
72impl RemoteConfigFetchClient for NoopFetchClient {
73 async fn fetch(&self, request: FetchRequest) -> RemoteConfigResult<FetchResponse> {
74 let _ = request;
75 Ok(FetchResponse {
76 status: 200,
77 etag: None,
78 config: Some(HashMap::new()),
79 template_version: None,
80 })
81 }
82}
83
84fn map_installations_error<T>(result: InstallationsResult<T>) -> RemoteConfigResult<T> {
85 result.map_err(|err| internal_error(err.to_string()))
86}
87
88#[cfg_attr(
89 all(feature = "wasm-web", target_arch = "wasm32"),
90 async_trait::async_trait(?Send)
91)]
92#[cfg_attr(
93 not(all(feature = "wasm-web", target_arch = "wasm32")),
94 async_trait::async_trait
95)]
96pub trait InstallationsTokenProvider: Send + Sync {
97 async fn installation_id(&self) -> InstallationsResult<String>;
98 async fn installation_token(&self) -> InstallationsResult<String>;
99}
100
101#[cfg_attr(
102 all(feature = "wasm-web", target_arch = "wasm32"),
103 async_trait::async_trait(?Send)
104)]
105#[cfg_attr(
106 not(all(feature = "wasm-web", target_arch = "wasm32")),
107 async_trait::async_trait
108)]
109impl InstallationsTokenProvider for Installations {
110 async fn installation_id(&self) -> InstallationsResult<String> {
111 self.get_id().await
112 }
113
114 async fn installation_token(&self) -> InstallationsResult<String> {
115 Ok(self.get_token(false).await?.token)
116 }
117}
118
119#[derive(Deserialize)]
120struct RestFetchResponse {
121 #[serde(default)]
122 entries: Option<HashMap<String, String>>,
123 #[serde(default)]
124 state: Option<String>,
125 #[serde(default, rename = "templateVersion")]
126 template_version: Option<u64>,
127}
128
129#[cfg(not(target_arch = "wasm32"))]
131pub struct HttpRemoteConfigFetchClient {
132 client: Client,
133 base_url: String,
134 project_id: String,
135 namespace: String,
136 api_key: String,
137 app_id: String,
138 sdk_version: String,
139 language_code: String,
140 installations: Arc<dyn InstallationsTokenProvider>,
141}
142
143#[cfg(not(target_arch = "wasm32"))]
144impl HttpRemoteConfigFetchClient {
145 #[allow(clippy::too_many_arguments)]
146 pub fn new(
147 client: Client,
148 base_url: impl Into<String>,
149 project_id: impl Into<String>,
150 namespace: impl Into<String>,
151 api_key: impl Into<String>,
152 app_id: impl Into<String>,
153 sdk_version: impl Into<String>,
154 language_code: impl Into<String>,
155 installations: Arc<dyn InstallationsTokenProvider>,
156 ) -> Self {
157 Self {
158 client,
159 base_url: base_url.into(),
160 project_id: project_id.into(),
161 namespace: namespace.into(),
162 api_key: api_key.into(),
163 app_id: app_id.into(),
164 sdk_version: sdk_version.into(),
165 language_code: language_code.into(),
166 installations,
167 }
168 }
169
170 fn build_headers(&self, e_tag: Option<&str>) -> RemoteConfigResult<HeaderMap> {
171 let mut headers = HeaderMap::new();
172 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
173 headers.insert(
174 IF_NONE_MATCH,
175 HeaderValue::from_str(e_tag.unwrap_or("*"))
176 .map_err(|err| internal_error(format!("invalid ETag: {err}")))?,
177 );
178 Ok(headers)
179 }
180
181 fn request_body(
182 &self,
183 installation_id: String,
184 installation_token: String,
185 custom_signals: Option<HashMap<String, JsonValue>>,
186 ) -> JsonValue {
187 let mut payload = json!({
188 "sdk_version": self.sdk_version,
189 "app_instance_id": installation_id,
190 "app_instance_id_token": installation_token,
191 "app_id": self.app_id,
192 "language_code": self.language_code,
193 });
194
195 if let Some(signals) = custom_signals {
196 if let Some(obj) = payload.as_object_mut() {
197 let mut map = JsonMap::with_capacity(signals.len());
198 for (key, value) in signals {
199 map.insert(key, value);
200 }
201 obj.insert("custom_signals".to_string(), JsonValue::Object(map));
202 }
203 }
204
205 payload
206 }
207
208 fn build_url(&self) -> String {
209 format!(
210 "{}/v1/projects/{}/namespaces/{}:fetch?key={}",
211 self.base_url, self.project_id, self.namespace, self.api_key
212 )
213 }
214}
215
216#[cfg(not(target_arch = "wasm32"))]
217#[async_trait::async_trait]
218impl RemoteConfigFetchClient for HttpRemoteConfigFetchClient {
219 async fn fetch(&self, request: FetchRequest) -> RemoteConfigResult<FetchResponse> {
220 let installation_id = map_installations_error(self.installations.installation_id().await)?;
221 let installation_token =
222 map_installations_error(self.installations.installation_token().await)?;
223 let url = self.build_url();
224
225 let headers = self.build_headers(request.e_tag.as_deref())?;
226 let body = self.request_body(installation_id, installation_token, request.custom_signals);
227
228 let mut builder = self.client.post(url).headers(headers).json(&body);
229
230 builder = builder.timeout(Duration::from_millis(request.timeout_millis));
231
232 let response = builder
233 .send()
234 .await
235 .map_err(|err| internal_error(format!("remote config fetch failed: {err}")))?;
236
237 let mut status = response.status();
238 let e_tag = response
239 .headers()
240 .get("ETag")
241 .and_then(|value| value.to_str().ok())
242 .map(|value| value.to_string());
243
244 let response_body = if status == StatusCode::OK {
245 Some(response.json::<RestFetchResponse>().await.map_err(|err| {
246 internal_error(format!("failed to parse Remote Config response: {err}"))
247 })?)
248 } else if status == StatusCode::NOT_MODIFIED {
249 None
250 } else {
251 return Err(internal_error(format!(
252 "fetch returned unexpected status {}",
253 status.as_u16()
254 )));
255 };
256
257 let mut config = response_body.as_ref().and_then(|body| body.entries.clone());
258 let state = response_body.as_ref().and_then(|body| body.state.clone());
259 let template_version = response_body
260 .as_ref()
261 .and_then(|body| body.template_version);
262
263 match state.as_deref() {
264 Some("INSTANCE_STATE_UNSPECIFIED") => status = StatusCode::INTERNAL_SERVER_ERROR,
265 Some("NO_CHANGE") => status = StatusCode::NOT_MODIFIED,
266 Some("NO_TEMPLATE") | Some("EMPTY_CONFIG") => {
267 config = Some(HashMap::new());
268 }
269 _ => {}
270 }
271
272 match status {
273 StatusCode::OK | StatusCode::NOT_MODIFIED => Ok(FetchResponse {
274 status: status.as_u16(),
275 etag: e_tag,
276 config,
277 template_version,
278 }),
279 other => Err(internal_error(format!(
280 "fetch returned unexpected status {}",
281 other.as_u16()
282 ))),
283 }
284 }
285}
286
287#[cfg(all(target_arch = "wasm32", feature = "wasm-web"))]
288pub struct WasmRemoteConfigFetchClient {
289 client: Client,
290 base_url: String,
291 project_id: String,
292 namespace: String,
293 api_key: String,
294 app_id: String,
295 sdk_version: String,
296 language_code: String,
297 installations: Arc<dyn InstallationsTokenProvider>,
298}
299
300#[cfg(all(target_arch = "wasm32", feature = "wasm-web"))]
301impl WasmRemoteConfigFetchClient {
302 #[allow(clippy::too_many_arguments)]
303 pub fn new(
304 client: Client,
305 base_url: impl Into<String>,
306 project_id: impl Into<String>,
307 namespace: impl Into<String>,
308 api_key: impl Into<String>,
309 app_id: impl Into<String>,
310 sdk_version: impl Into<String>,
311 language_code: impl Into<String>,
312 installations: Arc<dyn InstallationsTokenProvider>,
313 ) -> Self {
314 Self {
315 client,
316 base_url: base_url.into(),
317 project_id: project_id.into(),
318 namespace: namespace.into(),
319 api_key: api_key.into(),
320 app_id: app_id.into(),
321 sdk_version: sdk_version.into(),
322 language_code: language_code.into(),
323 installations,
324 }
325 }
326
327 fn request_body(
328 &self,
329 installation_id: String,
330 installation_token: String,
331 custom_signals: Option<HashMap<String, JsonValue>>,
332 ) -> JsonValue {
333 let mut payload = json!({
334 "sdk_version": self.sdk_version,
335 "app_instance_id": installation_id,
336 "app_instance_id_token": installation_token,
337 "app_id": self.app_id,
338 "language_code": self.language_code,
339 });
340
341 if let Some(signals) = custom_signals {
342 if let Some(obj) = payload.as_object_mut() {
343 let mut map = JsonMap::with_capacity(signals.len());
344 for (key, value) in signals {
345 map.insert(key, value);
346 }
347 obj.insert("custom_signals".to_string(), JsonValue::Object(map));
348 }
349 }
350
351 payload
352 }
353
354 fn build_url(&self) -> String {
355 format!(
356 "{}/v1/projects/{}/namespaces/{}:fetch?key={}",
357 self.base_url, self.project_id, self.namespace, self.api_key
358 )
359 }
360}
361
362#[cfg(all(target_arch = "wasm32", feature = "wasm-web"))]
363#[async_trait::async_trait(?Send)]
364impl RemoteConfigFetchClient for WasmRemoteConfigFetchClient {
365 async fn fetch(&self, request: FetchRequest) -> RemoteConfigResult<FetchResponse> {
366 let installation_id = map_installations_error(self.installations.installation_id().await)?;
367 let installation_token =
368 map_installations_error(self.installations.installation_token().await)?;
369 let url = self.build_url();
370
371 let body = self.request_body(installation_id, installation_token, request.custom_signals);
372
373 let response = self
374 .client
375 .post(url)
376 .json(&body)
377 .send()
378 .await
379 .map_err(|err| internal_error(format!("remote config fetch failed: {err}")))?;
380
381 let mut status = response.status();
382 let e_tag = response
383 .headers()
384 .get("ETag")
385 .and_then(|value| value.to_str().ok())
386 .map(|value| value.to_string());
387
388 let response_body = if status == StatusCode::OK {
389 Some(response.json::<RestFetchResponse>().await.map_err(|err| {
390 internal_error(format!("failed to parse Remote Config response: {err}"))
391 })?)
392 } else if status == StatusCode::NOT_MODIFIED {
393 None
394 } else {
395 return Err(internal_error(format!(
396 "fetch returned unexpected status {}",
397 status.as_u16()
398 )));
399 };
400
401 let mut config = response_body.as_ref().and_then(|body| body.entries.clone());
402 let state = response_body.as_ref().and_then(|body| body.state.clone());
403 let template_version = response_body
404 .as_ref()
405 .and_then(|body| body.template_version);
406
407 match state.as_deref() {
408 Some("INSTANCE_STATE_UNSPECIFIED") => status = StatusCode::INTERNAL_SERVER_ERROR,
409 Some("NO_CHANGE") => status = StatusCode::NOT_MODIFIED,
410 Some("NO_TEMPLATE") | Some("EMPTY_CONFIG") => {
411 config = Some(HashMap::new());
412 }
413 _ => {}
414 }
415
416 match status {
417 StatusCode::OK | StatusCode::NOT_MODIFIED => Ok(FetchResponse {
418 status: status.as_u16(),
419 etag: e_tag,
420 config,
421 template_version,
422 }),
423 other => Err(internal_error(format!(
424 "fetch returned unexpected status {}",
425 other.as_u16()
426 ))),
427 }
428 }
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434 use std::collections::HashMap;
435 use std::sync::atomic::{AtomicUsize, Ordering};
436
437 #[derive(Debug)]
438 struct TestInstallations {
439 installation_id: String,
440 installation_token: String,
441 id_calls: AtomicUsize,
442 token_calls: AtomicUsize,
443 }
444
445 impl TestInstallations {
446 fn new(id: &str, token: &str) -> Self {
447 Self {
448 installation_id: id.to_string(),
449 installation_token: token.to_string(),
450 id_calls: AtomicUsize::new(0),
451 token_calls: AtomicUsize::new(0),
452 }
453 }
454
455 #[cfg(not(target_arch = "wasm32"))]
456 fn id_call_count(&self) -> usize {
457 self.id_calls.load(Ordering::SeqCst)
458 }
459
460 #[cfg(not(target_arch = "wasm32"))]
461 fn token_call_count(&self) -> usize {
462 self.token_calls.load(Ordering::SeqCst)
463 }
464 }
465
466 #[cfg_attr(
467 all(feature = "wasm-web", target_arch = "wasm32"),
468 async_trait::async_trait(?Send)
469 )]
470 #[cfg_attr(
471 not(all(feature = "wasm-web", target_arch = "wasm32")),
472 async_trait::async_trait
473 )]
474 impl InstallationsTokenProvider for TestInstallations {
475 async fn installation_id(&self) -> InstallationsResult<String> {
476 self.id_calls.fetch_add(1, Ordering::SeqCst);
477 Ok(self.installation_id.clone())
478 }
479
480 async fn installation_token(&self) -> InstallationsResult<String> {
481 self.token_calls.fetch_add(1, Ordering::SeqCst);
482 Ok(self.installation_token.clone())
483 }
484 }
485
486 #[cfg(not(target_arch = "wasm32"))]
487 mod native {
488 use super::*;
489 use httpmock::prelude::*;
490 use serde_json::json;
491 fn fetch_request() -> FetchRequest {
492 let mut signals = HashMap::new();
493 signals.insert("feature".to_string(), JsonValue::Bool(true));
494 FetchRequest {
495 cache_max_age_millis: 60_000,
496 timeout_millis: 5_000,
497 e_tag: Some("\"etag-value\"".to_string()),
498 custom_signals: Some(signals),
499 }
500 }
501
502 #[tokio::test(flavor = "current_thread")]
503 async fn http_fetch_client_returns_config() {
504 let server = MockServer::start();
505 let mock = server.mock(|when, then| {
506 when.method(POST)
507 .path("/v1/projects/test-project/namespaces/test-namespace:fetch")
508 .header("content-type", "application/json")
509 .header("if-none-match", "\"etag-value\"")
510 .json_body(json!({
511 "sdk_version": "test-sdk",
512 "app_instance_id": "test-installation",
513 "app_instance_id_token": "test-token",
514 "app_id": "test-app",
515 "language_code": "en-GB",
516 "custom_signals": { "feature": true }
517 }));
518 then.status(200)
519 .header("ETag", "\"new-etag\"")
520 .json_body(json!({
521 "entries": { "welcome": "hello" },
522 "templateVersion": 42u64
523 }));
524 });
525
526 let provider = Arc::new(TestInstallations::new("test-installation", "test-token"));
527 let client = HttpRemoteConfigFetchClient::new(
528 Client::builder().build().unwrap(),
529 server.base_url(),
530 "test-project",
531 "test-namespace",
532 "test-api-key",
533 "test-app",
534 "test-sdk",
535 "en-GB",
536 provider.clone(),
537 );
538
539 let response = client.fetch(fetch_request()).await.expect("fetch succeeds");
540 mock.assert();
541
542 assert_eq!(response.status, 200);
543 assert_eq!(response.etag.as_deref(), Some("\"new-etag\""));
544 assert_eq!(response.template_version, Some(42));
545 let config = response.config.expect("config present");
546 assert_eq!(config.get("welcome"), Some(&"hello".to_string()));
547
548 assert_eq!(provider.id_call_count(), 1);
549 assert_eq!(provider.token_call_count(), 1);
550 }
551
552 #[tokio::test(flavor = "current_thread")]
553 async fn http_fetch_client_handles_not_modified() {
554 let server = MockServer::start();
555 let mock = server.mock(|when, then| {
556 when.method(POST)
557 .path("/v1/projects/test-project/namespaces/test-namespace:fetch");
558 then.status(304);
559 });
560
561 let provider = Arc::new(TestInstallations::new("test-installation", "test-token"));
562 let client = HttpRemoteConfigFetchClient::new(
563 Client::builder().build().unwrap(),
564 server.base_url(),
565 "test-project",
566 "test-namespace",
567 "test-api-key",
568 "test-app",
569 "test-sdk",
570 "en-US",
571 provider.clone(),
572 );
573
574 let mut request = fetch_request();
575 request.custom_signals = None;
576 let response = client.fetch(request).await.expect("fetch succeeds");
577 mock.assert();
578
579 assert_eq!(response.status, 304);
580 assert!(response.config.is_none());
581 assert_eq!(provider.id_call_count(), 1);
582 assert_eq!(provider.token_call_count(), 1);
583 }
584
585 #[tokio::test(flavor = "current_thread")]
586 async fn http_fetch_client_surfaces_server_errors() {
587 let server = MockServer::start();
588 let mock = server.mock(|when, then| {
589 when.method(POST)
590 .path("/v1/projects/test-project/namespaces/test-namespace:fetch");
591 then.status(503).body("unavailable");
592 });
593
594 let provider = Arc::new(TestInstallations::new("test-installation", "test-token"));
595 let client = HttpRemoteConfigFetchClient::new(
596 Client::builder().build().unwrap(),
597 server.base_url(),
598 "test-project",
599 "test-namespace",
600 "test-api-key",
601 "test-app",
602 "test-sdk",
603 "en-US",
604 provider.clone(),
605 );
606
607 let result = client.fetch(fetch_request()).await;
608 mock.assert();
609 assert!(result.is_err());
610 assert_eq!(provider.id_call_count(), 1);
611 assert_eq!(provider.token_call_count(), 1);
612 }
613 }
614
615 #[cfg(all(target_arch = "wasm32", feature = "wasm-web"))]
616 mod wasm {
617 use super::*;
618 use serde_json::json;
619 use wasm_bindgen_test::wasm_bindgen_test;
620
621 wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
622
623 #[wasm_bindgen_test]
624 fn request_body_includes_custom_signals() {
625 let provider = Arc::new(TestInstallations::new("id", "token"));
626 let client = WasmRemoteConfigFetchClient::new(
627 Client::new(),
628 "https://example.com",
629 "test-project",
630 "test-namespace",
631 "test-api-key",
632 "test-app",
633 "test-sdk",
634 "fr-FR",
635 provider,
636 );
637
638 let mut signals = HashMap::new();
639 signals.insert("flag".to_string(), JsonValue::Bool(true));
640
641 let body = client.request_body("iid".into(), "itoken".into(), Some(signals));
642 assert_eq!(body["language_code"], json!("fr-FR"));
643 assert_eq!(body["custom_signals"].get("flag"), Some(&json!(true)));
644 }
645 }
646}