Skip to content

Commit 2d91a98

Browse files
committed
Add messaging sendAll function
1 parent 3da3b5a commit 2d91a98

File tree

1 file changed

+95
-9
lines changed

1 file changed

+95
-9
lines changed

firebase_admin/messaging.py

Lines changed: 95 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

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

17+
import json
1718
import requests
1819
import six
1920

@@ -45,6 +46,7 @@
4546
'WebpushNotificationAction',
4647

4748
'send',
49+
'sendAll',
4850
'subscribe_to_topic',
4951
'unsubscribe_from_topic',
5052
]
@@ -88,6 +90,26 @@ def send(message, dry_run=False, app=None):
8890
"""
8991
return _get_messaging_service(app).send(message, dry_run)
9092

93+
def sendAll(messages, dry_run=False, app=None):
94+
"""Batch sends the given messages via Firebase Cloud Messaging (FCM).
95+
96+
If the ``dry_run`` mode is enabled, the message will not be actually delivered to the
97+
recipients. Instead FCM performs all the usual validations, and emulates the send operation.
98+
99+
Args:
100+
messages: A list of ``messaging.Message`` instances.
101+
dry_run: A boolean indicating whether to run the operation in dry run mode (optional).
102+
app: An App instance (optional).
103+
104+
Returns:
105+
list: A list of message ID string that uniquely identifies the sent the messages.
106+
107+
Raises:
108+
ApiCallError: If an error occurs while sending the message to FCM service.
109+
ValueError: If the input arguments are invalid.
110+
"""
111+
return _get_messaging_service(app).sendAll(messages, dry_run)
112+
91113
def subscribe_to_topic(tokens, topic, app=None):
92114
"""Subscribes a list of registration tokens to an FCM topic.
93115
@@ -196,10 +218,13 @@ class _MessagingService(object):
196218
"""Service class that implements Firebase Cloud Messaging (FCM) functionality."""
197219

198220
FCM_URL = 'https://fcm.googleapis.com/v1/projects/{0}/messages:send'
221+
FCM_BATCH_URL = 'https://fcm.googleapis.com/batch'
199222
IID_URL = 'https://iid.googleapis.com'
200223
IID_HEADERS = {'access_token_auth': 'true'}
201224
JSON_ENCODER = _messaging_utils.MessageEncoder()
202225

226+
BATCH_PART_BOUNDARY = '__END_OF_PART__'
227+
203228
INTERNAL_ERROR = 'internal-error'
204229
UNKNOWN_ERROR = 'unknown-error'
205230
FCM_ERROR_CODES = {
@@ -235,26 +260,25 @@ def __init__(self, app):
235260
'GOOGLE_CLOUD_PROJECT environment variable.')
236261
self._fcm_url = _MessagingService.FCM_URL.format(project_id)
237262
self._client = _http_client.JsonHttpClient(credential=app.credential.get_credential())
263+
self._fcm_headers = {
264+
'X-GOOG-API-FORMAT-VERSION': '2',
265+
'X-FIREBASE-CLIENT': 'fire-admin-python/{0}'.format(firebase_admin.__version__)
266+
}
238267
self._timeout = app.options.get('httpTimeout')
239-
self._client_version = 'fire-admin-python/{0}'.format(firebase_admin.__version__)
240268

241269
@classmethod
242270
def encode_message(cls, message):
243271
if not isinstance(message, Message):
244272
raise ValueError('Message must be an instance of messaging.Message class.')
245273
return cls.JSON_ENCODER.default(message)
246274

