firebase_admin/messaging/
client.rs1use crate::core::{Credentials, HttpClient, ProjectId, ServiceAccountKey};
4use crate::messaging::error::MessagingError;
5use crate::messaging::fcm_v1::{FcmEndpoints, IidEndpoints, MessagingOperations};
6use crate::messaging::message::{BatchResponse, Message, TopicManagementResponse};
7
8pub const MAX_BATCH_SIZE: usize = 500;
13
14pub struct MessagingClient {
20 http: HttpClient,
21 #[cfg_attr(not(feature = "live-messaging"), allow(dead_code))]
22 credentials: Credentials,
23 fcm_endpoints: FcmEndpoints,
24 iid_endpoints: IidEndpoints,
25 legacy_http_transport: bool,
26 #[cfg(feature = "live-messaging")]
27 token_provider: tokio::sync::OnceCell<crate::messaging::token_provider::TokenProvider>,
28}
29
30impl MessagingClient {
31 pub fn builder(project_id: impl Into<String>) -> MessagingClientBuilder {
33 MessagingClientBuilder::new(project_id)
34 }
35
36 pub async fn send(&self, message: &Message, dry_run: bool) -> Result<String, MessagingError> {
41 let token = self.bearer_token().await?;
42 self.operations(&token).send(message, dry_run).await
43 }
44
45 pub async fn send_each(
53 &self,
54 messages: &[Message],
55 dry_run: bool,
56 ) -> Result<BatchResponse, MessagingError> {
57 if messages.len() > MAX_BATCH_SIZE {
58 return Err(MessagingError::BatchTooLarge {
59 actual: messages.len(),
60 max: MAX_BATCH_SIZE,
61 });
62 }
63 let token = self.bearer_token().await?;
64 let ops = self.operations(&token);
65
66 let futures = messages
67 .iter()
68 .map(|message| ops.send_for_batch(message, dry_run));
69 let results = futures_util::future::join_all(futures).await;
70 Ok(BatchResponse::from_results(results))
71 }
72
73 pub async fn send_each_for_multicast(
77 &self,
78 message_template: &Message,
79 tokens: &[String],
80 dry_run: bool,
81 ) -> Result<BatchResponse, MessagingError> {
82 let messages = multicast_messages(message_template, tokens);
83 self.send_each(&messages, dry_run).await
84 }
85
86 pub async fn subscribe_to_topic(
90 &self,
91 tokens: &[String],
92 topic: &str,
93 ) -> Result<TopicManagementResponse, MessagingError> {
94 let token = self.bearer_token().await?;
95 self.operations(&token)
96 .subscribe_to_topic(tokens, topic)
97 .await
98 }
99
100 pub async fn unsubscribe_from_topic(
104 &self,
105 tokens: &[String],
106 topic: &str,
107 ) -> Result<TopicManagementResponse, MessagingError> {
108 let token = self.bearer_token().await?;
109 self.operations(&token)
110 .unsubscribe_from_topic(tokens, topic)
111 .await
112 }
113
114 pub fn legacy_http_transport_enabled(&self) -> bool {
126 self.legacy_http_transport
127 }
128
129 #[cfg(feature = "live-messaging")]
130 async fn bearer_token(&self) -> Result<String, MessagingError> {
131 let provider = self
132 .token_provider
133 .get_or_try_init(|| async {
134 match &self.credentials {
135 Credentials::ServiceAccount(key) => {
136 crate::messaging::token_provider::TokenProvider::from_service_account(key)
137 }
138 Credentials::ApplicationDefault => {
139 crate::messaging::token_provider::TokenProvider::from_application_default()
140 .await
141 }
142 Credentials::Emulator => {
143 Err(MessagingError::Core(crate::core::CoreError::Credentials(
144 "FCM has no emulator mode; configure a service account key or \
145 Application Default Credentials"
146 .to_string(),
147 )))
148 }
149 }
150 })
151 .await?;
152 provider.access_token().await
153 }
154
155 #[cfg(not(feature = "live-messaging"))]
156 async fn bearer_token(&self) -> Result<String, MessagingError> {
157 Err(MessagingError::Core(crate::core::CoreError::Credentials(
158 "sending FCM messages requires the `live-messaging` feature".to_string(),
159 )))
160 }
161
162 fn operations<'a>(&'a self, bearer_token: &'a str) -> MessagingOperations<'a> {
163 MessagingOperations::new(
164 &self.http,
165 &self.fcm_endpoints,
166 &self.iid_endpoints,
167 bearer_token,
168 )
169 }
170
171 #[cfg(test)]
176 pub(crate) fn for_testing(base_url: &str) -> Self {
177 Self {
178 http: HttpClient::default(),
179 credentials: Credentials::ServiceAccount(Box::new(ServiceAccountKey {
180 client_email: "test@test-project.iam.gserviceaccount.com".to_string(),
181 private_key: String::new(),
182 project_id: "test-project".to_string(),
183 private_key_id: "test-key-id".to_string(),
184 })),
185 fcm_endpoints: FcmEndpoints::custom(base_url),
186 iid_endpoints: IidEndpoints::custom(base_url),
187 legacy_http_transport: false,
188 #[cfg(feature = "live-messaging")]
189 token_provider: tokio::sync::OnceCell::new(),
190 }
191 }
192
193 #[cfg(test)]
197 pub(crate) async fn send_each_for_testing(
198 &self,
199 messages: &[Message],
200 dry_run: bool,
201 ) -> BatchResponse {
202 let ops = self.operations("test-bearer-token");
203 let futures = messages
204 .iter()
205 .map(|message| ops.send_for_batch(message, dry_run));
206 BatchResponse::from_results(futures_util::future::join_all(futures).await)
207 }
208}
209
210fn multicast_messages(message_template: &Message, tokens: &[String]) -> Vec<Message> {
217 tokens
218 .iter()
219 .map(|token| {
220 let mut m = message_template.clone();
221 m.target = crate::messaging::message::Target::Token(token.clone());
222 m
223 })
224 .collect()
225}
226
227pub struct MessagingClientBuilder {
229 project_id: String,
230 service_account: Option<ServiceAccountKey>,
231 #[cfg(feature = "live-messaging")]
232 use_application_default_credentials: bool,
233 legacy_http_transport: bool,
234 http_client: Option<reqwest::Client>,
235}
236
237impl MessagingClientBuilder {
238 pub fn new(project_id: impl Into<String>) -> Self {
240 Self {
241 project_id: project_id.into(),
242 service_account: None,
243 #[cfg(feature = "live-messaging")]
244 use_application_default_credentials: false,
245 legacy_http_transport: false,
246 http_client: None,
247 }
248 }
249
250 pub fn service_account_key(mut self, key: ServiceAccountKey) -> Self {
252 self.service_account = Some(key);
253 self
254 }
255
256 #[cfg(feature = "live-messaging")]
261 pub fn application_default_credentials(mut self) -> Self {
262 self.use_application_default_credentials = true;
263 self
264 }
265
266 pub fn enable_legacy_http_transport(mut self) -> Self {
272 self.legacy_http_transport = true;
273 self
274 }
275
276 pub fn http_client(mut self, client: reqwest::Client) -> Self {
278 self.http_client = Some(client);
279 self
280 }
281
282 pub fn build(self) -> Result<MessagingClient, MessagingError> {
284 let project_id = ProjectId::new(self.project_id).map_err(MessagingError::Core)?;
285
286 let credentials = if let Some(key) = self.service_account {
287 Credentials::ServiceAccount(Box::new(key))
288 } else {
289 #[cfg(feature = "live-messaging")]
290 if self.use_application_default_credentials {
291 Credentials::ApplicationDefault
292 } else {
293 return Err(MessagingError::Core(crate::core::CoreError::Credentials(
294 "no credentials configured: call service_account_key(...) or \
295 application_default_credentials()"
296 .to_string(),
297 )));
298 }
299 #[cfg(not(feature = "live-messaging"))]
300 return Err(MessagingError::Core(crate::core::CoreError::Credentials(
301 "no credentials configured: call service_account_key(...)".to_string(),
302 )));
303 };
304
305 let http = HttpClient::new(self.http_client.unwrap_or_default());
306 let fcm_endpoints = FcmEndpoints::live(project_id.as_str());
307 let iid_endpoints = IidEndpoints::live();
308
309 Ok(MessagingClient {
310 http,
311 credentials,
312 fcm_endpoints,
313 iid_endpoints,
314 legacy_http_transport: self.legacy_http_transport,
315 #[cfg(feature = "live-messaging")]
316 token_provider: tokio::sync::OnceCell::new(),
317 })
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use crate::messaging::message::SendResult;
325 use serde_json::json;
326 use std::time::Duration;
327 use wiremock::matchers::{method, path};
328 use wiremock::{Mock, MockServer, ResponseTemplate};
329
330 #[tokio::test]
336 async fn send_each_dispatches_requests_concurrently() {
337 let server = MockServer::start().await;
338 Mock::given(method("POST"))
339 .and(path("/projects/test-project/messages:send"))
340 .respond_with(
341 ResponseTemplate::new(200)
342 .set_body_json(json!({ "name": "msg-id" }))
343 .set_delay(Duration::from_millis(200)),
344 )
345 .mount(&server)
346 .await;
347
348 let client = MessagingClient::for_testing(&server.uri());
349 let messages: Vec<Message> = (0..5)
350 .map(|i| Message::to_token(format!("token-{i}")))
351 .collect();
352
353 let started = std::time::Instant::now();
354 let response = client.send_each_for_testing(&messages, false).await;
355 let elapsed = started.elapsed();
356
357 assert_eq!(response.success_count, 5);
358 assert!(
359 elapsed < Duration::from_millis(600),
360 "send_each took {elapsed:?}, which suggests requests ran sequentially \
361 (5 * 200ms delay) rather than concurrently"
362 );
363 }
364
365 #[tokio::test]
366 async fn send_each_for_multicast_targets_every_token() {
367 let server = MockServer::start().await;
368 Mock::given(method("POST"))
369 .and(path("/projects/test-project/messages:send"))
370 .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "name": "msg-id" })))
371 .mount(&server)
372 .await;
373
374 let client = MessagingClient::for_testing(&server.uri());
375 let template = Message::to_token("placeholder");
376 let tokens = vec!["token-a".to_string(), "token-b".to_string()];
377
378 let messages = multicast_messages(&template, &tokens);
379 let response = client.send_each_for_testing(&messages, false).await;
380
381 assert_eq!(response.success_count, 2);
382 assert!(response
383 .responses
384 .iter()
385 .all(|r| matches!(r, SendResult::Success { .. })));
386 }
387
388 #[test]
389 fn builder_requires_credentials() {
390 let result = MessagingClient::builder("some-project").build();
391 match result {
392 Err(MessagingError::Core(crate::core::CoreError::Credentials(_))) => {}
393 _ => panic!("expected a Credentials error"),
394 }
395 }
396
397 #[test]
398 fn builder_rejects_an_empty_project_id() {
399 let result = MessagingClient::builder("")
400 .service_account_key(ServiceAccountKey {
401 client_email: "test@test.iam.gserviceaccount.com".to_string(),
402 private_key: String::new(),
403 project_id: "test".to_string(),
404 private_key_id: "key-1".to_string(),
405 })
406 .build();
407 match result {
408 Err(MessagingError::Core(crate::core::CoreError::InvalidProjectId(_))) => {}
409 _ => panic!("expected an InvalidProjectId error"),
410 }
411 }
412
413 #[tokio::test]
414 async fn send_each_rejects_a_too_large_batch_without_panicking() {
415 let client = MessagingClient::for_testing("http://localhost:0");
416 let messages: Vec<Message> = (0..=MAX_BATCH_SIZE)
417 .map(|i| Message::to_token(format!("token-{i}")))
418 .collect();
419
420 let result = client.send_each(&messages, true).await;
421 match result {
422 Err(MessagingError::BatchTooLarge { actual, max }) => {
423 assert_eq!(actual, MAX_BATCH_SIZE + 1);
424 assert_eq!(max, MAX_BATCH_SIZE);
425 }
426 other => panic!("expected BatchTooLarge, got {other:?}"),
427 }
428 }
429
430 #[tokio::test]
431 async fn send_each_for_multicast_rejects_a_too_large_batch_without_panicking() {
432 let client = MessagingClient::for_testing("http://localhost:0");
433 let template = Message::to_token("placeholder");
434 let tokens: Vec<String> = (0..=MAX_BATCH_SIZE).map(|i| format!("token-{i}")).collect();
435
436 let result = client
437 .send_each_for_multicast(&template, &tokens, true)
438 .await;
439 assert!(matches!(result, Err(MessagingError::BatchTooLarge { .. })));
440 }
441}