Skip to content

Commit 13de0ff

Browse files
committed
Add messaging send_all and send_multicast functions
1 parent 3da3b5a commit 13de0ff

File tree

5 files changed

+611
-25
lines changed

5 files changed

+611
-25
lines changed

firebase_admin/_messaging_utils.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,33 @@ def __init__(self, data=None, notification=None, android=None, webpush=None, apn
5454
self.condition = condition
5555

5656

57+
class MulticastMessage(object):
58+
"""A message that can be sent to multiple tokens via Firebase Cloud Messaging.
59+
60+
Contains payload information as well as recipient information. In particular, the message must
61+
contain exactly one of token, topic or condition fields.
62+
63+
Args:
64+
tokens: A list of registration token of the device to which the message should be sent.
65+
data: A dictionary of data fields (optional). All keys and values in the dictionary must be
66+
strings.
67+
notification: An instance of ``messaging.Notification`` (optional).
68+
android: An instance of ``messaging.AndroidConfig`` (optional).
69+
webpush: An instance of ``messaging.WebpushConfig`` (optional).
70+
apns: An instance of ``messaging.ApnsConfig`` (optional).
71+
"""
72+
def __init__(self, tokens, data=None, notification=None, android=None, webpush=None, apns=None):
73+
_Validators.check_string_list('MulticastMessage.tokens', tokens)
74+
if len(tokens) > 100:
75+
raise ValueError('MulticastMessage.tokens must contain less than 100 tokens.')
76+
self.tokens = tokens
77+
self.data = data
78+
self.notification = notification
79+
self.android = android
80+
self.webpush = webpush
81+
self.apns = apns
82+
83+
5784
class Notification(object):
5885
"""A notification that can be included in a message.
5986
@@ -150,7 +177,7 @@ class WebpushConfig(object):
150177
data: A dictionary of data fields (optional). All keys and values in the dictionary must be
151178
strings. When specified, overrides any data fields set via ``Message.data``.
152179
notification: A ``messaging.WebpushNotification`` to be included in the message (optional).
153-
fcm_options: A ``messaging.WebpushFcmOptions`` instance to be included in the messsage
180+
fcm_options: A ``messaging.WebpushFcmOptions`` instance to be included in the message
154181
(optional).
155182
156183
.. _Webpush Specification: https://tools.ietf.org/html/rfc8030#section-5

firebase_admin/messaging.py

Lines changed: 189 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,14 @@
1414

1515
"""Firebase Cloud Messaging module."""
1616

17+
import json
1718
import requests
1819
import six
1920

21+
import googleapiclient
22+
from googleapiclient import http
23+
from googleapiclient import _auth
24+
2025
import firebase_admin
2126
from firebase_admin import _http_client
2227
from firebase_admin import _messaging_utils
@@ -34,17 +39,22 @@
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
]
@@ -58,6 +68,7 @@
5868
ApsAlert = _messaging_utils.ApsAlert
5969
CriticalSound = _messaging_utils.CriticalSound
6070
Message = _messaging_utils.Message
71+
MulticastMessage = _messaging_utils.MulticastMessage
6172
Notification = _messaging_utils.Notification
6273
WebpushConfig = _messaging_utils.WebpushConfig
6374
WebpushFcmOptions = _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+
"""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+
91152
def subscribe_to_topic(tokens, topic, app=None):
92153
"""Subscribes a list of registration tokens to an FCM topic.
93154
@@ -192,10 +253,58 @@ def __init__(self, code, message, detail=None):
192253
self.detail = detail
193254

194255

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+
195303
class _MessagingService(object):
196304
"""Service class that implements Firebase Cloud Messaging (FCM) functionality."""
197305

198306
FCM_URL = 'https://fcm.googleapis.com/v1/projects/{0}/messages:send'
307+
FCM_BATCH_URL = 'https://fcm.googleapis.com/batch'
199308
IID_URL = 'https://iid.googleapis.com'
200309
IID_HEADERS = {'access_token_auth': 'true'}
201310
JSON_ENCODER = _messaging_utils.MessageEncoder()
@@ -234,9 +343,13 @@ def __init__(self, app):
234343
'projectId option, or use service account credentials. Alternatively, set the '
235344
'GOOGLE_CLOUD_PROJECT environment variable.')
236345
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+
}
237350
self._client = _http_client.JsonHttpClient(credential=app.credential.get_credential())
238351
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())
240353

241354
@classmethod
242355
def encode_message(cls, message):
@@ -245,16 +358,10 @@ def encode_message(cls, message):
245358
return cls.JSON_ENCODER.default(message)
246359

247360
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)
251362
try:
252-
headers = {
253-
'X-GOOG-API-FORMAT-VERSION': '2',
254-
'X-FIREBASE-CLIENT': self._client_version,
255-
}
256363
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)
258365
except requests.exceptions.RequestException as error:
259366
if error.response is not None:
260367
self._handle_fcm_error(error)
@@ -264,6 +371,33 @@ def send(self, message, dry_run=False):
264371
else:
265372
return resp['name']
266373

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+
267401
def make_topic_management_request(self, tokens, topic, operation):
268402
"""Invokes the IID service for topic management functionality."""
269403
if isinstance(tokens, six.string_types):
@@ -299,6 +433,18 @@ def make_topic_management_request(self, tokens, topic, operation):
299433
else:
300434
return TopicManagementResponse(resp)
301435

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+
302448
def _handle_fcm_error(self, error):
303449
"""Handles errors received from the FCM API."""
304450
data = {}
@@ -309,21 +455,7 @@ def _handle_fcm_error(self, error):
309455
except ValueError:
310456
pass
311457

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)
327459

328460
def _handle_iid_error(self, error):
329461
"""Handles errors received from the Instance ID API."""
@@ -342,3 +474,36 @@ def _handle_iid_error(self, error):
342474
msg = 'Unexpected HTTP response with status: {0}; body: {1}'.format(
343475
error.response.status_code, error.response.content.decode())
344476
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)

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ tox >= 3.6.0
66

77
cachecontrol >= 0.12.4
88
google-api-core[grpc] >= 1.7.0, < 2.0.0dev; platform.python_implementation != 'PyPy'
9+
google-api-python-client >= 1.7.8
910
google-cloud-firestore >= 0.31.0; platform.python_implementation != 'PyPy'
1011
google-cloud-storage >= 1.13.0
1112
six >= 1.6.1

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
install_requires = [
4040
'cachecontrol>=0.12.4',
4141
'google-api-core[grpc] >= 1.7.0, < 2.0.0dev; platform.python_implementation != "PyPy"',
42+
'google-api-python-client >= 1.7.8',
4243
'google-cloud-firestore>=0.31.0; platform.python_implementation != "PyPy"',
4344
'google-cloud-storage>=1.13.0',
4445
'six>=1.6.1'

0 commit comments

Comments
 (0)