275+
# TODO: sendMulticast
276+
247277
def send(self, message, dry_run=False):
248-
data = {'message': _MessagingService.encode_message(message)}
249-
if dry_run:
250-
data['validate_only'] = True
278+
data = self._message_data(message, dry_run)
251279
try:
252-
headers = {
253-
'X-GOOG-API-FORMAT-VERSION': '2',
254-
'X-FIREBASE-CLIENT': self._client_version,
255-
}
256280
resp = self._client.body(
257-
'post', url=self._fcm_url, headers=headers, json=data, timeout=self._timeout)
281+
'post', url=self._fcm_url, headers=self._fcm_headers, json=data, timeout=self._timeout)
258282
except requests.exceptions.RequestException as error:
259283
if error.response is not None:
260284
self._handle_fcm_error(error)
@@ -264,6 +288,35 @@ def send(self, message, dry_run=False):
264288
else:
265289
return resp['name']
266290

291+
def sendAll(self, messages, dry_run=False):
292+
data = self._batch_message_data(messages, dry_run)
293+
headers = {
294+
'Content-Type': 'multipart/mixed; boundary={}'.format(_MessagingService.BATCH_PART_BOUNDARY)
295+
}
296+
try:
297+
response = self._client.request(
298+
'post', url=_MessagingService.FCM_BATCH_URL, headers=headers, data=data, timeout=self._timeout
299+
)
300+
except requests.exceptions.RequestException as error:
301+
if error.response is not None:
302+
self._handle_fcm_error(error)
303+
else:
304+
msg = 'Failed to call messaging API: {0}'.format(error)
305+
raise ApiCallError(self.INTERNAL_ERROR, msg, error)
306+
else:
307+
import re
308+
# Split our batches responses in to groups
309+
response_regex = re.compile('--batch.*?$\n(.*?)(?=--batch)', re.DOTALL | re.MULTILINE)
310+
responses = response_regex.findall(response.content)
311+
312+
# First section is headers related to the batch request
313+
# Second section is headers related to the batched request
314+
# Third section is data related to the batched request
315+
response_sections = [r.split('\r\n\r\n') for r in responses]
316+
response_json = [json.loads(r[2]) for r in response_sections]
317+
318+
return response_json
319+
267320
def make_topic_management_request(self, tokens, topic, operation):
268321
"""Invokes the IID service for topic management functionality."""
269322
if isinstance(tokens, six.string_types):
@@ -299,6 +352,39 @@ def make_topic_management_request(self, tokens, topic, operation):
299352
else:
300353
return TopicManagementResponse(resp)
301354

355+
def _message_data(self, message, dry_run):
356+
data = {'message': _MessagingService.encode_message(message)}
357+
if dry_run:
358+
data['validate_only'] = True
359+
return data
360+
361+
def _batch_message_data(self, messages, dry_run):
362+
parts = [self._batch_request_part(request, dry_run, _MessagingService.BATCH_PART_BOUNDARY, index)\
363+
for (index, request) in enumerate(messages)]
364+
return '{}--{}--\r\n'.format(''.join(parts), _MessagingService.BATCH_PART_BOUNDARY)
365+
366+
def _batch_request_part(self, request, dry_run, boundary, index):
367+
data = self._batch_request_part_data(request, dry_run)
368+
part = '--{}\r\n'.format(boundary)
369+
part += 'Content-Length: {}\r\n'.format(len(data))
370+
part += 'Content-Type: application/http\r\n'
371+
part += 'content-id: {}\r\n'.format(index + 1)
372+
part += 'content-transfer-encoding: binary\r\n'
373+
part += '\r\n'
374+
part += '{}\r\n'.format(data)
375+
return part
376+
377+
def _batch_request_part_data(self, request, dry_run):
378+
body = json.dumps(self._message_data(request, dry_run))
379+
data = 'POST {} HTTP/1.1\r\n'.format(self._fcm_url)
380+
data += 'Content-Length: {}\r\n'.format(len(body))
381+
data += 'Content-Type: application/json; charset=UTF-8\r\n'
382+
data += '\r\n'.join(['{}: {}'.format(k, v) for k, v in self._fcm_headers.items()])
383+
data += '\r\n\r\n'
384+
data += body
385+
return data
386+
387+
302388
def _handle_fcm_error(self, error):
303389
"""Handles errors received from the FCM API."""
304390
data = {}

0 commit comments

Comments
 (0)