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