firebase_rs_sdk/data_connect/
api.rs

1use 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}