Skip to main content

firebase_admin/messaging/
client.rs

1//! The `MessagingClient` entry point and its builder.
2
3use 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
8/// Maximum number of messages accepted by a single [`MessagingClient::send_each`]
9/// or [`MessagingClient::send_each_for_multicast`] call, matching the limit
10/// documented for `sendEach`/`sendEachForMulticast` in the official Admin
11/// SDKs.
12pub const MAX_BATCH_SIZE: usize = 500;
13
14/// Firebase Cloud Messaging client.
15///
16/// Unlike [`crate::auth::AuthClient`], there is no unauthenticated emulator
17/// mode: every FCM v1 and Instance ID call requires a live OAuth2 bearer
18/// token. Build one with [`MessagingClientBuilder`].
19pub 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    /// Starts building a new client for the given Firebase project.
32    pub fn builder(project_id: impl Into<String>) -> MessagingClientBuilder {
33        MessagingClientBuilder::new(project_id)
34    }
35
36    /// Sends a single message, returning FCM's assigned message id.
37    ///
38    /// When `dry_run` is `true`, the message is validated but not actually
39    /// delivered (FCM v1's `validate_only`).
40    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    /// Sends up to [`MAX_BATCH_SIZE`] messages, returning a per-message
46    /// success/failure result. Each message is sent as its own HTTP
47    /// request, concurrently — mirroring the official Admin SDKs'
48    /// `sendEach`/`sendEachForMulticast`, which dispatch every request
49    /// before awaiting any of them (`Promise.allSettled`) rather than
50    /// serializing round-trips. A failure sending one message does not
51    /// prevent the others from being sent.
52    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    /// Sends one message to up to [`MAX_BATCH_SIZE`] device registration
74    /// tokens, individually. Equivalent to building one [`Message`] per
75    /// token from a shared template and calling [`Self::send_each`].
76    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    /// Subscribes device registration tokens to a topic.
87    ///
88    /// A single call can partially fail: see [`TopicManagementResponse`].
89    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    /// Unsubscribes device registration tokens from a topic.
101    ///
102    /// A single call can partially fail: see [`TopicManagementResponse`].
103    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    /// Whether [`Self::send_each`]/[`Self::send_each_for_multicast`] were
115    /// configured (via [`MessagingClientBuilder::enable_legacy_http_transport`])
116    /// to send each message over its own HTTP/1.1 request instead of
117    /// multiplexing over HTTP/2.
118    ///
119    /// This crate's HTTP client (`reqwest` over `hyper`) negotiates HTTP/2
120    /// automatically when the server supports it and otherwise falls back to
121    /// HTTP/1.1, so this flag is a no-op today; it exists so callers
122    /// migrating from the official Admin SDKs (where this setting works
123    /// around a legacy Node.js HTTP/2 bug) have an equivalent method to call
124    /// without their code failing to compile.
125    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    /// Builds a client pointed at a mock HTTP server for both the FCM v1 and
172    /// Instance ID endpoints, with a fixed dummy bearer token so tests can
173    /// exercise `send`/`send_each`/topic management against a `wiremock`
174    /// server without resolving real OAuth2 credentials.
175    #[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    /// Runs [`Self::send_each`] against the fixed test bearer token from
194    /// [`Self::for_testing`], bypassing [`Self::bearer_token`]'s OAuth2
195    /// resolution.
196    #[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
210/// Builds one [`Message`] per token from a shared template, for
211/// [`MessagingClient::send_each_for_multicast`].
212///
213/// Batch-size validation is left to the [`MessagingClient::send_each`] call
214/// this feeds into, so a too-large `tokens` slice surfaces as a recoverable
215/// [`MessagingError::BatchTooLarge`] rather than being checked twice.
216fn 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
227/// Builds a [`MessagingClient`].
228pub 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    /// Starts building a client for the given Firebase project id.
239    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    /// Authenticates using an explicit service account key.
251    pub fn service_account_key(mut self, key: ServiceAccountKey) -> Self {
252        self.service_account = Some(key);
253        self
254    }
255
256    /// Authenticates using Application Default Credentials, resolved on
257    /// first use: the `GOOGLE_APPLICATION_CREDENTIALS` environment
258    /// variable, gcloud user credentials, or the GCE/Cloud Run metadata
259    /// server, in that order (see [`gcp_auth::provider`]).
260    #[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    /// Opts [`MessagingClient::send_each`]/[`MessagingClient::send_each_for_multicast`]
267    /// into HTTP/1.1 transport instead of multiplexing over HTTP/2 — mirrors
268    /// `Messaging.enableLegacyHttpTransport()` in the official Admin SDKs.
269    /// See [`MessagingClient::legacy_http_transport_enabled`] for why this is
270    /// a no-op on this crate's transport.
271    pub fn enable_legacy_http_transport(mut self) -> Self {
272        self.legacy_http_transport = true;
273        self
274    }
275
276    /// Supplies a custom [`reqwest::Client`], e.g. for testing.
277    pub fn http_client(mut self, client: reqwest::Client) -> Self {
278        self.http_client = Some(client);
279        self
280    }
281
282    /// Builds the [`MessagingClient`].
283    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    /// `send_each` must dispatch all requests concurrently, not
331    /// sequentially — mirroring the official Admin SDKs' `Promise.allSettled`
332    /// behavior. Each mocked response is delayed by 200ms; 5 messages sent
333    /// sequentially would take ~1s, while concurrent dispatch completes in
334    /// roughly one delay period regardless of message count.
335    #[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}