Skip to content

feat(fcm): Analytics label based on @chemidy #310

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jul 19, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 104 additions & 5 deletions firebase_admin/_messaging_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,21 @@ class Message(object):
android: An instance of ``messaging.AndroidConfig`` (optional).
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).
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,
token=None, topic=None, condition=None):
fcm_options=None, token=None, topic=None, condition=None):
self.data = data
self.notification = notification
self.android = android
self.webpush = webpush
self.apns = apns
self.fcm_options = fcm_options
self.token = token
self.topic = topic
self.condition = condition
Expand All @@ -65,8 +67,10 @@ class MulticastMessage(object):
android: An instance of ``messaging.AndroidConfig`` (optional).
webpush: An instance of ``messaging.WebpushConfig`` (optional).
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):
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) > 100:
raise ValueError('MulticastMessage.tokens must not contain more than 100 tokens.')
Expand All @@ -76,6 +80,7 @@ def __init__(self, tokens, data=None, notification=None, android=None, webpush=N
self.android = android
self.webpush = webpush
self.apns = apns
self.fcm_options = fcm_options


class Notification(object):
Expand Down Expand Up @@ -107,16 +112,18 @@ class AndroidConfig(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.AndroidNotification`` to be included in the message (optional).
fcm_options: A ``messaging.AndroidFcmOptions`` to be included in the message (optional).
"""

def __init__(self, collapse_key=None, priority=None, ttl=None, restricted_package_name=None,
data=None, notification=None):
data=None, notification=None, fcm_options=None):
self.collapse_key = collapse_key
self.priority = priority
self.ttl = ttl
self.restricted_package_name = restricted_package_name
self.data = data
self.notification = notification
self.fcm_options = fcm_options


class AndroidNotification(object):
Expand Down Expand Up @@ -165,6 +172,18 @@ def __init__(self, title=None, body=None, icon=None, color=None, sound=None, tag
self.channel_id = channel_id


class AndroidFcmOptions(object):
"""Options for features provided by the FCM SDK for Android.

Args:
analytics_label: contains additional options for features provided by the FCM Android SDK
(optional).
"""

def __init__(self, analytics_label=None):
self.analytics_label = analytics_label


class WebpushConfig(object):
"""Webpush-specific options that can be included in a message.

Expand Down Expand Up @@ -279,14 +298,17 @@ class APNSConfig(object):
Args:
headers: A dictionary of headers (optional).
payload: A ``messaging.APNSPayload`` to be included in the message (optional).
fcm_options: A ``messaging.APNSFcmOptions`` instance to be included in the message
(optional).

.. _APNS Documentation: https://developer.apple.com/library/content/documentation\
/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html
"""

def __init__(self, headers=None, payload=None):
def __init__(self, headers=None, payload=None, fcm_options=None):
self.headers = headers
self.payload = payload
self.fcm_options = fcm_options


class APNSPayload(object):
Expand Down Expand Up @@ -387,6 +409,29 @@ def __init__(self, title=None, subtitle=None, body=None, loc_key=None, loc_args=
self.launch_image = launch_image


class APNSFcmOptions(object):
"""Options for features provided by the FCM SDK for iOS.

Args:
analytics_label: contains additional options for features provided by the FCM iOS SDK
(optional).
"""

def __init__(self, analytics_label=None):
self.analytics_label = analytics_label


class FcmOptions(object):
"""Options for features provided by SDK.

Args:
analytics_label: contains additional options to use across all platforms (optional).
"""

def __init__(self, analytics_label=None):
self.analytics_label = analytics_label


class _Validators(object):
"""A collection of data validation utilities.

Expand Down Expand Up @@ -442,6 +487,14 @@ def check_string_list(cls, label, value):
raise ValueError('{0} must not contain non-string values.'.format(label))
return value

@classmethod
def check_analytics_label(cls, label, value):
"""Checks if the given value is a valid analytics label."""
value = _Validators.check_string(label, value)
if value is not None and not re.match(r'^[a-zA-Z0-9-_.~%]{1,50}$', value):
raise ValueError('Malformed {}.'.format(label))
return value


class MessageEncoder(json.JSONEncoder):
"""A custom JSONEncoder implementation for serializing Message instances into JSON."""
Expand All @@ -468,13 +521,29 @@ def encode_android(cls, android):
'restricted_package_name': _Validators.check_string(
'AndroidConfig.restricted_package_name', android.restricted_package_name),
'ttl': cls.encode_ttl(android.ttl),
'fcm_options': cls.encode_android_fcm_options(android.fcm_options),
}
result = cls.remove_null_values(result)
priority = result.get('priority')
if priority and priority not in ('high', 'normal'):
raise ValueError('AndroidConfig.priority must be "high" or "normal".')
return result

@classmethod
def encode_android_fcm_options(cls, fcm_options):
"""Encodes a AndroidFcmOptions instance into a json."""
if fcm_options is None:
return None
if not isinstance(fcm_options, AndroidFcmOptions):
raise ValueError('AndroidConfig.fcm_options must be an instance of '
'AndroidFcmOptions class.')
result = {
'analytics_label': _Validators.check_analytics_label(
'AndroidFcmOptions.analytics_label', fcm_options.analytics_label),
}
result = cls.remove_null_values(result)
return result

@classmethod
def encode_ttl(cls, ttl):
"""Encodes a AndroidConfig TTL duration into a string."""
Expand Down Expand Up @@ -553,7 +622,7 @@ def encode_webpush(cls, webpush):
'headers': _Validators.check_string_dict(
'WebpushConfig.headers', webpush.headers),
'notification': cls.encode_webpush_notification(webpush.notification),
'fcmOptions': cls.encode_webpush_fcm_options(webpush.fcm_options),
'fcm_options': cls.encode_webpush_fcm_options(webpush.fcm_options),
}
return cls.remove_null_values(result)

