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