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;
9use std::time::Duration;
10
11use crate::remote_config::error::{internal_error, RemoteConfigResult};
12use serde::Deserialize;
13use serde_json::{json, Map as JsonMap, Value as JsonValue};
14
15#[cfg(not(target_arch = "wasm32"))]
16use reqwest::blocking::Client;
17#[cfg(not(target_arch = "wasm32"))]
18use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE, IF_NONE_MATCH};
19#[cfg(not(target_arch = "wasm32"))]
20use reqwest::StatusCode;
21
22/// Parameters describing a fetch attempt.
23#[derive(Clone, Debug, PartialEq)]
24pub struct FetchRequest {
25    /// Maximum allowed age for cached results before a network call should be forced.
26    pub cache_max_age_millis: u64,
27    /// Timeout budget for the request.
28    pub timeout_millis: u64,
29    /// Optional entity tag to include via `If-None-Match`.
30    pub e_tag: Option<String>,
31    /// Optional custom signals payload forwarded to the backend.
32    pub custom_signals: Option<HashMap<String, JsonValue>>,
33}
34
35/// Minimal representation of the Remote Config REST response.
36#[derive(Clone, Debug, Default, PartialEq)]
37pub struct FetchResponse {
38    pub status: u16,
39    pub etag: Option<String>,
40    pub config: Option<HashMap<String, String>>,
41    pub template_version: Option<u64>,
42}
43
44/// Abstraction over the network layer used to retrieve Remote Config templates.
45pub trait RemoteConfigFetchClient: Send + Sync {
46    fn fetch(&self, request: FetchRequest) -> RemoteConfigResult<FetchResponse>;
47}
48
49/// Default stub fetch client: returns an empty template with a 200 status.
50#[derive(Default)]
51pub struct NoopFetchClient;
52
53impl RemoteConfigFetchClient for NoopFetchClient {
54    fn fetch(&self, request: FetchRequest) -> RemoteConfigResult<FetchResponse> {
55        let _ = request;
56        Ok(FetchResponse {
57            status: 200,
58            etag: None,
59            config: Some(HashMap::new()),
60            template_version: None,
61        })
62    }
63}
64
65/// Helper to create an error for unsupported transports.
66pub fn unsupported_transport(message: impl Into<String>) -> RemoteConfigResult<FetchResponse> {
67    Err(internal_error(message))
68}
69
70/// Provides the installation identifier and auth token used by the Remote Config REST API.
71pub trait InstallationsProvider: Send + Sync {
72    fn installation_id(&self) -> RemoteConfigResult<String>;
73    fn installation_token(&self) -> RemoteConfigResult<String>;
74}
75
76#[derive(Deserialize)]
77struct RestFetchResponse {
78    #[serde(default)]
79    entries: Option<HashMap<String, String>>,
80    #[serde(default)]
81    state: Option<String>,
82    #[serde(default, rename = "templateVersion")]
83    template_version: Option<u64>,
84}
85
86/// Blocking HTTP implementation for the Remote Config REST API.
87#[cfg(not(target_arch = "wasm32"))]
88pub struct HttpRemoteConfigFetchClient {
89    client: Client,
90    base_url: String,
91    project_id: String,
92    namespace: String,
93    api_key: String,
94    app_id: String,
95    sdk_version: String,
96    language_code: String,
97    installations: Arc<dyn InstallationsProvider>,
98}
99
100#[cfg(not(target_arch = "wasm32"))]
101impl HttpRemoteConfigFetchClient {
102    #[allow(clippy::too_many_arguments)]
103    pub fn new(
104        client: Client,
105        base_url: impl Into<String>,
106        project_id: impl Into<String>,
107        namespace: impl Into<String>,
108        api_key: impl Into<String>,
109        app_id: impl Into<String>,
110        sdk_version: impl Into<String>,
111        language_code: impl Into<String>,
112        installations: Arc<dyn InstallationsProvider>,
113    ) -> Self {
114        Self {
115            client,
116            base_url: base_url.into(),
117            project_id: project_id.into(),
118            namespace: namespace.into(),
119            api_key: api_key.into(),
120            app_id: app_id.into(),
121            sdk_version: sdk_version.into(),
122            language_code: language_code.into(),
123            installations,
124        }
125    }
126
127    fn build_headers(&self, e_tag: Option<&str>) -> RemoteConfigResult<HeaderMap> {
128        let mut headers = HeaderMap::new();
129        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
130        headers.insert(
131            IF_NONE_MATCH,
132            HeaderValue::from_str(e_tag.unwrap_or("*"))
133                .map_err(|err| internal_error(format!("invalid ETag: {err}")))?,
134        );
135        Ok(headers)
136    }
137
138    fn request_body(
139        &self,
140        installation_id: String,
141        installation_token: String,
142        custom_signals: Option<HashMap<String, JsonValue>>,
143    ) -> JsonValue {
144        let mut payload = json!({
145            "sdk_version": self.sdk_version,
146            "app_instance_id": installation_id,
147            "app_instance_id_token": installation_token,
148            "app_id": self.app_id,
149            "language_code": self.language_code,
150        });
151
152        if let Some(signals) = custom_signals {
153            if let Some(obj) = payload.as_object_mut() {
154                let mut map = JsonMap::with_capacity(signals.len());
155                for (key, value) in signals {
156                    map.insert(key, value);
157                }
158                obj.insert("custom_signals".to_string(), JsonValue::Object(map));
159            }
160        }
161
162        payload
163    }
164
165    fn build_url(&self) -> String {
166        format!(
167            "{}/v1/projects/{}/namespaces/{}:fetch?key={}",
168            self.base_url, self.project_id, self.namespace, self.api_key
169        )
170    }
171}
172
173#[cfg(not(target_arch = "wasm32"))]
174impl RemoteConfigFetchClient for HttpRemoteConfigFetchClient {
175    fn fetch(&self, request: FetchRequest) -> RemoteConfigResult<FetchResponse> {
176        let installation_id = self.installations.installation_id()?;
177        let installation_token = self.installations.installation_token()?;
178        let url = self.build_url();
179
180        let headers = self.build_headers(request.e_tag.as_deref())?;
181        let body = self.request_body(installation_id, installation_token, request.custom_signals);
182
183        let response = self
184            .client
185            .post(url)
186            .headers(headers)
187            .json(&body)
188            .timeout(Duration::from_millis(request.timeout_millis))
189            .send()
190            .map_err(|err| internal_error(format!("remote config fetch failed: {err}")))?;
191
192        let mut status = response.status();
193        let e_tag = response
194            .headers()
195            .get("ETag")
196            .and_then(|value| value.to_str().ok())
197            .map(|value| value.to_string());
198
199        let response_body = if status == StatusCode::OK {
200            Some(response.json::<RestFetchResponse>().map_err(|err| {
201                internal_error(format!("failed to parse Remote Config response: {err}"))
202            })?)
203        } else if status == StatusCode::NOT_MODIFIED {
204            None
205        } else {
206            return Err(internal_error(format!(
207                "fetch returned unexpected status {}",
208                status.as_u16()
209            )));
210        };
211
212        let mut config = response_body.as_ref().and_then(|body| body.entries.clone());
213        let state = response_body.as_ref().and_then(|body| body.state.clone());
214        let template_version = response_body
215            .as_ref()
216            .and_then(|body| body.template_version);
217
218        match state.as_deref() {
219            Some("INSTANCE_STATE_UNSPECIFIED") => status = StatusCode::INTERNAL_SERVER_ERROR,
220            Some("NO_CHANGE") => status = StatusCode::NOT_MODIFIED,
221            Some("NO_TEMPLATE") | Some("EMPTY_CONFIG") => {
222                config = Some(HashMap::new());
223            }
224            _ => {}
225        }
226
227        match status {
228            StatusCode::OK | StatusCode::NOT_MODIFIED => Ok(FetchResponse {
229                status: status.as_u16(),
230                etag: e_tag,
231                config,
232                template_version,
233            }),
234            other => Err(internal_error(format!(
235                "fetch returned unexpected status {}",
236                other.as_u16()
237            ))),
238        }
239    }
240}