Expand Down Expand Up @@ -653,6 +722,7 @@ def encode_apns(cls, apns):
'headers': _Validators.check_string_dict(
'APNSConfig.headers', apns.headers),
'payload': cls.encode_apns_payload(apns.payload),
'fcm_options': cls.encode_apns_fcm_options(apns.fcm_options),
}
return cls.remove_null_values(result)

Expand All @@ -670,6 +740,20 @@ def encode_apns_payload(cls, payload):
result[key] = value
return cls.remove_null_values(result)

@classmethod
def encode_apns_fcm_options(cls, fcm_options):
"""Encodes an APNSFcmOptions instance into JSON."""
if fcm_options is None:
return None
if not isinstance(fcm_options, APNSFcmOptions):
raise ValueError('APNSConfig.fcm_options must be an instance of APNSFcmOptions class.')
result = {
'analytics_label': _Validators.check_analytics_label(
'APNSFcmOptions.analytics_label', fcm_options.analytics_label),
}
result = cls.remove_null_values(result)
return result

@classmethod
def encode_aps(cls, aps):
"""Encodes an Aps instance into JSON."""
Expand Down Expand Up @@ -790,10 +874,25 @@ def default(self, obj): # pylint: disable=method-hidden
'token': _Validators.check_string('Message.token', obj.token, non_empty=True),
'topic': _Validators.check_string('Message.topic', obj.topic, non_empty=True),
'webpush': MessageEncoder.encode_webpush(obj.webpush),
'fcm_options': MessageEncoder.encode_fcm_options(obj.fcm_options),
}
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']])
if target_count != 1:
raise ValueError('Exactly one of token, topic or condition must be specified.')
return result

@classmethod
def encode_fcm_options(cls, fcm_options):
"""Encodes an FcmOptions instance into JSON."""
if fcm_options is None:
return None
if not isinstance(fcm_options, FcmOptions):
raise ValueError('Message.fcm_options must be an instance of FcmOptions class.')
result = {
'analytics_label': _Validators.check_analytics_label(
'FcmOptions.analytics_label', fcm_options.analytics_label),
}
result = cls.remove_null_values(result)
return result
7 changes: 7 additions & 0 deletions firebase_admin/messaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,18 @@

