diff --git a/firebase_admin/_messaging_encoder.py b/firebase_admin/_messaging_encoder.py index 4c0c6daa..34ecea2d 100644 --- a/firebase_admin/_messaging_encoder.py +++ b/firebase_admin/_messaging_encoder.py @@ -19,6 +19,7 @@ import math import numbers import re +import warnings from firebase_admin import _messaging_utils @@ -27,7 +28,7 @@ class Message: """A message that can be sent 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. + contain exactly one of fid, token, topic or condition fields. Args: data: A dictionary of data fields (optional). All keys and values in the dictionary must be @@ -37,20 +38,29 @@ class Message: webpush: An instance of ``messaging.WebpushConfig`` (optional). apns: An instance of ``messaging.ApnsConfig`` (optional). fcm_options: An instance of ``messaging.FCMOptions`` (optional). - token: The registration token of the device to which the message should be sent (optional). + fid: The Firebase installation ID of an FCM registered app instance to which the + message should be sent (optional). + token: Deprecated. Use ``fid`` instead. topic: Name of the FCM topic to which the message should be sent (optional). Topic name may contain the ``/topics/`` prefix. condition: The FCM condition to which the message should be sent (optional). """ def __init__(self, data=None, notification=None, android=None, webpush=None, apns=None, - fcm_options=None, token=None, topic=None, condition=None): + fcm_options=None, token=None, topic=None, condition=None, fid=None): + if token is not None: + warnings.warn( + "Message.token is deprecated. Use fid instead.", + DeprecationWarning, + stacklevel=2 + ) self.data = data self.notification = notification self.android = android self.webpush = webpush self.apns = apns self.fcm_options = fcm_options + self.fid = fid self.token = token self.topic = topic self.condition = condition @@ -60,10 +70,11 @@ def __str__(self): class MulticastMessage: - """A message that can be sent to multiple tokens via Firebase Cloud Messaging. + """A message that can be sent to multiple tokens or fids via Firebase Cloud Messaging. Args: - tokens: A list of registration tokens of targeted devices. + fids: A list of Firebase Installation IDs of targeted app instances (optional). + tokens: Deprecated. Use ``fids`` instead (optional). data: A dictionary of data fields (optional). All keys and values in the dictionary must be strings. notification: An instance of ``messaging.Notification`` (optional). @@ -72,12 +83,32 @@ class MulticastMessage: apns: An instance of ``messaging.ApnsConfig`` (optional). fcm_options: An instance of ``messaging.FCMOptions`` (optional). """ - def __init__(self, tokens, data=None, notification=None, android=None, webpush=None, apns=None, - fcm_options=None): - _Validators.check_string_list('MulticastMessage.tokens', tokens) - if len(tokens) > 500: - raise ValueError('MulticastMessage.tokens must not contain more than 500 tokens.') - self.tokens = tokens + def __init__( + self, tokens=None, fids=None, data=None, notification=None, android=None, + webpush=None, apns=None, fcm_options=None): + if tokens is not None: + warnings.warn( + "MulticastMessage.tokens is deprecated. Use fids instead.", + DeprecationWarning, + stacklevel=2 + ) + + if (tokens is None and fids is None) or (tokens is not None and fids is not None): + raise ValueError("Must specify either 'tokens' or 'fids'.") + + if tokens is not None: + _Validators.check_string_list('MulticastMessage.tokens', tokens) + if len(tokens) > 500: + raise ValueError('MulticastMessage.tokens must not contain more than 500 tokens.') + self.tokens = tokens + self.fids = None + else: + _Validators.check_string_list('MulticastMessage.fids', fids) + if len(fids) > 500: + raise ValueError('MulticastMessage.fids must not contain more than 500 fids.') + self.fids = fids + self.tokens = None + self.data = data self.notification = notification self.android = android @@ -695,6 +726,7 @@ def default(self, o): # pylint: disable=method-hidden 'Message.condition', o.condition, non_empty=True), 'data': _Validators.check_string_dict('Message.data', o.data), 'notification': MessageEncoder.encode_notification(o.notification), + 'fid': _Validators.check_string('Message.fid', o.fid, non_empty=True), 'token': _Validators.check_string('Message.token', o.token, non_empty=True), 'topic': _Validators.check_string('Message.topic', o.topic, non_empty=True), 'webpush': MessageEncoder.encode_webpush(o.webpush), @@ -702,9 +734,9 @@ def default(self, o): # pylint: disable=method-hidden } result['topic'] = MessageEncoder.sanitize_topic_name(result.get('topic')) result = MessageEncoder.remove_null_values(result) - target_count = sum(t in result for t in ['token', 'topic', 'condition']) + target_count = sum(t in result for t in ['fid', 'token', 'topic', 'condition']) if target_count != 1: - raise ValueError('Exactly one of token, topic or condition must be specified.') + raise ValueError('Exactly one of fid, token, topic or condition must be specified.') return result @classmethod diff --git a/firebase_admin/messaging.py b/firebase_admin/messaging.py index 74904443..e326fd36 100644 --- a/firebase_admin/messaging.py +++ b/firebase_admin/messaging.py @@ -20,6 +20,7 @@ import json import asyncio import logging +import warnings import requests import httpx @@ -172,13 +173,40 @@ async def send_each_async( """ return await _get_messaging_service(app).send_each_async(messages, dry_run) +def _get_messages_from_multicast(multicast_message: MulticastMessage) -> List[Message]: + """Extracts individual Message objects from a MulticastMessage.""" + if not isinstance(multicast_message, MulticastMessage): + raise ValueError('Message must be an instance of messaging.MulticastMessage class.') + if multicast_message.tokens is not None: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + return [Message( + data=multicast_message.data, + notification=multicast_message.notification, + android=multicast_message.android, + webpush=multicast_message.webpush, + apns=multicast_message.apns, + fcm_options=multicast_message.fcm_options, + token=token + ) for token in multicast_message.tokens] + + return [Message( + data=multicast_message.data, + notification=multicast_message.notification, + android=multicast_message.android, + webpush=multicast_message.webpush, + apns=multicast_message.apns, + fcm_options=multicast_message.fcm_options, + fid=fid + ) for fid in multicast_message.fids] + async def send_each_for_multicast_async( multicast_message: MulticastMessage, dry_run: bool = False, app: Optional[App] = None ) -> BatchResponse: - """Sends the given mutlicast message to each token asynchronously via Firebase Cloud Messaging - (FCM). + """Sends the given multicast message to each token or fid asynchronously 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. @@ -195,21 +223,11 @@ async def send_each_for_multicast_async( FirebaseError: If an error occurs while sending the message to the FCM service. ValueError: If the input arguments are invalid. """ - if not isinstance(multicast_message, MulticastMessage): - raise ValueError('Message must be an instance of messaging.MulticastMessage class.') - messages = [Message( - data=multicast_message.data, - notification=multicast_message.notification, - android=multicast_message.android, - webpush=multicast_message.webpush, - apns=multicast_message.apns, - fcm_options=multicast_message.fcm_options, - token=token - ) for token in multicast_message.tokens] + messages = _get_messages_from_multicast(multicast_message) return await _get_messaging_service(app).send_each_async(messages, dry_run) def send_each_for_multicast(multicast_message, dry_run=False, app=None): - """Sends the given mutlicast message to each token via Firebase Cloud Messaging (FCM). + """Sends the given multicast message to each token or fid 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. @@ -226,17 +244,7 @@ def send_each_for_multicast(multicast_message, dry_run=False, app=None): FirebaseError: If an error occurs while sending the message to the FCM service. ValueError: If the input arguments are invalid. """ - if not isinstance(multicast_message, MulticastMessage): - raise ValueError('Message must be an instance of messaging.MulticastMessage class.') - messages = [Message( - data=multicast_message.data, - notification=multicast_message.notification, - android=multicast_message.android, - webpush=multicast_message.webpush, - apns=multicast_message.apns, - fcm_options=multicast_message.fcm_options, - token=token - ) for token in multicast_message.tokens] + messages = _get_messages_from_multicast(multicast_message) return _get_messaging_service(app).send_each(messages, dry_run) def subscribe_to_topic(tokens, topic, app=None): diff --git a/tests/test_messaging.py b/tests/test_messaging.py index b30790f1..f27322a7 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -73,18 +73,22 @@ class TestMessageStr: messaging.Message(topic='topic', condition='condition'), messaging.Message(condition='condition', token='token'), messaging.Message(topic='topic', token='token', condition='condition'), + messaging.Message(fid='fid', token='token'), + messaging.Message(fid='fid', topic='topic'), + messaging.Message(fid='fid', condition='condition'), + messaging.Message(fid='fid', token='token', topic='topic'), ]) def test_invalid_target_message(self, msg): with pytest.raises(ValueError) as excinfo: str(msg) assert str( - excinfo.value) == 'Exactly one of token, topic or condition must be specified.' + excinfo.value) == 'Exactly one of fid, token, topic or condition must be specified.' def test_empty_message(self): assert str(messaging.Message(token='value')) == '{"token": "value"}' assert str(messaging.Message(topic='value')) == '{"topic": "value"}' - assert str(messaging.Message(condition='value') - ) == '{"condition": "value"}' + assert str(messaging.Message(condition='value')) == '{"condition": "value"}' + assert str(messaging.Message(fid='value')) == '{"fid": "value"}' def test_data_message(self): assert str(messaging.Message(topic='topic', data={}) @@ -95,6 +99,15 @@ def test_data_message(self): class TestMulticastMessage: + def test_invalid_targets(self): + with pytest.raises(ValueError) as excinfo: + messaging.MulticastMessage() + assert str(excinfo.value) == "Must specify either 'tokens' or 'fids'." + + with pytest.raises(ValueError) as excinfo: + messaging.MulticastMessage(tokens=['token'], fids=['fid']) + assert str(excinfo.value) == "Must specify either 'tokens' or 'fids'." + @pytest.mark.parametrize('tokens', NON_LIST_ARGS) def test_invalid_tokens_type(self, tokens): with pytest.raises(ValueError) as excinfo: @@ -119,6 +132,40 @@ def test_tokens_type(self): message = messaging.MulticastMessage(tokens=['token' for _ in range(0, 500)]) assert len(message.tokens) == 500 + @pytest.mark.parametrize('fids', NON_LIST_ARGS) + def test_invalid_fids_type(self, fids): + with pytest.raises(ValueError) as excinfo: + messaging.MulticastMessage(fids=fids) + if isinstance(fids, list): + expected = 'MulticastMessage.fids must not contain non-string values.' + assert str(excinfo.value) == expected + else: + expected = 'MulticastMessage.fids must be a list of strings.' + assert str(excinfo.value) == expected + + def test_fids_over_500(self): + with pytest.raises(ValueError) as excinfo: + messaging.MulticastMessage(fids=['fid' for _ in range(0, 501)]) + expected = 'MulticastMessage.fids must not contain more than 500 fids.' + assert str(excinfo.value) == expected + + def test_fids_type(self): + message = messaging.MulticastMessage(fids=['fid']) + assert len(message.fids) == 1 + + message = messaging.MulticastMessage(fids=['fid' for _ in range(0, 500)]) + assert len(message.fids) == 500 + + def test_tokens_deprecation_warning(self): + msg = 'MulticastMessage.tokens is deprecated. Use fids instead.' + with pytest.warns(DeprecationWarning, match=msg): + messaging.MulticastMessage(tokens=['token']) + + def test_tokens_deprecation_warning_positional(self): + msg = 'MulticastMessage.tokens is deprecated. Use fids instead.' + with pytest.warns(DeprecationWarning, match=msg): + messaging.MulticastMessage(['token']) + class TestMessageEncoder: @@ -128,11 +175,16 @@ class TestMessageEncoder: messaging.Message(topic='topic', condition='condition'), messaging.Message(condition='condition', token='token'), messaging.Message(topic='topic', token='token', condition='condition'), + messaging.Message(fid='fid', token='token'), + messaging.Message(fid='fid', topic='topic'), + messaging.Message(fid='fid', condition='condition'), + messaging.Message(fid='fid', token='token', topic='topic'), ]) def test_invalid_target_message(self, msg): with pytest.raises(ValueError) as excinfo: check_encoding(msg) - assert str(excinfo.value) == 'Exactly one of token, topic or condition must be specified.' + expected = 'Exactly one of fid, token, topic or condition must be specified.' + assert str(excinfo.value) == expected @pytest.mark.parametrize('target', NON_STRING_ARGS + ['']) def test_invalid_token(self, target): @@ -140,6 +192,12 @@ def test_invalid_token(self, target): check_encoding(messaging.Message(token=target)) assert str(excinfo.value) == 'Message.token must be a non-empty string.' + @pytest.mark.parametrize('target', NON_STRING_ARGS + ['']) + def test_invalid_fid(self, target): + with pytest.raises(ValueError) as excinfo: + check_encoding(messaging.Message(fid=target)) + assert str(excinfo.value) == 'Message.fid must be a non-empty string.' + @pytest.mark.parametrize('target', NON_STRING_ARGS + ['']) def test_invalid_topic(self, target): with pytest.raises(ValueError) as excinfo: @@ -159,9 +217,15 @@ def test_malformed_topic_name(self, topic): def test_empty_message(self): check_encoding(messaging.Message(token='value'), {'token': 'value'}) + check_encoding(messaging.Message(fid='value'), {'fid': 'value'}) check_encoding(messaging.Message(topic='value'), {'topic': 'value'}) check_encoding(messaging.Message(condition='value'), {'condition': 'value'}) + def test_token_deprecation_warning(self): + msg = 'Message.token is deprecated. Use fid instead.' + with pytest.warns(DeprecationWarning, match=msg): + messaging.Message(token='value') + @pytest.mark.parametrize('data', NON_DICT_ARGS) def test_invalid_data_message(self, data): with pytest.raises(ValueError): @@ -2212,6 +2276,62 @@ def test_send_each_for_multicast(self): assert all(r.success for r in batch_response.responses) assert not any(r.exception for r in batch_response.responses) + def test_send_each_for_multicast_fids(self): + payload1 = json.dumps({'name': 'message-id1'}) + payload2 = json.dumps({'name': 'message-id2'}) + _ = self._instrument_messaging_service( + response_dict={'foo1': [200, payload1], 'foo2': [200, payload2]}) + msg = messaging.MulticastMessage(fids=['foo1', 'foo2']) + batch_response = messaging.send_each_for_multicast(msg, dry_run=True) + assert batch_response.success_count == 2 + assert batch_response.failure_count == 0 + assert len(batch_response.responses) == 2 + assert [r.message_id for r in batch_response.responses] == ['message-id1', 'message-id2'] + assert all(r.success for r in batch_response.responses) + assert not any(r.exception for r in batch_response.responses) + + @respx.mock + @pytest.mark.asyncio + async def test_send_each_for_multicast_async(self): + responses = [ + respx.MockResponse(200, http_version='HTTP/2', json={'name': 'message-id1'}), + respx.MockResponse(200, http_version='HTTP/2', json={'name': 'message-id2'}), + ] + msg = messaging.MulticastMessage(tokens=['foo1', 'foo2']) + route = respx.request( + method='POST', + url='https://fcm.googleapis.com/v1/projects/explicit-project-id/messages:send' + ) + route.side_effect = responses + batch_response = await messaging.send_each_for_multicast_async(msg, dry_run=True) + assert batch_response.success_count == 2 + assert batch_response.failure_count == 0 + assert len(batch_response.responses) == 2 + assert [r.message_id for r in batch_response.responses] == ['message-id1', 'message-id2'] + assert all(r.success for r in batch_response.responses) + assert not any(r.exception for r in batch_response.responses) + + @respx.mock + @pytest.mark.asyncio + async def test_send_each_for_multicast_async_fids(self): + responses = [ + respx.MockResponse(200, http_version='HTTP/2', json={'name': 'message-id1'}), + respx.MockResponse(200, http_version='HTTP/2', json={'name': 'message-id2'}), + ] + msg = messaging.MulticastMessage(fids=['foo1', 'foo2']) + route = respx.request( + method='POST', + url='https://fcm.googleapis.com/v1/projects/explicit-project-id/messages:send' + ) + route.side_effect = responses + batch_response = await messaging.send_each_for_multicast_async(msg, dry_run=True) + assert batch_response.success_count == 2 + assert batch_response.failure_count == 0 + assert len(batch_response.responses) == 2 + assert [r.message_id for r in batch_response.responses] == ['message-id1', 'message-id2'] + assert all(r.success for r in batch_response.responses) + assert not any(r.exception for r in batch_response.responses) + @pytest.mark.parametrize('status', HTTP_ERROR_CODES) def test_send_each_for_multicast_detailed_error(self, status): success_payload = json.dumps({'name': 'message-id'})