firebase_rs_sdk/functions/
api.rs1use 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#[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 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#[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 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
338pub fn register_functions_component() {
343 ensure_registered();
344}
345
346pub 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}