firebase_rs_sdk/functions/
api.rs

1use std::sync::{Arc, LazyLock, Mutex};
2use std::time::Duration;
3
4use crate::app;
5use crate::app::FirebaseApp;
6use crate::component::types::{
7    ComponentError, DynService, InstanceFactoryOptions, InstantiationMode,
8};
9use crate::component::{Component, ComponentType};
10use crate::functions::constants::FUNCTIONS_COMPONENT_NAME;
11use crate::functions::context::ContextProvider;
12use crate::functions::error::{internal_error, invalid_argument, FunctionsResult};
13use crate::functions::transport::{invoke_callable_async, CallableRequest};
14use serde_json::{json, Value as JsonValue};
15use url::Url;
16
17#[cfg(test)]
18use crate::functions::context::CallContext;
19
20const DEFAULT_REGION: &str = "us-central1";
21const DEFAULT_TIMEOUT_MS: u64 = 70_000;
22
23/// Client entry point for invoking HTTPS callable Cloud Functions.
24///
25/// This mirrors the JavaScript `FunctionsService` implementation in
26/// [`packages/functions/src/service.ts`](../../packages/functions/src/service.ts), exposing
27/// a strongly-typed Rust surface that aligns with the modular SDK.
28#[derive(Clone, Debug)]
29pub struct Functions {
30    inner: Arc<FunctionsInner>,
31}
32
33#[derive(Debug)]
34struct FunctionsInner {
35    app: FirebaseApp,
36    endpoint: Endpoint,
37    context: ContextProvider,
38}
39
40impl Functions {
41    fn new(app: FirebaseApp, endpoint: Endpoint) -> Self {
42        let context = ContextProvider::new(app.clone());
43        Self {
44            inner: Arc::new(FunctionsInner {
45                app,
46                endpoint,
47                context,
48            }),
49        }
50    }
51
52    pub fn app(&self) -> &FirebaseApp {
53        &self.inner.app
54    }
55
56    pub fn region(&self) -> &str {
57        self.inner.endpoint.region()
58    }
59
60    /// Returns a typed callable reference for the given Cloud Function name.
61    ///
62    /// This is the Rust equivalent of
63    /// [`httpsCallable`](https://firebase.google.com/docs/functions/callable-reference) from the
64    /// JavaScript SDK (`packages/functions/src/service.ts`).
65    ///
66    /// # Examples
67    /// ```ignore
68    /// # use firebase_rs_sdk::functions::{get_functions, register_functions_component};
69    /// # use firebase_rs_sdk::functions::error::FunctionsResult;
70    /// # use firebase_rs_sdk::app::initialize_app;
71    /// # use firebase_rs_sdk::app::{FirebaseAppSettings, FirebaseOptions};
72    /// # async fn demo() -> firebase_rs_sdk::functions::error::FunctionsResult<()> {
73    /// # register_functions_component();
74    /// # let app = initialize_app(FirebaseOptions {
75    /// #     project_id: Some("demo-project".into()),
76    /// #     ..Default::default()
77    /// # }, Some(FirebaseAppSettings::default())).await.unwrap();
78    /// # use serde_json::json;
79    /// let functions = get_functions(Some(app.clone()), None).await?;
80    /// let callable = functions
81    ///     .https_callable::<serde_json::Value, serde_json::Value>("helloWorld")?;
82    /// let response = callable
83    ///     .call_async(&json!({"text": "hi"}))
84    ///     .await?;
85    /// println!("{:?}", response);
86    /// # Ok(())
87    /// # }
88    /// # let _ = demo;
89    /// ```
90    pub fn https_callable<Request, Response>(
91        &self,
92        name: &str,
93    ) -> FunctionsResult<CallableFunction<Request, Response>>
94    where
95        Request: serde::Serialize + 'static,
96        Response: serde::de::DeserializeOwned + 'static,
97    {
98        if name.trim().is_empty() {
99            return Err(invalid_argument("Function name must not be empty"));
100        }
101        Ok(CallableFunction {
102            functions: self.clone(),
103            name: name.trim().trim_matches('/').to_string(),
104            _request: std::marker::PhantomData,
105            _response: std::marker::PhantomData,
106        })
107    }
108
109    fn callable_url(&self, name: &str) -> FunctionsResult<String> {
110        let sanitized = name.trim_start_matches('/');
111        let options = self.inner.app.options();
112        let project_id = options.project_id.ok_or_else(|| {
113            invalid_argument("FirebaseOptions.project_id is required to call Functions")
114        })?;
115        self.inner.endpoint.callable_url(&project_id, sanitized)
116    }
117
118    fn context(&self) -> &ContextProvider {
119        &self.inner.context
120    }
121
122    #[cfg(test)]
123    pub fn set_context_overrides(&self, overrides: CallContext) {
124        self.inner.context.set_overrides(overrides);
125    }
126}
127
128/// Callable Cloud Function handle that can be invoked with typed payloads.
129///
130/// The shape follows the JavaScript `HttpsCallable` returned from
131/// `httpsCallable()` in `packages/functions/src/service.ts`.
132#[derive(Clone)]
133pub struct CallableFunction<Request, Response> {
134    functions: Functions,
135    name: String,
136    _request: std::marker::PhantomData<Request>,
137    _response: std::marker::PhantomData<Response>,
138}
139
140impl<Request, Response> CallableFunction<Request, Response>
141where
142    Request: serde::Serialize,
143    Response: serde::de::DeserializeOwned,
144{
145    /// Asynchronously invokes the backend function and returns the decoded response payload.
146    ///
147    /// The request and response serialization mirrors the JavaScript SDK behaviour: payloads are
148    /// encoded as JSON objects (`{ "data": ... }`) and any server error is mapped to a
149    /// `FunctionsError` code.
150    ///
151    /// This method is available on all targets and should be awaited within the caller's async
152    /// runtime.
153    pub async fn call_async(&self, data: &Request) -> FunctionsResult<Response> {
154        let payload = serde_json::to_value(data).map_err(|err| {
155            internal_error(format!("Failed to serialize callable payload: {err}"))
156        })?;
157        let body = json!({ "data": payload });
158        let url = self.functions.callable_url(&self.name)?;
159        let mut request =
160            CallableRequest::new(url, body, Duration::from_millis(DEFAULT_TIMEOUT_MS));
161        request
162            .headers
163            .insert("Content-Type".to_string(), "application/json".to_string());
164
165        let context = self.functions.context().get_context_async(false).await;
166        if let Some(token) = context.auth_token {
167            if !token.is_empty() {
168                request
169                    .headers
170                    .insert("Authorization".to_string(), format!("Bearer {token}"));
171            }
172        }
173        if let Some(token) = context.messaging_token {
174            if !token.is_empty() {
175                request
176                    .headers
177                    .insert("Firebase-Instance-ID-Token".to_string(), token);
178            }
179        }
180        if let Some(token) = context.app_check_token {
181            if !token.is_empty() {
182                request
183                    .headers
184                    .insert("X-Firebase-AppCheck".to_string(), token);
185            }
186        }
187
188        if let Some(header) = context.app_check_heartbeat {
189            if !header.is_empty() {
190                request
191                    .headers
192                    .insert("X-Firebase-Client".to_string(), header);
193            }
194        }
195
196        let response_body = invoke_callable_async(request).await?;
197        extract_data(response_body)
198    }
199
200    pub fn name(&self) -> &str {
201        &self.name
202    }
203
204    pub fn region(&self) -> &str {
205        self.functions.region()
206    }
207}
208
209fn extract_data<Response>(body: JsonValue) -> FunctionsResult<Response>
210where
211    Response: serde::de::DeserializeOwned,
212{
213    match body {
214        JsonValue::Object(mut map) => {
215            if let Some(data_value) = map.remove("data").or_else(|| map.remove("result")) {
216                serde_json::from_value(data_value).map_err(|err| {
217                    internal_error(format!(
218                        "Failed to deserialize callable response payload: {err}"
219                    ))
220                })
221            } else {
222                Err(internal_error(
223                    "Callable response JSON is missing a data field",
224                ))
225            }
226        }
227        JsonValue::Null => Err(internal_error(
228            "Callable response did not contain a JSON payload",
229        )),
230        other => Err(internal_error(format!(
231            "Unexpected callable response shape: expected object, got {other}"
232        ))),
233    }
234}
235
236#[derive(Clone, Debug)]
237struct Endpoint {
238    region: String,
239    custom_domain: Option<String>,
240    emulator_origin: Arc<Mutex<Option<String>>>,
241}
242
243impl Endpoint {
244    fn new(identifier: Option<String>) -> Self {
245        match identifier.and_then(|value| {
246            let trimmed = value.trim().to_string();
247            if trimmed.is_empty() {
248                None
249            } else {
250                Some(trimmed)
251            }
252        }) {
253            Some(raw) => match Url::parse(&raw) {
254                Ok(url) => {
255                    let origin = url.origin().ascii_serialization();
256                    let mut normalized = origin;
257                    let path = url.path();
258                    if path != "/" {
259                        normalized.push_str(path.trim_end_matches('/'));
260                    }
261                    Self {
262                        region: DEFAULT_REGION.to_string(),
263                        custom_domain: Some(normalized),
264                        emulator_origin: Arc::new(Mutex::new(None)),
265                    }
266                }
267                Err(_) => Self {
268                    region: raw,
269                    custom_domain: None,
270                    emulator_origin: Arc::new(Mutex::new(None)),
271                },
272            },
273            None => Self::default(),
274        }
275    }
276
277    fn region(&self) -> &str {
278        &self.region
279    }
280
281    fn callable_url(&self, project_id: &str, name: &str) -> FunctionsResult<String> {
282        if let Some(origin) = self.emulator_origin.lock().unwrap().clone() {
283            return Ok(format!("{origin}/{project_id}/{}/{}", self.region, name));
284        }
285
286        if let Some(domain) = &self.custom_domain {
287            return Ok(format!("{}/{}", domain.trim_end_matches('/'), name));
288        }
289
290        Ok(format!(
291            "https://{}-{}.cloudfunctions.net/{}",
292            self.region, project_id, name
293        ))
294    }
295}
296
297impl Default for Endpoint {
298    fn default() -> Self {
299        Self {
300            region: DEFAULT_REGION.to_string(),
301            custom_domain: None,
302            emulator_origin: Arc::new(Mutex::new(None)),
303        }
304    }
305}
306
307static FUNCTIONS_COMPONENT: LazyLock<()> = LazyLock::new(|| {
308    let component = Component::new(
309        FUNCTIONS_COMPONENT_NAME,
310        Arc::new(functions_factory),
311        ComponentType::Public,
312    )
313    .with_instantiation_mode(InstantiationMode::Lazy)
314    .with_multiple_instances(true);
315    let _ = app::register_component(component);
316});
317
318fn functions_factory(
319    container: &crate::component::ComponentContainer,
320    options: InstanceFactoryOptions,
321) -> Result<DynService, ComponentError> {
322    let app = container.root_service::<FirebaseApp>().ok_or_else(|| {
323        ComponentError::InitializationFailed {
324            name: FUNCTIONS_COMPONENT_NAME.to_string(),
325            reason: "Firebase app not attached to component container".to_string(),
326        }
327    })?;
328
329    let endpoint = Endpoint::new(options.instance_identifier.clone());
330    let functions = Functions::new((*app).clone(), endpoint);
331    Ok(Arc::new(functions) as DynService)
332}
333
334fn ensure_registered() {
335    LazyLock::force(&FUNCTIONS_COMPONENT);
336}
337
338/// Registers the Functions component with the global app container.
339///
340/// Equivalent to the JavaScript bootstrap performed in
341/// `packages/functions/src/register.ts`. Call this before resolving the service manually.
342pub fn register_functions_component() {
343    ensure_registered();
344}
345
346/// Fetches (or lazily creates) a `Functions` client for the given Firebase app.
347///
348/// Mirrors the modular helper exported from `packages/functions/src/api.ts`.
349/// Passing `region_or_domain` allows selecting a different region or custom domain just like the
350/// `app.functions('europe-west1')` overload in the JavaScript SDK.
351pub async fn get_functions(
352    app: Option<FirebaseApp>,
353    region_or_domain: Option<&str>,
354) -> FunctionsResult<Arc<Functions>> {
355    ensure_registered();
356    let app = match app {
357        Some(app) => app,
358        None => crate::app::get_app(None)
359            .await
360            .map_err(|err| internal_error(err.to_string()))?,
361    };
362
363    let provider = app::get_provider(&app, FUNCTIONS_COMPONENT_NAME);
364    if let Some(identifier) = region_or_domain {
365        provider
366            .initialize::<Functions>(serde_json::Value::Null, Some(identifier))
367            .map_err(|err| internal_error(err.to_string()))
368    } else {
369        provider
370            .get_immediate::<Functions>()
371            .ok_or_else(|| internal_error("Functions component not available"))
372    }
373}
374
375#[cfg(all(test, not(target_arch = "wasm32")))]
376mod tests {
377    use super::*;
378    use crate::app::initialize_app;
379    use crate::app::{FirebaseAppSettings, FirebaseOptions};
380    use httpmock::Method::POST;
381    use httpmock::MockServer;
382    use std::panic;
383
384    fn unique_settings() -> FirebaseAppSettings {
385        use std::sync::atomic::{AtomicUsize, Ordering};
386        static COUNTER: AtomicUsize = AtomicUsize::new(0);
387        FirebaseAppSettings {
388            name: Some(format!(
389                "functions-{}",
390                COUNTER.fetch_add(1, Ordering::SeqCst)
391            )),
392            ..Default::default()
393        }
394    }
395
396    #[tokio::test(flavor = "current_thread")]
397    async fn https_callable_invokes_backend() {
398        let server = match panic::catch_unwind(|| MockServer::start()) {
399            Ok(server) => server,
400            Err(_) => {
401                eprintln!(
402                    "Skipping https_callable_invokes_backend: unable to bind mock server in this environment"
403                );
404                return;
405            }
406        };
407        let mock = server.mock(|when, then| {
408            when.method(POST)
409                .path("/callable/hello")
410                .json_body(json!({ "data": { "message": "ping" } }));
411            then.status(200)
412                .json_body(json!({ "data": { "message": "pong" } }));
413        });
414
415        let options = FirebaseOptions {
416            project_id: Some("demo-project".into()),
417            ..Default::default()
418        };
419        let app = initialize_app(options, Some(unique_settings()))
420            .await
421            .unwrap();
422        let functions = get_functions(Some(app), Some(&server.url("/callable")))
423            .await
424            .unwrap();
425        let callable = functions
426            .https_callable::<serde_json::Value, serde_json::Value>("hello")
427            .unwrap();
428
429        let payload = json!({ "message": "ping" });
430        let response = callable.call_async(&payload).await.unwrap();
431
432        assert_eq!(response, json!({ "message": "pong" }));
433        mock.assert();
434    }
435
436    #[tokio::test(flavor = "current_thread")]
437    async fn https_callable_includes_context_headers() {
438        let server = match panic::catch_unwind(|| MockServer::start()) {
439            Ok(server) => server,
440            Err(_) => {
441                eprintln!(
442                    "Skipping https_callable_includes_context_headers: unable to bind mock server"
443                );
444                return;
445            }
446        };
447
448        let mock = server.mock(|when, then| {
449            when.method(POST)
450                .path("/callable/secureCall")
451                .header("authorization", "Bearer auth-token")
452                .header("firebase-instance-id-token", "iid-token")
453                .header("x-firebase-appcheck", "app-check-token")
454                .json_body(json!({ "data": { "ping": true } }));
455            then.status(200)
456                .json_body(json!({ "data": { "ok": true } }));
457        });
458
459        let options = FirebaseOptions {
460            project_id: Some("demo-project".into()),
461            ..Default::default()
462        };
463        let app = initialize_app(options, Some(unique_settings()))
464            .await
465            .unwrap();
466        let functions = get_functions(Some(app), Some(&server.url("/callable")))
467            .await
468            .unwrap();
469        functions.set_context_overrides(CallContext {
470            auth_token: Some("auth-token".into()),
471            messaging_token: Some("iid-token".into()),
472            app_check_token: Some("app-check-token".into()),
473            app_check_heartbeat: None,
474        });
475
476        let callable = functions
477            .https_callable::<serde_json::Value, serde_json::Value>("secureCall")
478            .unwrap();
479
480        let payload = json!({ "ping": true });
481        let response = callable.call_async(&payload).await.unwrap();
482
483        assert_eq!(response, json!({ "ok": true }));
484        mock.assert();
485    }
486}