14
14
15
15
"""Firebase Cloud Messaging module."""
16
16
17
+ import json
17
18
import requests
18
19
import six
19
20
21
+ import googleapiclient
22
+ from googleapiclient import http
23
+ from googleapiclient import _auth
24
+
20
25
import firebase_admin
21
26
from firebase_admin import _http_client
22
27
from firebase_admin import _messaging_utils
34
39
'ApiCallError' ,
35
40
'Aps' ,
36
41
'ApsAlert' ,
42
+ 'BatchResponse' ,
37
43
'CriticalSound' ,
38
44
'ErrorInfo' ,
39
45
'Message' ,
46
+ 'MulticastMessage' ,
40
47
'Notification' ,
48
+ 'SendResponse' ,
41
49
'TopicManagementResponse' ,
42
50
'WebpushConfig' ,
43
51
'WebpushFcmOptions' ,
44
52
'WebpushNotification' ,
45
53
'WebpushNotificationAction' ,
46
54
47
55
'send' ,
56
+ 'send_all' ,
57
+ 'send_multicast' ,
48
58
'subscribe_to_topic' ,
49
59
'unsubscribe_from_topic' ,
50
60
]
58
68
ApsAlert = _messaging_utils .ApsAlert
59
69
CriticalSound = _messaging_utils .CriticalSound
60
70
Message = _messaging_utils .Message
71
+ MulticastMessage = _messaging_utils .MulticastMessage
61
72
Notification = _messaging_utils .Notification
62
73
WebpushConfig = _messaging_utils .WebpushConfig
63
74
WebpushFcmOptions = _messaging_utils .WebpushFcmOptions
@@ -88,6 +99,56 @@ def send(message, dry_run=False, app=None):
88
99
"""
89
100
return _get_messaging_service (app ).send (message , dry_run )
90
101
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
+
91
152
def subscribe_to_topic (tokens , topic , app = None ):
92
153
"""Subscribes a list of registration tokens to an FCM topic.
93
154
@@ -192,10 +253,57 @@ def __init__(self, code, message, detail=None):
192
253
self .detail = detail
193
254
194
255
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
+
195
302
class _MessagingService (object ):
196
303
"""Service class that implements Firebase Cloud Messaging (FCM) functionality."""
197
304
198
305
FCM_URL = 'https://fcm.googleapis.com/v1/projects/{0}/messages:send'
306
+ FCM_BATCH_URL = 'https://fcm.googleapis.com/batch'
199
307
IID_URL = 'https://iid.googleapis.com'
200
308
IID_HEADERS = {'access_token_auth' : 'true' }
201
309
JSON_ENCODER = _messaging_utils .MessageEncoder ()
@@ -234,9 +342,13 @@ def __init__(self, app):
234
342
'projectId option, or use service account credentials. Alternatively, set the '
235
343
'GOOGLE_CLOUD_PROJECT environment variable.' )
236
344
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
+ }
237
349
self ._client = _http_client .JsonHttpClient (credential = app .credential .get_credential ())
238
350
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 () )
240
352
241
353
@classmethod
242
354
def encode_message (cls , message ):
@@ -245,16 +357,15 @@ def encode_message(cls, message):
245
357
return cls .JSON_ENCODER .default (message )
246
358
247
359
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 )
251
361
try :
252
- headers = {
253
- 'X-GOOG-API-FORMAT-VERSION' : '2' ,
254
- 'X-FIREBASE-CLIENT' : self ._client_version ,
255
- }
256
362
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
+ )
258
369
except requests .exceptions .RequestException as error :
259
370
if error .response is not None :
260
371
self ._handle_fcm_error (error )
@@ -264,6 +375,42 @@ def send(self, message, dry_run=False):
264
375
else :
265
376
return resp ['name' ]
266
377
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
+
267
414
def make_topic_management_request (self , tokens , topic , operation ):
268
415
"""Invokes the IID service for topic management functionality."""
269
416
if isinstance (tokens , six .string_types ):
@@ -299,6 +446,17 @@ def make_topic_management_request(self, tokens, topic, operation):
299
446
else :
300
447
return TopicManagementResponse (resp )
301
448
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
+
302
460
def _handle_fcm_error (self , error ):
303
461
"""Handles errors received from the FCM API."""
304
462
data = {}
@@ -309,20 +467,8 @@ def _handle_fcm_error(self, error):
309
467
except ValueError :
310
468
pass
311
469
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 )
326
472
raise ApiCallError (code , msg , error )
327
473
328
474
def _handle_iid_error (self , error ):
@@ -342,3 +488,39 @@ def _handle_iid_error(self, error):
342
488
msg = 'Unexpected HTTP response with status: {0}; body: {1}' .format (
343
489
error .response .status_code , error .response .content .decode ())
344
490
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