firebase_rs_sdk/remote_config/
fetch.rs

1//! Remote Config fetch client abstractions.
2//!
3//! This mirrors the TypeScript `RemoteConfigFetchClient` interface in
4//! `packages/remote-config/src/client/remote_config_fetch_client.ts`, providing a pluggable
5//! transport layer for retrieving templates from the backend.
6
7use 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/// Parameters describing a fetch attempt.
26#[derive(Clone, Debug, PartialEq)]
27pub struct FetchRequest {
28    /// Maximum allowed age for cached results before a network call should be forced.
29    pub cache_max_age_millis: u64,
30    /// Timeout budget for the request.
31    pub timeout_millis: u64,
32    /// Optional entity tag to include via `If-None-Match`.
33    pub e_tag: Option<String>,
34    /// Optional custom signals payload forwarded to the backend.
35    pub custom_signals: Option<HashMap<String, JsonValue>>,
36}
37
38/// Minimal representation of the Remote Config REST response.
39#[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/// Abstraction over the network layer used to retrieve Remote Config templates.
48#[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/// Default stub fetch client: returns an empty template with a 200 status.
61#[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/// Blocking HTTP implementation for the Remote Config REST API.
130#[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}