Skip to content

Commit

Permalink
Add messaging send_all and send_multicast functions
Browse files Browse the repository at this point in the history
  • Loading branch information
ZachOrr committed May 2, 2019
1 parent 3da3b5a commit 544877a
Show file tree
Hide file tree
Showing 4 changed files with 203 additions and 25 deletions.
30 changes: 29 additions & 1 deletion firebase_admin/_messaging_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,34 @@ def __init__(self, data=None, notification=None, android=None, webpush=None, apn
self.condition = condition


class MulticastMessage(object):
"""A message that can be sent to multiple tokens via Firebase Cloud Messaging.
Contains payload information as well as recipient information. In particular, the message must
contain exactly one of token, topic or condition fields.
Args:
tokens: A list of registration token of the device to which the message should be sent.
data: A dictionary of data fields (optional). All keys and values in the dictionary must be
strings.
notification: An instance of ``messaging.Notification`` (optional).
android: An instance of ``messaging.AndroidConfig`` (optional).
webpush: An instance of ``messaging.WebpushConfig`` (optional).
apns: An instance of ``messaging.ApnsConfig`` (optional).
"""
def __init__(self, tokens, data=None, notification=None, android=None, webpush=None, apns=None):
if not tokens or not isinstance(tokens, list):
raise ValueError('tokens must be a non-empty list of strings.')
if len(tokens) > 100:
raise ValueError('tokens must contain less than 100 tokens.')
self.tokens = tokens
self.data = data
self.notification = notification
self.android = android
self.webpush = webpush
self.apns = apns


class Notification(object):
"""A notification that can be included in a message.
Expand Down Expand Up @@ -150,7 +178,7 @@ class WebpushConfig(object):
data: A dictionary of data fields (optional). All keys and values in the dictionary must be
strings. When specified, overrides any data fields set via ``Message.data``.
notification: A ``messaging.WebpushNotification`` to be included in the message (optional).
fcm_options: A ``messaging.WebpushFcmOptions`` instance to be included in the messsage
fcm_options: A ``messaging.WebpushFcmOptions`` instance to be included in the message
(optional).
.. _Webpush Specification: https://tools.ietf.org/html/rfc8030#section-5
Expand Down
196 changes: 172 additions & 24 deletions firebase_admin/messaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,14 @@

"""Firebase Cloud Messaging module."""

import json
import requests
import six

import googleapiclient
from googleapiclient import http
from googleapiclient import _auth

import firebase_admin
from firebase_admin import _http_client
from firebase_admin import _messaging_utils
Expand All @@ -34,17 +39,22 @@
'ApiCallError',
'Aps',
'ApsAlert',
'BatchResponse',
'CriticalSound',
'ErrorInfo',
'Message',
'MulticastMessage',
'Notification',
'SendResponse',
'TopicManagementResponse',
'WebpushConfig',
'WebpushFcmOptions',
'WebpushNotification',
'WebpushNotificationAction',

'send',
'send_all',
'send_multicast',
'subscribe_to_topic',
'unsubscribe_from_topic',
]
Expand All @@ -58,6 +68,7 @@
ApsAlert = _messaging_utils.ApsAlert
CriticalSound = _messaging_utils.CriticalSound
Message = _messaging_utils.Message
MulticastMessage = _messaging_utils.MulticastMessage
Notification = _messaging_utils.Notification
WebpushConfig = _messaging_utils.WebpushConfig
WebpushFcmOptions = _messaging_utils.WebpushFcmOptions
Expand Down Expand Up @@ -88,6 +99,54 @@ def send(message, dry_run=False, app=None):
"""
return _get_messaging_service(app).send(message, dry_run)

def send_all(messages, dry_run=False, app=None):
"""Batch sends the given messages via Firebase Cloud Messaging (FCM).
If the ``dry_run`` mode is enabled, the message will not be actually delivered to the
recipients. Instead FCM performs all the usual validations, and emulates the send operation.
Args:
messages: A list of ``messaging.Message`` instances.
dry_run: A boolean indicating whether to run the operation in dry run mode (optional).
app: An App instance (optional).
Returns:
BatchResponse: A ``messaging.BatchResponse`` instance.
Raises:
ApiCallError: If an error occurs while sending the message to FCM service.
ValueError: If the input arguments are invalid.
"""
return _get_messaging_service(app).send_all(messages, dry_run)

def send_multicast(multicast_message, dry_run=False, app=None):
"""Sends the given mutlicast message to the mutlicast message tokens via Firebase Cloud Messaging (FCM).
If the ``dry_run`` mode is enabled, the message will not be actually delivered to the
recipients. Instead FCM performs all the usual validations, and emulates the send operation.
Args:
message: An instance of ``messaging.MulticastMessage``.
dry_run: A boolean indicating whether to run the operation in dry run mode (optional).
app: An App instance (optional).
Returns:
BatchResponse: A ``messaging.BatchResponse`` instance.
Raises:
ApiCallError: If an error occurs while sending the message to FCM service.
ValueError: If the input arguments are invalid.
"""
messages = map(lambda token: Message(
data=multicast_message.data,
notification=multicast_message.notification,
android=multicast_message.android,
webpush=multicast_message.webpush,
apns=multicast_message.apns,
token=token
), multicast_message.tokens)
return _get_messaging_service(app).send_all(messages, dry_run)

def subscribe_to_topic(tokens, topic, app=None):
"""Subscribes a list of registration tokens to an FCM topic.
Expand Down Expand Up @@ -192,10 +251,67 @@ def __init__(self, code, message, detail=None):
self.detail = detail


class BatchResponse(object):

def __init__(self, responses):
self._responses = responses
self._success_count = 0
for response in responses:
if response.success:
self._success_count += 1

@property
def responses(self):
"""A list of ``messaging.SendResponse`` objects (possibly empty)."""
return self._responses

@property
def success_count(self):
return self._success_count

@property
def failure_count(self):
return len(self.responses) - self.success_count


class SendResponse(object):

def __init__(self, resp, exception):
self._message_id = None
self._exception = None
if resp:
self._message_id = resp.get('name', None)
if exception:
data = {}
try:
parsed_body = json.loads(exception.content)
if isinstance(parsed_body, dict):
data = parsed_body
except ValueError:
pass
self._exception = _MessagingService._parse_fcm_error(data, exception.content, exception.resp.status, exception)

@property
def message_id(self):
"""A message ID string that uniquely identifies the sent the message."""
return self._message_id

@property
def success(self):
"""A boolean indicating if the request was successful."""
return self._message_id is not None and not self._exception

@property
def exception(self):
"""A ApiCallError if an error occurs while sending the message to FCM service."""
return self._exception


class _MessagingService(object):
"""Service class that implements Firebase Cloud Messaging (FCM) functionality."""

FCM_URL = 'https://fcm.googleapis.com/v1/projects/{0}/messages:send'
FCM_BATCH_URL = 'https://fcm.googleapis.com/batch'
IID_URL = 'https://iid.googleapis.com'
IID_HEADERS = {'access_token_auth': 'true'}
JSON_ENCODER = _messaging_utils.MessageEncoder()
Expand Down Expand Up @@ -234,9 +350,13 @@ def __init__(self, app):
'projectId option, or use service account credentials. Alternatively, set the '
'GOOGLE_CLOUD_PROJECT environment variable.')
self._fcm_url = _MessagingService.FCM_URL.format(project_id)
self._fcm_headers = {
'X-GOOG-API-FORMAT-VERSION': '2',
'X-FIREBASE-CLIENT': 'fire-admin-python/{0}'.format(firebase_admin.__version__),
}
self._client = _http_client.JsonHttpClient(credential=app.credential.get_credential())
self._timeout = app.options.get('httpTimeout')
self._client_version = 'fire-admin-python/{0}'.format(firebase_admin.__version__)
self._transport = _auth.authorized_http(app.credential.get_credential())

@classmethod
def encode_message(cls, message):
Expand All @@ -245,16 +365,10 @@ def encode_message(cls, message):
return cls.JSON_ENCODER.default(message)

def send(self, message, dry_run=False):
data = {'message': _MessagingService.encode_message(message)}
if dry_run:
data['validate_only'] = True
data = self._message_data(message, dry_run)
try:
headers = {
'X-GOOG-API-FORMAT-VERSION': '2',
'X-FIREBASE-CLIENT': self._client_version,
}
resp = self._client.body(
'post', url=self._fcm_url, headers=headers, json=data, timeout=self._timeout)
'post', url=self._fcm_url, headers=self._fcm_headers, json=data, timeout=self._timeout)
except requests.exceptions.RequestException as error:
if error.response is not None:
self._handle_fcm_error(error)
Expand All @@ -264,6 +378,23 @@ def send(self, message, dry_run=False):
else:
return resp['name']

def send_all(self, messages, dry_run=False):
responses = []

def batch_callback(request_id, response, exception):
send_response = SendResponse(response, exception)
responses.append(send_response)

batch = http.BatchHttpRequest(batch_callback, _MessagingService.FCM_BATCH_URL)
for message in messages:
body = json.dumps(self._message_data(message, dry_run))
req = http.HttpRequest(
http=self._transport, postproc=self._postproc, uri=self._fcm_url, method='POST', body=body, headers=self._fcm_headers)
batch.add(req)

batch.execute()
return BatchResponse(responses)

def make_topic_management_request(self, tokens, topic, operation):
"""Invokes the IID service for topic management functionality."""
if isinstance(tokens, six.string_types):
Expand Down Expand Up @@ -299,6 +430,18 @@ def make_topic_management_request(self, tokens, topic, operation):
else:
return TopicManagementResponse(resp)

def _message_data(self, message, dry_run):
data = {'message': _MessagingService.encode_message(message)}
if dry_run:
data['validate_only'] = True
return data

def _postproc(self, resp, body):
if resp.status == 200:
return json.loads(body)
else:
raise Exception('unexpected response')

def _handle_fcm_error(self, error):
"""Handles errors received from the FCM API."""
data = {}
Expand All @@ -309,21 +452,7 @@ def _handle_fcm_error(self, error):
except ValueError:
pass

error_dict = data.get('error', {})
server_code = None
for detail in error_dict.get('details', []):
if detail.get('@type') == 'type.googleapis.com/google.firebase.fcm.v1.FcmError':
server_code = detail.get('errorCode')
break
if not server_code:
server_code = error_dict.get('status')
code = _MessagingService.FCM_ERROR_CODES.get(server_code, _MessagingService.UNKNOWN_ERROR)

msg = error_dict.get('message')
if not msg:
msg = 'Unexpected HTTP response with status: {0}; body: {1}'.format(
error.response.status_code, error.response.content.decode())
raise ApiCallError(code, msg, error)
raise _MessagingService._parse_fcm_error(data, error.response.content, error.response.status_code, error)

def _handle_iid_error(self, error):
"""Handles errors received from the Instance ID API."""
Expand All @@ -342,3 +471,22 @@ def _handle_iid_error(self, error):
msg = 'Unexpected HTTP response with status: {0}; body: {1}'.format(
error.response.status_code, error.response.content.decode())
raise ApiCallError(code, msg, error)

@classmethod
def _parse_fcm_error(cls, data, content, status_code, error):
"""Parses an error response from the FCM API to a ApiCallError."""
error_dict = data.get('error', {})
server_code = None
for detail in error_dict.get('details', []):
if detail.get('@type') == 'type.googleapis.com/google.firebase.fcm.v1.FcmError':
server_code = detail.get('errorCode')
break
if not server_code:
server_code = error_dict.get('status')
code = _MessagingService.FCM_ERROR_CODES.get(server_code, _MessagingService.UNKNOWN_ERROR)

msg = error_dict.get('message')
if not msg:
msg = 'Unexpected HTTP response with status: {0}; body: {1}'.format(status_code, content.decode())

return ApiCallError(code, msg, error)
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ tox >= 3.6.0

cachecontrol >= 0.12.4
google-api-core[grpc] >= 1.7.0, < 2.0.0dev; platform.python_implementation != 'PyPy'
google-api-python-client >= 1.7.8
google-cloud-firestore >= 0.31.0; platform.python_implementation != 'PyPy'
google-cloud-storage >= 1.13.0
six >= 1.6.1
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
install_requires = [
'cachecontrol>=0.12.4',
'google-api-core[grpc] >= 1.7.0, < 2.0.0dev; platform.python_implementation != "PyPy"',
'google-api-python-client >= 1.7.8',
'google-cloud-firestore>=0.31.0; platform.python_implementation != "PyPy"',
'google-cloud-storage>=1.13.0',
'six>=1.6.1'
Expand Down

0 comments on commit 544877a

Please sign in to comment.