firebase_rs_sdk/remote_config/
fetch.rs1use 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#[derive(Clone, Debug, PartialEq)]
24pub struct FetchRequest {
25 pub cache_max_age_millis: u64,
27 pub timeout_millis: u64,
29 pub e_tag: Option<String>,
31 pub custom_signals: Option<HashMap<String, JsonValue>>,
33}
34
35#[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
44pub trait RemoteConfigFetchClient: Send + Sync {
46 fn fetch(&self, request: FetchRequest) -> RemoteConfigResult<FetchResponse>;
47}
48
49#[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
65pub fn unsupported_transport(message: impl Into<String>) -> RemoteConfigResult<FetchResponse> {
67 Err(internal_error(message))
68}
69
70pub 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#[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}