Skip to main content

firebase_admin_sdk/remote_config/
mod.rs

1//! Firebase Remote Config module.
2//!
3//! This module provides functionality to read and modify the Remote Config template.
4//!
5//! # Optimistic Concurrency
6//!
7//! The `publish` method uses the ETag from the fetched configuration to ensure optimistic concurrency.
8//! If the remote configuration has changed since it was fetched, the publish operation will fail.
9
10pub mod models;
11#[cfg(test)]
12mod tests;
13
14use crate::core::middleware::AuthMiddleware;
15use crate::remote_config::models::RemoteConfig;
16use reqwest::Client;
17use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
18use reqwest_retry::policies::ExponentialBackoff;
19use reqwest_retry::RetryTransientMiddleware;
20use url::Url;
21
22/// Client for interacting with Firebase Remote Config.
23pub struct FirebaseRemoteConfig {
24    client: ClientWithMiddleware,
25    base_url: String,
26}
27
28const REMOTE_CONFIG_V1_API: &str =
29    "https://firebaseremoteconfig.googleapis.com/v1/projects/{project_id}/remoteConfig";
30
31#[derive(Debug, serde::Deserialize)]
32struct ApiError {
33    code: u16,
34    message: String,
35    status: String,
36}
37
38#[derive(Debug, serde::Deserialize)]
39struct ErrorWrapper {
40    error: ApiError,
41}
42
43/// Errors that can occur during Remote Config operations.
44#[derive(Debug, thiserror::Error)]
45pub enum Error {
46    /// The service account key provided does not contain a project ID.
47    #[error("the service account key is missing the project_id")]
48    ProjectIdMissing,
49    /// Wrapper for `reqwest_middleware::Error`.
50    #[error("an error occurred while sending the request: {0}")]
51    Request(#[from] reqwest_middleware::Error),
52    /// Wrapper for `reqwest::Error`.
53    #[error("an error occurred while sending the request: {0}")]
54    Reqwest(#[from] reqwest::Error),
55    /// Wrapper for `serde_json::Error`.
56    #[error("an error occurred while serializing/deserializing JSON: {0}")]
57    Json(#[from] serde_json::Error),
58    /// Error returned by the Remote Config API.
59    #[error("the firebase API returned an error: {code} {status}: {message}")]
60    Api {
61        code: u16,
62        message: String,
63        status: String,
64    },
65}
66
67impl FirebaseRemoteConfig {
68    /// Creates a new `FirebaseRemoteConfig` instance.
69    ///
70    /// This is typically called via `FirebaseApp::remote_config()`.
71    pub fn new(middleware: AuthMiddleware) -> Self {
72        let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3);
73
74        let client = ClientBuilder::new(Client::new())
75            .with(RetryTransientMiddleware::new_with_policy(retry_policy))
76            .with(middleware.clone())
77            .build();
78
79        let project_id = middleware.key.project_id.clone().unwrap_or_default();
80        let base_url = REMOTE_CONFIG_V1_API.replace("{project_id}", &project_id);
81
82        Self { client, base_url }
83    }
84
85    /// Creates a new `FirebaseRemoteConfig` instance with a custom client and base URL.
86    /// Internal use only, primarily for testing.
87    #[allow(dead_code)]
88    pub(crate) fn new_with_client(client: ClientWithMiddleware, base_url: String) -> Self {
89        Self { client, base_url }
90    }
91
92    async fn process_response<T: serde::de::DeserializeOwned>(
93        &self,
94        response: reqwest::Response,
95    ) -> Result<T, Error> {
96        if response.status().is_success() {
97            Ok(response.json().await?)
98        } else {
99            let error: ErrorWrapper = response.json().await?;
100            Err(Error::Api {
101                code: error.error.code,
102                message: error.error.message,
103                status: error.error.status,
104            })
105        }
106    }
107
108    async fn request<T: serde::de::DeserializeOwned>(
109        &self,
110        req: reqwest_middleware::RequestBuilder,
111    ) -> Result<(T, Option<String>), Error> {
112        let response = req.send().await?;
113        if !response.status().is_success() {
114            let error: ErrorWrapper = response.json().await?;
115            return Err(Error::Api {
116                code: error.error.code,
117                message: error.error.message,
118                status: error.error.status,
119            });
120        }
121        let etag = response
122            .headers()
123            .get("ETag")
124            .and_then(|v| v.to_str().ok())
125            .map(|s| s.to_string());
126        let body: T = response.json().await?;
127        Ok((body, etag))
128    }
129
130    /// Fetches the current active Remote Config template.
131    ///
132    /// The returned `RemoteConfig` object contains an ETag which is used for optimistic locking during updates.
133    pub async fn get(&self) -> Result<RemoteConfig, Error> {
134        let req = self.client.get(&self.base_url);
135        let (mut config, etag) = self.request::<RemoteConfig>(req).await?;
136        if let Some(e) = etag {
137            config.etag = e;
138        }
139        Ok(config)
140    }
141
142    /// Publishes a new Remote Config template.
143    ///
144    /// This method includes the `If-Match` header using the ETag present in the `config` object.  
145    /// If the ETag does not match the server's current version, the request will fail.
146    ///
147    /// # Arguments
148    ///
149    /// * `config` - The `RemoteConfig` template to publish.
150    pub async fn publish(&self, config: RemoteConfig) -> Result<RemoteConfig, Error> {
151        let req = self
152            .client
153            .put(&self.base_url)
154            .header("If-Match", config.etag.clone())
155            .json(&config);
156        let (mut config, etag) = self.request::<RemoteConfig>(req).await?;
157        if let Some(e) = etag {
158            config.etag = e;
159        }
160        Ok(config)
161    }
162
163    /// Lists previous versions of the Remote Config template.
164    ///
165    /// # Arguments
166    ///
167    /// * `options` - Optional query parameters for pagination and filtering.
168    pub async fn list_versions(
169        &self,
170        options: Option<models::ListVersionsOptions>,
171    ) -> Result<models::ListVersionsResult, Error> {
172        let url = format!("{}/versions", self.base_url);
173        let mut url_obj = Url::parse(&url).map_err(|e| Error::Api {
174            code: 0,
175            message: e.to_string(),
176            status: "INTERNAL".to_string(),
177        })?;
178
179        if let Some(opts) = options {
180            let mut query_pairs = url_obj.query_pairs_mut();
181            if let Some(size) = opts.page_size {
182                query_pairs.append_pair("pageSize", &size.to_string());
183            }
184            if let Some(token) = opts.page_token {
185                query_pairs.append_pair("pageToken", &token);
186            }
187        }
188
189        let response = self.client.get(url_obj).send().await?;
190        self.process_response(response).await
191    }
192
193    /// Rolls back the Remote Config template to a specific version.
194    ///
195    /// # Arguments
196    ///
197    /// * `version_number` - The version number to roll back to.
198    pub async fn rollback(&self, version_number: String) -> Result<RemoteConfig, Error> {
199        let url = format!("{}:rollback", self.base_url);
200        let body = models::RollbackRequest { version_number };
201
202        let req = self.client.post(url).json(&body);
203        let (mut config, etag) = self.request::<RemoteConfig>(req).await?;
204        if let Some(e) = etag {
205            config.etag = e;
206        }
207        Ok(config)
208    }
209}