firebase_rs_sdk/data_connect/
api.rs

1use 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    /// Executes a Data Connect query or mutation.
59    ///
60    /// This mirrors the async surface of the JS SDK; the current stub simply echoes the
61    /// request payload until real transports are wired in.
62    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}