__all__ = [
'AndroidConfig',
'AndroidFcmOptions',
'AndroidNotification',
'APNSConfig',
'APNSFcmOptions',
'APNSPayload',
'ApiCallError',
'Aps',
'ApsAlert',
'BatchResponse',
'CriticalSound',
'ErrorInfo',
'FcmOptions',
'Message',
'MulticastMessage',
'Notification',
Expand All @@ -61,12 +64,15 @@


AndroidConfig = _messaging_utils.AndroidConfig
AndroidFcmOptions = _messaging_utils.AndroidFcmOptions
AndroidNotification = _messaging_utils.AndroidNotification
APNSConfig = _messaging_utils.APNSConfig
APNSFcmOptions = _messaging_utils.APNSFcmOptions
APNSPayload = _messaging_utils.APNSPayload
Aps = _messaging_utils.Aps
ApsAlert = _messaging_utils.ApsAlert
CriticalSound = _messaging_utils.CriticalSound
FcmOptions = _messaging_utils.FcmOptions
Message = _messaging_utils.Message
MulticastMessage = _messaging_utils.MulticastMessage
Notification = _messaging_utils.Notification
Expand Down Expand Up @@ -145,6 +151,7 @@ def send_multicast(multicast_message, dry_run=False, app=None):
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 _get_messaging_service(app).send_all(messages, dry_run)
Expand Down
66 changes: 63 additions & 3 deletions tests/test_messaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@ def test_data_message(self):
def test_prefixed_topic(self):
check_encoding(messaging.Message(topic='/topics/topic'), {'topic': 'topic'})

def test_fcm_options(self):
check_encoding(
messaging.Message(
topic='topic', fcm_options=messaging.FcmOptions('analytics_label_v1')),
{'topic': 'topic', 'fcm_options': {'analytics_label': 'analytics_label_v1'}})
check_encoding(
messaging.Message(topic='topic', fcm_options=messaging.FcmOptions()),
{'topic': 'topic'})


class TestNotificationEncoder(object):

Expand Down Expand Up @@ -157,6 +166,47 @@ def test_notification_message(self):
{'topic': 'topic', 'notification': {'title': 't'}})


class TestFcmOptionEncoder(object):

@pytest.mark.parametrize('label', [
'!',
'THIS_IS_LONGER_THAN_50_CHARACTERS_WHICH_IS_NOT_ALLOWED',
'',
])
def test_invalid_fcm_options(self, label):
with pytest.raises(ValueError) as excinfo:
check_encoding(messaging.Message(
topic='topic',
fcm_options=messaging.FcmOptions(label)
))
expected = 'Malformed FcmOptions.analytics_label.'
assert str(excinfo.value) == expected

def test_fcm_options(self):
check_encoding(
messaging.Message(
topic='topic',
fcm_options=messaging.FcmOptions(),
android=messaging.AndroidConfig(fcm_options=messaging.AndroidFcmOptions()),
apns=messaging.APNSConfig(fcm_options=messaging.APNSFcmOptions())
),
{'topic': 'topic'})
check_encoding(
messaging.Message(
topic='topic',
fcm_options=messaging.FcmOptions('message-label'),
android=messaging.AndroidConfig(
fcm_options=messaging.AndroidFcmOptions('android-label')),
apns=messaging.APNSConfig(fcm_options=messaging.APNSFcmOptions('apns-label'))
),
{
'topic': 'topic',
'fcm_options': {'analytics_label': 'message-label'},
'android': {'fcm_options': {'analytics_label': 'android-label'}},
'apns': {'fcm_options': {'analytics_label': 'apns-label'}},
})


class TestAndroidConfigEncoder(object):

@pytest.mark.parametrize('data', NON_OBJECT_ARGS)
Expand Down Expand Up @@ -216,7 +266,8 @@ def test_android_config(self):
restricted_package_name='package',
priority='high',
ttl=123,
data={'k1': 'v1', 'k2': 'v2'}
data={'k1': 'v1', 'k2': 'v2'},
fcm_options=messaging.AndroidFcmOptions('analytics_label_v1')
)
)
expected = {
Expand All @@ -230,6 +281,9 @@ def test_android_config(self):
'k1': 'v1',
'k2': 'v2',
},
'fcm_options': {
'analytics_label': 'analytics_label_v1',
},
},
}
check_encoding(msg, expected)
Expand Down Expand Up @@ -484,7 +538,7 @@ def test_webpush_notification(self):
expected = {
'topic': 'topic',
'webpush': {
'fcmOptions': {
'fcm_options': {
'link': 'https://example',
},
},
Expand Down Expand Up @@ -714,7 +768,10 @@ def test_invalid_headers(self, data):
def test_apns_config(self):
msg = messaging.Message(
topic='topic',
apns=messaging.APNSConfig(headers={'h1': 'v1', 'h2': 'v2'})
apns=messaging.APNSConfig(
headers={'h1': 'v1', 'h2': 'v2'},
fcm_options=messaging.APNSFcmOptions('analytics_label_v1')
),
)
expected = {
'topic': 'topic',
Expand All @@ -723,6 +780,9 @@ def test_apns_config(self):
'h1': 'v1',
'h2': 'v2',
},
'fcm_options': {
'analytics_label': 'analytics_label_v1',
},
},
}
check_encoding(msg, expected)
Expand Down