firebase_rs_sdk/data_connect/
api.rs1use std::collections::BTreeMap;
2use std::collections::HashMap;
3use std::sync::{Arc, LazyLock};
4
5use serde_json::{json, Value};
6
7use crate::app;
8use crate::app::FirebaseApp;
9use crate::component::types::{
10 ComponentError, DynService, InstanceFactoryOptions, InstantiationMode,
11};
12use crate::component::{Component, ComponentType};
13use crate::data_connect::constants::DATA_CONNECT_COMPONENT_NAME;
14use crate::data_connect::error::{internal_error, invalid_argument, DataConnectResult};
15use async_lock::Mutex;
16
17#[derive(Clone, Debug)]
18pub struct DataConnectService {
19 inner: Arc<DataConnectInner>,
20}
21
22#[derive(Debug)]
23struct DataConnectInner {
24 app: FirebaseApp,
25 endpoint: Option<String>,
26}
27
28static DATA_CONNECT_CACHE: LazyLock<
29 Mutex<HashMap<(String, Option<String>), Arc<DataConnectService>>>,
30> = LazyLock::new(|| Mutex::new(HashMap::new()));
31
32#[derive(Clone, Debug, PartialEq)]
33pub struct QueryRequest {
34 pub operation: String,
35 pub variables: BTreeMap<String, Value>,
36}
37
38#[derive(Clone, Debug, PartialEq)]
39pub struct QueryResponse {
40 pub data: Value,
41}
42
43impl DataConnectService {
44 fn new(app: FirebaseApp, endpoint: Option<String>) -> Self {
45 Self {
46 inner: Arc::new(DataConnectInner { app, endpoint }),
47 }
48 }
49
50 pub fn app(&self) -> &FirebaseApp {
51 &self.inner.app
52 }
53
54 pub fn endpoint(&self) -> Option<&str> {
55 self.inner.endpoint.as_deref()
56 }
57
58 pub async fn execute(&self, request: QueryRequest) -> DataConnectResult<QueryResponse> {
63 if request.operation.trim().is_empty() {
64 return Err(invalid_argument("Operation text must not be empty"));
65 }
66 let payload = json!({
67 "operation": request.operation,
68 "variables": request.variables,
69 "endpoint": self.endpoint().unwrap_or("default"),
70 });
71 Ok(QueryResponse { data: payload })
72 }
73}
74
75static DATA_CONNECT_COMPONENT: LazyLock<()> = LazyLock::new(|| {
76 let component = Component::new(
77 DATA_CONNECT_COMPONENT_NAME,
78 Arc::new(data_connect_factory),
79 ComponentType::Public,
80 )
81 .with_instantiation_mode(InstantiationMode::Lazy)
82 .with_multiple_instances(true);
83 let _ = app::register_component(component);
84});
85
86fn data_connect_factory(
87 container: &crate::component::ComponentContainer,
88 options: InstanceFactoryOptions,
89) -> Result<DynService, ComponentError> {
90 let app = container.root_service::<FirebaseApp>().ok_or_else(|| {
91 ComponentError::InitializationFailed {
92 name: DATA_CONNECT_COMPONENT_NAME.to_string(),
93 reason: "Firebase app not attached to component container".to_string(),
94 }
95 })?;
96
97 let endpoint = options
98 .options
99 .get("endpoint")
100 .and_then(|value| value.as_str().map(|s| s.to_string()))
101 .or(options.instance_identifier.clone());
102
103 let service = DataConnectService::new((*app).clone(), endpoint);
104 Ok(Arc::new(service) as DynService)
105}
106
107fn ensure_registered() {
108 LazyLock::force(&DATA_CONNECT_COMPONENT);
109}
110
111pub fn register_data_connect_component() {
112 ensure_registered();
113}
114
115pub async fn get_data_connect_service(
116 app: Option<FirebaseApp>,
117 endpoint: Option<&str>,
118) -> DataConnectResult<Arc<DataConnectService>> {
119 ensure_registered();
120 let app = match app {
121 Some(app) => app,
122 None => crate::app::get_app(None)
123 .await
124 .map_err(|err| internal_error(err.to_string()))?,
125 };
126
127 let endpoint_string = endpoint.map(|e| e.to_string());
128 let cache_key = (app.name().to_string(), endpoint_string.clone());
129 if let Some(service) = DATA_CONNECT_CACHE.lock().await.get(&cache_key).cloned() {
130 return Ok(service);
131 }
132
133 let provider = app::get_provider(&app, DATA_CONNECT_COMPONENT_NAME);
134 if let Some(service) = match endpoint {
135 Some(id) => provider
136 .get_immediate_with_options::<DataConnectService>(Some(id), true)
137 .unwrap_or(None),
138 None => provider.get_immediate::<DataConnectService>(),
139 } {
140 DATA_CONNECT_CACHE
141 .lock()
142 .await
143 .insert(cache_key.clone(), service.clone());
144 return Ok(service);
145 }
146
147 let options = if let Some(ref endpoint) = endpoint_string {
148 json!({ "endpoint": endpoint })
149 } else {
150 Value::Null
151 };
152
153 match provider.initialize::<DataConnectService>(options, endpoint) {
154 Ok(service) => {
155 DATA_CONNECT_CACHE
156 .lock()
157 .await
158 .insert(cache_key, service.clone());
159 Ok(service)
160 }
161 Err(crate::component::types::ComponentError::InstanceUnavailable { .. }) => {
162 if let Some(service) = match endpoint {
163 Some(id) => provider
164 .get_immediate_with_options::<DataConnectService>(Some(id), true)
165 .unwrap_or(None),
166 None => provider.get_immediate::<DataConnectService>(),
167 } {
168 DATA_CONNECT_CACHE
169 .lock()
170 .await
171 .insert(cache_key, service.clone());
172 Ok(service)
173 } else {
174 let fallback = Arc::new(DataConnectService::new(
175 app.clone(),
176 endpoint_string.clone(),
177 ));
178 DATA_CONNECT_CACHE
179 .lock()
180 .await
181 .insert(cache_key, fallback.clone());
182 Ok(fallback)
183 }
184 }
185 Err(err) => Err(internal_error(err.to_string())),
186 }
187}
188
189#[cfg(all(test, not(target_arch = "wasm32")))]
190mod tests {
191 use super::*;
192 use crate::app::initialize_app;
193 use crate::app::{FirebaseAppSettings, FirebaseOptions};
194
195 fn unique_settings() -> FirebaseAppSettings {
196 use std::sync::atomic::{AtomicUsize, Ordering};
197 static COUNTER: AtomicUsize = AtomicUsize::new(0);
198 FirebaseAppSettings {
199 name: Some(format!(
200 "data-connect-{}",
201 COUNTER.fetch_add(1, Ordering::SeqCst)
202 )),
203 ..Default::default()
204 }
205 }
206
207 #[tokio::test(flavor = "current_thread")]
208 async fn execute_returns_stub_payload() {
209 let options = FirebaseOptions {
210 project_id: Some("project".into()),
211 ..Default::default()
212 };
213 let app = initialize_app(options, Some(unique_settings()))
214 .await
215 .unwrap();
216 let service = get_data_connect_service(Some(app), Some("https://example/graphql"))
217 .await
218 .expect("service");
219 let mut vars = BTreeMap::new();
220 vars.insert("id".into(), json!(123));
221 let response = service
222 .execute(QueryRequest {
223 operation: "query GetItem { item { id } }".into(),
224 variables: vars.clone(),
225 })
226 .await
227 .unwrap();
228 assert!(response
229 .data
230 .get("operation")
231 .unwrap()
232 .as_str()
233 .unwrap()
234 .contains("GetItem"));
235 assert_eq!(response.data.get("variables").unwrap(), &json!(vars));
236 }
237
238 #[tokio::test(flavor = "current_thread")]
239 async fn empty_operation_errors() {
240 let options = FirebaseOptions {
241 project_id: Some("project".into()),
242 ..Default::default()
243 };
244 let app = initialize_app(options, Some(unique_settings()))
245 .await
246 .unwrap();
247 let service = get_data_connect_service(Some(app), None).await.unwrap();
248 let err = service
249 .execute(QueryRequest {
250 operation: " ".into(),
251 variables: BTreeMap::new(),
252 })
253 .await
254 .unwrap_err();
255 assert_eq!(err.code_str(), "data-connect/invalid-argument");
256 }
257}