Skip to content

Commit baf4991

Browse files
committed
Merged with master
2 parents dbb6970 + bbfa5e8 commit baf4991

File tree

13 files changed

+451
-104
lines changed

13 files changed

+451
-104
lines changed

.travis.yml

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,20 @@ python:
55
- "3.5"
66
- "3.6"
77
- "pypy3.5"
8-
# command to install dependencies
9-
install: "pip install -r requirements.txt"
10-
before_script:
11-
- export PY_VERSION=`python -c 'import sys; print sys.version_info.major'`
12-
- if [[ "$PY_VERSION" == '2' ]]; then ./lint.sh all; fi
13-
# command to run tests
14-
script: pytest
8+
9+
jobs:
10+
include:
11+
- name: "Lint"
12+
python: "2.7"
13+
script: ./lint.sh all
14+
15+
before_install:
16+
- nvm install 8 && npm install -g firebase-tools
17+
script:
18+
- pytest
19+
- firebase emulators:exec --only database --project fake-project-id 'pytest integration/test_db.py'
20+
cache:
21+
pip: true
22+
npm: true
23+
directories:
24+
- $HOME/.cache/firebase/emulators

CONTRIBUTING.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,18 @@ Now you can invoke the integration test suite as follows:
195195
pytest integration/ --cert scripts/cert.json --apikey scripts/apikey.txt
196196
```
197197

198+
### Emulator-based Integration Testing
199+
200+
Some integration tests can run against emulators. This allows local testing
201+
without using real projects or credentials. For now, only the RTDB Emulator
202+
is supported.
203+
204+
First, install the Firebase CLI, then run:
205+
206+
```
207+
firebase emulators:exec --only database --project fake-project-id 'pytest integration/test_db.py'
208+
```
209+
198210
### Test Coverage
199211

200212
To review the test coverage, run `pytest` with the `--cov` flag. To view a detailed line by line

firebase_admin/__init__.py

Lines changed: 30 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -215,39 +215,15 @@ def __init__(self, name, credential, options):
215215
self._options = _AppOptions(options)
216216
self._lock = threading.RLock()
217217
self._services = {}
218-
self._project_id = App._lookup_project_id(self._credential, self._options)
219218

220-
@classmethod
221-
def _lookup_project_id(cls, credential, options):
222-
"""Looks up the Firebase project ID associated with an App.
223-
224-
This method first inspects the app options for a ``projectId`` entry. Then it attempts to
225-
get the project ID from the credential used to initialize the app. If that also fails,
226-
attempts to look up the ``GOOGLE_CLOUD_PROJECT`` and ``GCLOUD_PROJECT`` environment
227-
variables.
228-
229-
Args:
230-
credential: A Firebase credential instance.
231-
options: A Firebase AppOptions instance.
232-
233-
Returns:
234-
str: A project ID string or None.
219+
App._validate_project_id(self._options.get('projectId'))
220+
self._project_id_initialized = False
235221

236-
Raises:
237-
ValueError: If a non-string project ID value is specified.
238-
"""
239-
project_id = options.get('projectId')
240-
if not project_id:
241-
try:
242-
project_id = credential.project_id
243-
except AttributeError:
244-
pass
245-
if not project_id:
246-
project_id = os.environ.get('GOOGLE_CLOUD_PROJECT', os.environ.get('GCLOUD_PROJECT'))
222+
@classmethod
223+
def _validate_project_id(cls, project_id):
247224
if project_id is not None and not isinstance(project_id, six.string_types):
248225
raise ValueError(
249226
'Invalid project ID: "{0}". project ID must be a string.'.format(project_id))
250-
return project_id
251227

252228
@property
253229
def name(self):
@@ -263,8 +239,34 @@ def options(self):
263239

264240
@property
265241
def project_id(self):
242+
if not self._project_id_initialized:
243+
self._project_id = self._lookup_project_id()
244+
self._project_id_initialized = True
266245
return self._project_id
267246

247+
def _lookup_project_id(self):
248+
"""Looks up the Firebase project ID associated with an App.
249+
250+
If a ``projectId`` is specified in app options, it is returned. Then tries to
251+
get the project ID from the credential used to initialize the app. If that also fails,
252+
attempts to look up the ``GOOGLE_CLOUD_PROJECT`` and ``GCLOUD_PROJECT`` environment
253+
variables.
254+
255+
Returns:
256+
str: A project ID string or None.
257+
"""
258+
project_id = self._options.get('projectId')
259+
if not project_id:
260+
try:
261+
project_id = self._credential.project_id
262+
except AttributeError:
263+
pass
264+
if not project_id:
265+
project_id = os.environ.get('GOOGLE_CLOUD_PROJECT',
266+
os.environ.get('GCLOUD_PROJECT'))
267+
App._validate_project_id(self._options.get('projectId'))
268+
return project_id
269+
268270
def _get_service(self, name, initializer):
269271
"""Returns the service instance identified by the given name.
270272

firebase_admin/_messaging_utils.py

Lines changed: 104 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,19 +38,21 @@ class Message(object):
3838
android: An instance of ``messaging.AndroidConfig`` (optional).
3939
webpush: An instance of ``messaging.WebpushConfig`` (optional).
4040
apns: An instance of ``messaging.ApnsConfig`` (optional).
41+
fcm_options: An instance of ``messaging.FcmOptions`` (optional).
4142
token: The registration token of the device to which the message should be sent (optional).
4243
topic: Name of the FCM topic to which the message should be sent (optional). Topic name
4344
may contain the ``/topics/`` prefix.
4445
condition: The FCM condition to which the message should be sent (optional).
4546
"""
4647

4748
def __init__(self, data=None, notification=None, android=None, webpush=None, apns=None,
48-
token=None, topic=None, condition=None):
49+
fcm_options=None, token=None, topic=None, condition=None):
4950
self.data = data
5051
self.notification = notification
5152
self.android = android
5253
self.webpush = webpush
5354
self.apns = apns
55+
self.fcm_options = fcm_options
5456
self.token = token
5557
self.topic = topic
5658
self.condition = condition
@@ -67,8 +69,10 @@ class MulticastMessage(object):
6769
android: An instance of ``messaging.AndroidConfig`` (optional).
6870
webpush: An instance of ``messaging.WebpushConfig`` (optional).
6971
apns: An instance of ``messaging.ApnsConfig`` (optional).
72+
fcm_options: An instance of ``messaging.FcmOptions`` (optional).
7073
"""
71-
def __init__(self, tokens, data=None, notification=None, android=None, webpush=None, apns=None):
74+
def __init__(self, tokens, data=None, notification=None, android=None, webpush=None, apns=None,
75+
fcm_options=None):
7276
_Validators.check_string_list('MulticastMessage.tokens', tokens)
7377
if len(tokens) > 100:
7478
raise ValueError('MulticastMessage.tokens must not contain more than 100 tokens.')
@@ -78,6 +82,7 @@ def __init__(self, tokens, data=None, notification=None, android=None, webpush=N
7882
self.android = android
7983
self.webpush = webpush
8084
self.apns = apns
85+
self.fcm_options = fcm_options
8186

8287

8388
class Notification(object):
@@ -109,16 +114,18 @@ class AndroidConfig(object):
109114
data: A dictionary of data fields (optional). All keys and values in the dictionary must be
110115
strings. When specified, overrides any data fields set via ``Message.data``.
111116
notification: A ``messaging.AndroidNotification`` to be included in the message (optional).
117+
fcm_options: A ``messaging.AndroidFcmOptions`` to be included in the message (optional).
112118
"""
113119

114120
def __init__(self, collapse_key=None, priority=None, ttl=None, restricted_package_name=None,
115-
data=None, notification=None):
121+
data=None, notification=None, fcm_options=None):
116122
self.collapse_key = collapse_key
117123
self.priority = priority
118124
self.ttl = ttl
119125
self.restricted_package_name = restricted_package_name
120126
self.data = data
121127
self.notification = notification
128+
self.fcm_options = fcm_options
122129

123130

124131
class AndroidNotification(object):
@@ -167,6 +174,18 @@ def __init__(self, title=None, body=None, icon=None, color=None, sound=None, tag
167174
self.channel_id = channel_id
168175

169176

177+
class AndroidFcmOptions(object):
178+
"""Options for features provided by the FCM SDK for Android.
179+
180+
Args:
181+
analytics_label: contains additional options for features provided by the FCM Android SDK
182+
(optional).
183+
"""
184+
185+
def __init__(self, analytics_label=None):
186+
self.analytics_label = analytics_label
187+
188+
170189
class WebpushConfig(object):
171190
"""Webpush-specific options that can be included in a message.
172191
@@ -281,14 +300,17 @@ class APNSConfig(object):
281300
Args:
282301
headers: A dictionary of headers (optional).
283302
payload: A ``messaging.APNSPayload`` to be included in the message (optional).
303+
fcm_options: A ``messaging.APNSFcmOptions`` instance to be included in the message
304+
(optional).
284305
285306
.. _APNS Documentation: https://developer.apple.com/library/content/documentation\
286307
/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html
287308
"""
288309

289-
def __init__(self, headers=None, payload=None):
310+
def __init__(self, headers=None, payload=None, fcm_options=None):
290311
self.headers = headers
291312
self.payload = payload
313+
self.fcm_options = fcm_options
292314

293315

294316
class APNSPayload(object):
@@ -389,6 +411,29 @@ def __init__(self, title=None, subtitle=None, body=None, loc_key=None, loc_args=
389411
self.launch_image = launch_image
390412

391413

414+
class APNSFcmOptions(object):
415+
"""Options for features provided by the FCM SDK for iOS.
416+
417+
Args:
418+
analytics_label: contains additional options for features provided by the FCM iOS SDK
419+
(optional).
420+
"""
421+
422+
def __init__(self, analytics_label=None):
423+
self.analytics_label = analytics_label
424+
425+
426+
class FcmOptions(object):
427+
"""Options for features provided by SDK.
428+
429+
Args:
430+
analytics_label: contains additional options to use across all platforms (optional).
431+
"""
432+
433+
def __init__(self, analytics_label=None):
434+
self.analytics_label = analytics_label
435+
436+
392437
class _Validators(object):
393438
"""A collection of data validation utilities.
394439
@@ -444,6 +489,14 @@ def check_string_list(cls, label, value):
444489
raise ValueError('{0} must not contain non-string values.'.format(label))
445490
return value
446491

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

448501
class MessageEncoder(json.JSONEncoder):
449502
"""A custom JSONEncoder implementation for serializing Message instances into JSON."""
@@ -470,13 +523,29 @@ def encode_android(cls, android):
470523
'restricted_package_name': _Validators.check_string(
471524
'AndroidConfig.restricted_package_name', android.restricted_package_name),
472525
'ttl': cls.encode_ttl(android.ttl),
526+
'fcm_options': cls.encode_android_fcm_options(android.fcm_options),
473527
}
474528
result = cls.remove_null_values(result)
475529
priority = result.get('priority')
476530
if priority and priority not in ('high', 'normal'):
477531
raise ValueError('AndroidConfig.priority must be "high" or "normal".')
478532
return result
479533

534+
@classmethod
535+
def encode_android_fcm_options(cls, fcm_options):
536+
"""Encodes a AndroidFcmOptions instance into a json."""
537+
if fcm_options is None:
538+
return None
539+
if not isinstance(fcm_options, AndroidFcmOptions):
540+
raise ValueError('AndroidConfig.fcm_options must be an instance of '
541+
'AndroidFcmOptions class.')
542+
result = {
543+
'analytics_label': _Validators.check_analytics_label(
544+
'AndroidFcmOptions.analytics_label', fcm_options.analytics_label),
545+
}
546+
result = cls.remove_null_values(result)
547+
return result
548+
480549
@classmethod
481550
def encode_ttl(cls, ttl):
482551
"""Encodes a AndroidConfig TTL duration into a string."""
@@ -555,7 +624,7 @@ def encode_webpush(cls, webpush):
555624
'headers': _Validators.check_string_dict(
556625
'WebpushConfig.headers', webpush.headers),
557626
'notification': cls.encode_webpush_notification(webpush.notification),
558-
'fcmOptions': cls.encode_webpush_fcm_options(webpush.fcm_options),
627+
'fcm_options': cls.encode_webpush_fcm_options(webpush.fcm_options),
559628
}
560629
return cls.remove_null_values(result)
561630

@@ -655,6 +724,7 @@ def encode_apns(cls, apns):
655724
'headers': _Validators.check_string_dict(
656725
'APNSConfig.headers', apns.headers),
657726
'payload': cls.encode_apns_payload(apns.payload),
727+
'fcm_options': cls.encode_apns_fcm_options(apns.fcm_options),
658728
}
659729
return cls.remove_null_values(result)
660730

@@ -672,6 +742,20 @@ def encode_apns_payload(cls, payload):
672742
result[key] = value
673743
return cls.remove_null_values(result)
674744

745+
@classmethod
746+
def encode_apns_fcm_options(cls, fcm_options):
747+
"""Encodes an APNSFcmOptions instance into JSON."""
748+
if fcm_options is None:
749+
return None
750+
if not isinstance(fcm_options, APNSFcmOptions):
751+
raise ValueError('APNSConfig.fcm_options must be an instance of APNSFcmOptions class.')
752+
result = {
753+
'analytics_label': _Validators.check_analytics_label(
754+
'APNSFcmOptions.analytics_label', fcm_options.analytics_label),
755+
}
756+
result = cls.remove_null_values(result)
757+
return result
758+
675759
@classmethod
676760
def encode_aps(cls, aps):
677761
"""Encodes an Aps instance into JSON."""
@@ -792,6 +876,7 @@ def default(self, obj): # pylint: disable=method-hidden
792876
'token': _Validators.check_string('Message.token', obj.token, non_empty=True),
793877
'topic': _Validators.check_string('Message.topic', obj.topic, non_empty=True),
794878
'webpush': MessageEncoder.encode_webpush(obj.webpush),
879+
'fcm_options': MessageEncoder.encode_fcm_options(obj.fcm_options),
795880
}
796881
result['topic'] = MessageEncoder.sanitize_topic_name(result.get('topic'))
797882
result = MessageEncoder.remove_null_values(result)
@@ -800,6 +885,20 @@ def default(self, obj): # pylint: disable=method-hidden
800885
raise ValueError('Exactly one of token, topic or condition must be specified.')
801886
return result
802887

888+
@classmethod
889+
def encode_fcm_options(cls, fcm_options):
890+
"""Encodes an FcmOptions instance into JSON."""
891+
if fcm_options is None:
892+
return None
893+
if not isinstance(fcm_options, FcmOptions):
894+
raise ValueError('Message.fcm_options must be an instance of FcmOptions class.')
895+
result = {
896+
'analytics_label': _Validators.check_analytics_label(
897+
'FcmOptions.analytics_label', fcm_options.analytics_label),
898+
}
899+
result = cls.remove_null_values(result)
900+
return result
901+
803902

804903
class ThirdPartyAuthError(exceptions.UnauthenticatedError):
805904
"""APNs certificate or web push auth key was invalid or missing."""

0 commit comments

Comments
 (0)