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
+ """Batch sends the given messages via Firebase Cloud Messaging (FCM).
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 the mutlicast message 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
+ 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 = map (lambda token : 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
+ ), 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,58 @@ def __init__(self, code, message, detail=None):
192
253
self .detail = detail
193
254
194
255
256
+ class BatchResponse (object ):
257
+
258
+ def __init__ (self , responses ):
259
+ self ._responses = responses
260
+ self ._success_count = 0
261
+ for response in responses :
262
+ if response .success :
263
+ self ._success_count += 1
264
+
265
+ @property
266
+ def responses (self ):
267
+ """A list of ``messaging.SendResponse`` objects (possibly empty)."""
268
+ return self ._responses
269
+
270
+ @property
271
+ def success_count (self ):
272
+ return self ._success_count
273
+
274
+ @property
275
+ def failure_count (self ):
276
+ return len (self .responses ) - self .success_count
277
+
278
+
279
+ class SendResponse (object ):
280
+
281
+ def __init__ (self , resp , exception ):
282
+ self ._exception = exception
283
+ self ._message_id = None
284
+ if resp :
285
+ self ._message_id = resp .get ('name' , None )
286
+
287
+ @property
288
+ def message_id (self ):
289
+ """A message ID string that uniquely identifies the sent the message."""
290
+ return self ._message_id
291
+
292
+ @property
293
+ def success (self ):
294
+ """A boolean indicating if the request was successful."""
295
+ return self ._message_id is not None and not self ._exception
296
+
297
+ @property
298
+ def exception (self ):
299
+ """A ApiCallError if an error occurs while sending the message to FCM service."""
300
+ return self ._exception
301
+
302
+
195
303
class _MessagingService (object ):
196
304
"""Service class that implements Firebase Cloud Messaging (FCM) functionality."""
197
305
198
306
FCM_URL = 'https://fcm.googleapis.com/v1/projects/{0}/messages:send'
307
+ FCM_BATCH_URL = 'https://fcm.googleapis.com/batch'
199
308
IID_URL = 'https://iid.googleapis.com'
200
309
IID_HEADERS = {'access_token_auth' : 'true' }
201
310
JSON_ENCODER = _messaging_utils .MessageEncoder ()
@@ -234,9 +343,13 @@ def __init__(self, app):
234
343
'projectId option, or use service account credentials. Alternatively, set the '
235
344
'GOOGLE_CLOUD_PROJECT environment variable.' )
236
345
self ._fcm_url = _MessagingService .FCM_URL .format (project_id )
346
+ self ._fcm_headers = {
347
+ 'X-GOOG-API-FORMAT-VERSION' : '2' ,
348
+ 'X-FIREBASE-CLIENT' : 'fire-admin-python/{0}' .format (firebase_admin .__version__ ),
349
+ }
237
350
self ._client = _http_client .JsonHttpClient (credential = app .credential .get_credential ())
238
351
self ._timeout = app .options .get ('httpTimeout' )
239
- self ._client_version = 'fire-admin-python/{0}' . format ( firebase_admin . __version__ )
352
+ self ._transport = _auth . authorized_http ( app . credential . get_credential () )
240
353
241
354
@classmethod
242
355
def encode_message (cls , message ):
@@ -245,16 +358,10 @@ def encode_message(cls, message):
245
358
return cls .JSON_ENCODER .default (message )
246
359
247
360
def send (self , message , dry_run = False ):
248
- data = {'message' : _MessagingService .encode_message (message )}
249
- if dry_run :
250
- data ['validate_only' ] = True
361
+ data = self ._message_data (message , dry_run )
251
362
try :
252
- headers = {
253
- 'X-GOOG-API-FORMAT-VERSION' : '2' ,
254
- 'X-FIREBASE-CLIENT' : self ._client_version ,
255
- }
256
363
resp = self ._client .body (
257
- 'post' , url = self ._fcm_url , headers = headers , json = data , timeout = self ._timeout )
364
+ 'post' , url = self ._fcm_url , headers = self . _fcm_headers , json = data , timeout = self ._timeout )
258
365
except requests .exceptions .RequestException as error :
259
366
if error .response is not None :
260
367
self ._handle_fcm_error (error )
@@ -264,6 +371,33 @@ def send(self, message, dry_run=False):
264
371
else :
265
372
return resp ['name' ]
266
373
374
+ def send_all (self , messages , dry_run = False ):
375
+ if not isinstance (messages , list ):
376
+ raise ValueError ('Messages must be an list of messaging.Message instances.' )
377
+
378
+ responses = []
379
+
380
+ def batch_callback (request_id , response , error ):
381
+ exception = None
382
+ if error :
383
+ exception = self ._parse_batch_error (error )
384
+ send_response = SendResponse (response , exception )
385
+ responses .append (send_response )
386
+
387
+ batch = http .BatchHttpRequest (batch_callback , _MessagingService .FCM_BATCH_URL )
388
+ for message in messages :
389
+ body = json .dumps (self ._message_data (message , dry_run ))
390
+ req = http .HttpRequest (
391
+ http = self ._transport , postproc = self ._postproc , uri = self ._fcm_url , method = 'POST' , body = body , headers = self ._fcm_headers )
392
+ batch .add (req )
393
+
394
+ try :
395
+ batch .execute ()
396
+ except googleapiclient .http .HttpError as error :
397
+ raise self ._parse_batch_error (error )
398
+ else :
399
+ return BatchResponse (responses )
400
+
267
401
def make_topic_management_request (self , tokens , topic , operation ):
268
402
"""Invokes the IID service for topic management functionality."""
269
403
if isinstance (tokens , six .string_types ):
@@ -299,6 +433,18 @@ def make_topic_management_request(self, tokens, topic, operation):
299
433
else :
300
434
return TopicManagementResponse (resp )
301
435
436
+ def _message_data (self , message , dry_run ):
437
+ data = {'message' : _MessagingService .encode_message (message )}
438
+ if dry_run :
439
+ data ['validate_only' ] = True
440
+ return data
441
+
442
+ def _postproc (self , resp , body ):
443
+ if resp .status == 200 :
444
+ return json .loads (body )
445
+ else :
446
+ raise Exception ('unexpected response' )
447
+
302
448
def _handle_fcm_error (self , error ):
303
449
"""Handles errors received from the FCM API."""
304
450
data = {}
@@ -309,21 +455,7 @@ def _handle_fcm_error(self, error):
309
455
except ValueError :
310
456
pass
311
457
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 ())
326
- raise ApiCallError (code , msg , error )
458
+ raise _MessagingService ._parse_fcm_error (data , error .response .content , error .response .status_code , error )
327
459
328
460
def _handle_iid_error (self , error ):
329
461
"""Handles errors received from the Instance ID API."""
@@ -342,3 +474,36 @@ def _handle_iid_error(self, error):
342
474
msg = 'Unexpected HTTP response with status: {0}; body: {1}' .format (
343
475
error .response .status_code , error .response .content .decode ())
344
476
raise ApiCallError (code , msg , error )
477
+
478
+ def _parse_batch_error (self , error ):
479
+ if error .content is None :
480
+ msg = 'Failed to call messaging API: {0}' .format (error )
481
+ return ApiCallError (self .INTERNAL_ERROR , msg , error )
482
+
483
+ data = {}
484
+ try :
485
+ parsed_body = json .loads (error .content )
486
+ if isinstance (parsed_body , dict ):
487
+ data = parsed_body
488
+ except ValueError :
489
+ pass
490
+ return _MessagingService ._parse_fcm_error (data , error .content , error .resp .status , error )
491
+
492
+ @classmethod
493
+ def _parse_fcm_error (cls , data , content , status_code , error ):
494
+ """Parses an error response from the FCM API to a ApiCallError."""
495
+ error_dict = data .get ('error' , {})
496
+ server_code = None
497
+ for detail in error_dict .get ('details' , []):
498
+ if detail .get ('@type' ) == 'type.googleapis.com/google.firebase.fcm.v1.FcmError' :
499
+ server_code = detail .get ('errorCode' )
500
+ break
501
+ if not server_code :
502
+ server_code = error_dict .get ('status' )
503
+ code = _MessagingService .FCM_ERROR_CODES .get (server_code , _MessagingService .UNKNOWN_ERROR )
504
+
505
+ msg = error_dict .get ('message' )
506
+ if not msg :
507
+ msg = 'Unexpected HTTP response with status: {0}; body: {1}' .format (status_code , content .decode ())
508
+
509
+ return ApiCallError (code , msg , error )
0 commit comments