1414
1515"""Firebase Cloud Messaging module."""
1616
17+ import json
1718import requests
1819import six
1920
21+ import googleapiclient
22+ from googleapiclient import http
23+ from googleapiclient import _auth
24+
2025import firebase_admin
2126from firebase_admin import _http_client
2227from firebase_admin import _messaging_utils
3439 'ApiCallError' ,
3540 'Aps' ,
3641 'ApsAlert' ,
42+ 'BatchResponse' ,
3743 'CriticalSound' ,
3844 'ErrorInfo' ,
3945 'Message' ,
46+ 'MulticastMessage' ,
4047 'Notification' ,
48+ 'SendResponse' ,
4149 'TopicManagementResponse' ,
4250 'WebpushConfig' ,
4351 'WebpushFcmOptions' ,
4452 'WebpushNotification' ,
4553 'WebpushNotificationAction' ,
4654
4755 'send' ,
56+ 'send_all' ,
57+ 'send_multicast' ,
4858 'subscribe_to_topic' ,
4959 'unsubscribe_from_topic' ,
5060]
5868ApsAlert = _messaging_utils .ApsAlert
5969CriticalSound = _messaging_utils .CriticalSound
6070Message = _messaging_utils .Message
71+ MulticastMessage = _messaging_utils .MulticastMessage
6172Notification = _messaging_utils .Notification
6273WebpushConfig = _messaging_utils .WebpushConfig
6374WebpushFcmOptions = _messaging_utils .WebpushFcmOptions
@@ -88,6 +99,56 @@ def send(message, dry_run=False, app=None):
8899 """
89100 return _get_messaging_service (app ).send (message , dry_run )
90101
102+ def send_all (messages , dry_run = False , app = None ):
103+ """Sends the given list of messages via Firebase Cloud Messaging as a single batch.
104+
105+ If the ``dry_run`` mode is enabled, the message will not be actually delivered to the
106+ recipients. Instead FCM performs all the usual validations, and emulates the send operation.
107+
108+ Args:
109+ messages: A list of ``messaging.Message`` instances.
110+ dry_run: A boolean indicating whether to run the operation in dry run mode (optional).
111+ app: An App instance (optional).
112+
113+ Returns:
114+ BatchResponse: A ``messaging.BatchResponse`` instance.
115+
116+ Raises:
117+ ApiCallError: If an error occurs while sending the message to FCM service.
118+ ValueError: If the input arguments are invalid.
119+ """
120+ return _get_messaging_service (app ).send_all (messages , dry_run )
121+
122+ def send_multicast (multicast_message , dry_run = False , app = None ):
123+ """Sends the given mutlicast message to all tokens via Firebase Cloud Messaging (FCM).
124+
125+ If the ``dry_run`` mode is enabled, the message will not be actually delivered to the
126+ recipients. Instead FCM performs all the usual validations, and emulates the send operation.
127+
128+ Args:
129+ multicast_message: An instance of ``messaging.MulticastMessage``.
130+ dry_run: A boolean indicating whether to run the operation in dry run mode (optional).
131+ app: An App instance (optional).
132+
133+ Returns:
134+ BatchResponse: A ``messaging.BatchResponse`` instance.
135+
136+ Raises:
137+ ApiCallError: If an error occurs while sending the message to FCM service.
138+ ValueError: If the input arguments are invalid.
139+ """
140+ if not isinstance (multicast_message , MulticastMessage ):
141+ raise ValueError ('Message must be an instance of messaging.MulticastMessage class.' )
142+ messages = [Message (
143+ data = multicast_message .data ,
144+ notification = multicast_message .notification ,
145+ android = multicast_message .android ,
146+ webpush = multicast_message .webpush ,
147+ apns = multicast_message .apns ,
148+ token = token
149+ ) for token in multicast_message .tokens ]
150+ return _get_messaging_service (app ).send_all (messages , dry_run )
151+
91152def subscribe_to_topic (tokens , topic , app = None ):
92153 """Subscribes a list of registration tokens to an FCM topic.
93154
@@ -192,10 +253,57 @@ def __init__(self, code, message, detail=None):
192253 self .detail = detail
193254
194255
256+ class BatchResponse (object ):
257+ """The response received from a batch request to the FCM API."""
258+
259+ def __init__ (self , responses ):
260+ self ._responses = responses
261+ self ._success_count = len ([resp for resp in responses if resp .success ])
262+
263+ @property
264+ def responses (self ):
265+ """A list of ``messaging.SendResponse`` objects (possibly empty)."""
266+ return self ._responses
267+
268+ @property
269+ def success_count (self ):
270+ return self ._success_count
271+
272+ @property
273+ def failure_count (self ):
274+ return len (self .responses ) - self .success_count
275+
276+
277+ class SendResponse (object ):
278+ """The response received from an individual batched request to the FCM API."""
279+
280+ def __init__ (self , resp , exception ):
281+ self ._exception = exception
282+ self ._message_id = None
283+ if resp :
284+ self ._message_id = resp .get ('name' , None )
285+
286+ @property
287+ def message_id (self ):
288+ """A message ID string that uniquely identifies the sent the message."""
289+ return self ._message_id
290+
291+ @property
292+ def success (self ):
293+ """A boolean indicating if the request was successful."""
294+ return self ._message_id is not None and not self ._exception
295+
296+ @property
297+ def exception (self ):
298+ """A ApiCallError if an error occurs while sending the message to FCM service."""
299+ return self ._exception
300+
301+
195302class _MessagingService (object ):
196303 """Service class that implements Firebase Cloud Messaging (FCM) functionality."""
197304
198305 FCM_URL = 'https://fcm.googleapis.com/v1/projects/{0}/messages:send'
306+ FCM_BATCH_URL = 'https://fcm.googleapis.com/batch'
199307 IID_URL = 'https://iid.googleapis.com'
200308 IID_HEADERS = {'access_token_auth' : 'true' }
201309 JSON_ENCODER = _messaging_utils .MessageEncoder ()
@@ -234,9 +342,13 @@ def __init__(self, app):
234342 'projectId option, or use service account credentials. Alternatively, set the '
235343 'GOOGLE_CLOUD_PROJECT environment variable.' )
236344 self ._fcm_url = _MessagingService .FCM_URL .format (project_id )
345+ self ._fcm_headers = {
346+ 'X-GOOG-API-FORMAT-VERSION' : '2' ,
347+ 'X-FIREBASE-CLIENT' : 'fire-admin-python/{0}' .format (firebase_admin .__version__ ),
348+ }
237349 self ._client = _http_client .JsonHttpClient (credential = app .credential .get_credential ())
238350 self ._timeout = app .options .get ('httpTimeout' )
239- self ._client_version = 'fire-admin-python/{0}' . format ( firebase_admin . __version__ )
351+ self ._transport = _auth . authorized_http ( app . credential . get_credential () )
240352
241353 @classmethod
242354 def encode_message (cls , message ):
@@ -245,16 +357,15 @@ def encode_message(cls, message):
245357 return cls .JSON_ENCODER .default (message )
246358
247359 def send (self , message , dry_run = False ):
248- data = {'message' : _MessagingService .encode_message (message )}
249- if dry_run :
250- data ['validate_only' ] = True
360+ data = self ._message_data (message , dry_run )
251361 try :
252- headers = {
253- 'X-GOOG-API-FORMAT-VERSION' : '2' ,
254- 'X-FIREBASE-CLIENT' : self ._client_version ,
255- }
256362 resp = self ._client .body (
257- 'post' , url = self ._fcm_url , headers = headers , json = data , timeout = self ._timeout )
363+ 'post' ,
364+ url = self ._fcm_url ,
365+ headers = self ._fcm_headers ,
366+ json = data ,
367+ timeout = self ._timeout
368+ )
258369 except requests .exceptions .RequestException as error :
259370 if error .response is not None :
260371 self ._handle_fcm_error (error )
@@ -264,6 +375,42 @@ def send(self, message, dry_run=False):
264375 else :
265376 return resp ['name' ]
266377
378+ def send_all (self , messages , dry_run = False ):
379+ """Sends the given messages to FCM via the batch API."""
380+ if not isinstance (messages , list ):
381+ raise ValueError ('Messages must be an list of messaging.Message instances.' )
382+ if len (messages ) > 100 :
383+ raise ValueError ('send_all messages must not contain more than 100 messages.' )
384+
385+ responses = []
386+
387+ def batch_callback (_ , response , error ):
388+ exception = None
389+ if error :
390+ exception = self ._parse_batch_error (error )
391+ send_response = SendResponse (response , exception )
392+ responses .append (send_response )
393+
394+ batch = http .BatchHttpRequest (batch_callback , _MessagingService .FCM_BATCH_URL )
395+ for message in messages :
396+ body = json .dumps (self ._message_data (message , dry_run ))
397+ req = http .HttpRequest (
398+ http = self ._transport ,
399+ postproc = self ._postproc ,
400+ uri = self ._fcm_url ,
401+ method = 'POST' ,
402+ body = body ,
403+ headers = self ._fcm_headers
404+ )
405+ batch .add (req )
406+
407+ try :
408+ batch .execute ()
409+ except googleapiclient .http .HttpError as error :
410+ raise self ._parse_batch_error (error )
411+ else :
412+ return BatchResponse (responses )
413+
267414 def make_topic_management_request (self , tokens , topic , operation ):
268415 """Invokes the IID service for topic management functionality."""
269416 if isinstance (tokens , six .string_types ):
@@ -299,6 +446,17 @@ def make_topic_management_request(self, tokens, topic, operation):
299446 else :
300447 return TopicManagementResponse (resp )
301448
449+ def _message_data (self , message , dry_run ):
450+ data = {'message' : _MessagingService .encode_message (message )}
451+ if dry_run :
452+ data ['validate_only' ] = True
453+ return data
454+
455+ def _postproc (self , _ , body ):
456+ """Handle response from batch API request."""
457+ # This only gets called for 2xx responses.
458+ return json .loads (body .decode ())
459+
302460 def _handle_fcm_error (self , error ):
303461 """Handles errors received from the FCM API."""
304462 data = {}
@@ -309,20 +467,8 @@ def _handle_fcm_error(self, error):
309467 except ValueError :
310468 pass
311469
312- error_dict = data .get ('error' , {})
313- server_code = None
314- for detail in error_dict .get ('details' , []):
315- if detail .get ('@type' ) == 'type.googleapis.com/google.firebase.fcm.v1.FcmError' :
316- server_code = detail .get ('errorCode' )
317- break
318- if not server_code :
319- server_code = error_dict .get ('status' )
320- code = _MessagingService .FCM_ERROR_CODES .get (server_code , _MessagingService .UNKNOWN_ERROR )
321-
322- msg = error_dict .get ('message' )
323- if not msg :
324- msg = 'Unexpected HTTP response with status: {0}; body: {1}' .format (
325- error .response .status_code , error .response .content .decode ())
470+ code , msg = _MessagingService ._parse_fcm_error (
471+ data , error .response .content , error .response .status_code )
326472 raise ApiCallError (code , msg , error )
327473
328474 def _handle_iid_error (self , error ):
@@ -342,3 +488,39 @@ def _handle_iid_error(self, error):
342488 msg = 'Unexpected HTTP response with status: {0}; body: {1}' .format (
343489 error .response .status_code , error .response .content .decode ())
344490 raise ApiCallError (code , msg , error )
491+
492+ def _parse_batch_error (self , error ):
493+ """Parses a googleapiclient.http.HttpError content in to an ApiCallError."""
494+ if error .content is None :
495+ msg = 'Failed to call messaging API: {0}' .format (error )
496+ return ApiCallError (self .INTERNAL_ERROR , msg , error )
497+
498+ data = {}
499+ try :
500+ parsed_body = json .loads (error .content .decode ())
501+ if isinstance (parsed_body , dict ):
502+ data = parsed_body
503+ except ValueError :
504+ pass
505+
506+ code , msg = _MessagingService ._parse_fcm_error (data , error .content , error .resp .status )
507+ return ApiCallError (code , msg , error )
508+
509+ @classmethod
510+ def _parse_fcm_error (cls , data , content , status_code ):
511+ """Parses an error response from the FCM API to a ApiCallError."""
512+ error_dict = data .get ('error' , {})
513+ server_code = None
514+ for detail in error_dict .get ('details' , []):
515+ if detail .get ('@type' ) == 'type.googleapis.com/google.firebase.fcm.v1.FcmError' :
516+ server_code = detail .get ('errorCode' )
517+ break
518+ if not server_code :
519+ server_code = error_dict .get ('status' )
520+ code = _MessagingService .FCM_ERROR_CODES .get (server_code , _MessagingService .UNKNOWN_ERROR )
521+
522+ msg = error_dict .get ('message' )
523+ if not msg :
524+ msg = 'Unexpected HTTP response with status: {0}; body: {1}' .format (
525+ status_code , content .decode ())
526+ return code , msg
0 commit comments