Skip to content

Commit aecd35e

Browse files
authored
Support RTDB Emulator via FIREBASE_DATABASE_EMULATOR_HOST. (#313)
* Support RTDB Emulator via FIREBASE_DATABASE_EMULATOR_HOST. * Fix linter issues. * Defer ApplicationDefault init and remove FakeCredential class. * Fix lazy initialization and tests. * Address PR feedback and clean up URL parsing logic. * Use non-global app for db tests. * Simplify app project_id initialization logic. * Docstring. * Simplify parsing logic again! * Docstring and indentation fix. * Return!
1 parent 8cf7291 commit aecd35e

File tree

9 files changed

+260
-89
lines changed

9 files changed

+260
-89
lines changed

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/credentials.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,26 +123,40 @@ class ApplicationDefault(Base):
123123
"""A Google Application Default credential."""
124124

125125
def __init__(self):
126-
"""Initializes the Application Default credentials for the current environment.
126+
"""Creates an instance that will use Application Default credentials.
127127
128-
Raises:
129-
google.auth.exceptions.DefaultCredentialsError: If Application Default
130-
credentials cannot be initialized in the current environment.
128+
The credentials will be lazily initialized when get_credential() or
129+
project_id() is called. See those methods for possible errors raised.
131130
"""
132131
super(ApplicationDefault, self).__init__()
133-
self._g_credential, self._project_id = google.auth.default(scopes=_scopes)
132+
self._g_credential = None # Will be lazily-loaded via _load_credential().
134133

135134
def get_credential(self):
136135
"""Returns the underlying Google credential.
137136
137+
Raises:
138+
google.auth.exceptions.DefaultCredentialsError: If Application Default
139+
credentials cannot be initialized in the current environment.
138140
Returns:
139141
google.auth.credentials.Credentials: A Google Auth credential instance."""
142+
self._load_credential()
140143
return self._g_credential
141144

142145
@property
143146
def project_id(self):
147+
"""Returns the project_id from the underlying Google credential.
148+
149+
Raises:
150+
google.auth.exceptions.DefaultCredentialsError: If Application Default
151+
credentials cannot be initialized in the current environment.
152+
Returns:
153+
str: The project id."""
154+
self._load_credential()
144155
return self._project_id
145156

157+
def _load_credential(self):
158+
if not self._g_credential:
159+
self._g_credential, self._project_id = google.auth.default(scopes=_scopes)
146160

147161
class RefreshToken(Base):
148162
"""A credential initialized from an existing refresh token."""

firebase_admin/db.py

Lines changed: 107 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@
2222

2323
import collections
2424
import json
25+
import os
2526
import sys
2627
import threading
2728

29+
import google.auth
2830
import requests
2931
import six
3032
from six.moves import urllib
@@ -41,6 +43,7 @@
4143
_USER_AGENT = 'Firebase/HTTP/{0}/{1}.{2}/AdminPython'.format(
4244
firebase_admin.__version__, sys.version_info.major, sys.version_info.minor)
4345
_TRANSACTION_MAX_RETRIES = 25
46+
_EMULATOR_HOST_ENV_VAR = 'FIREBASE_DATABASE_EMULATOR_HOST'
4447

4548

4649
def reference(path='/', app=None, url=None):
@@ -768,46 +771,108 @@ class _DatabaseService(object):
768771
_DEFAULT_AUTH_OVERRIDE = '_admin_'
769772

770773
def __init__(self, app):
771-
self._credential = app.credential.get_credential()
774+
self._credential = app.credential
772775
db_url = app.options.get('databaseURL')
773776
if db_url:
774-
self._db_url = _DatabaseService._validate_url(db_url)
777+
_DatabaseService._parse_db_url(db_url) # Just for validation.
778+
self._db_url = db_url
775779
else:
776780
self._db_url = None
777781
auth_override = _DatabaseService._get_auth_override(app)
778782
if auth_override != self._DEFAULT_AUTH_OVERRIDE and auth_override != {}:
779-
encoded = json.dumps(auth_override, separators=(',', ':'))
780-
self._auth_override = 'auth_variable_override={0}'.format(encoded)
783+
self._auth_override = json.dumps(auth_override, separators=(',', ':'))
781784
else:
782785
self._auth_override = None
783786
self._timeout = app.options.get('httpTimeout')
784787
self._clients = {}
785788

786-
def get_client(self, base_url=None):
787-
if base_url is None:
788-
base_url = self._db_url
789-
base_url = _DatabaseService._validate_url(base_url)
790-
if base_url not in self._clients:
791-
client = _Client(self._credential, base_url, self._auth_override, self._timeout)
792-
self._clients[base_url] = client
793-
return self._clients[base_url]
789+
emulator_host = os.environ.get(_EMULATOR_HOST_ENV_VAR)
790+
if emulator_host:
791+
if '//' in emulator_host:
792+
raise ValueError(
793+
'Invalid {0}: "{1}". It must follow format "host:port".'.format(
794+
_EMULATOR_HOST_ENV_VAR, emulator_host))
795+
self._emulator_host = emulator_host
796+
else:
797+
self._emulator_host = None
798+
799+
def get_client(self, db_url=None):
800+
"""Creates a client based on the db_url. Clients may be cached."""
801+
if db_url is None:
802+
db_url = self._db_url
803+
804+
base_url, namespace = _DatabaseService._parse_db_url(db_url, self._emulator_host)
805+
if base_url == 'https://{0}.firebaseio.com'.format(namespace):
806+
# Production base_url. No need to specify namespace in query params.
807+
params = {}
808+
credential = self._credential.get_credential()
809+
else:
810+
# Emulator base_url. Use fake credentials and specify ?ns=foo in query params.
811+
credential = _EmulatorAdminCredentials()
812+
params = {'ns': namespace}
813+
if self._auth_override:
814+
params['auth_variable_override'] = self._auth_override
815+
816+
client_cache_key = (base_url, json.dumps(params, sort_keys=True))
817+
if client_cache_key not in self._clients:
818+
client = _Client(credential, base_url, self._timeout, params)
819+
self._clients[client_cache_key] = client
820+
return self._clients[client_cache_key]
794821

795822
@classmethod
796-
def _validate_url(cls, url):
797-
"""Parses and validates a given database URL."""
823+
def _parse_db_url(cls, url, emulator_host=None):
824+
"""Parses (base_url, namespace) from a database URL.
825+
826+
The input can be either a production URL (https://foo-bar.firebaseio.com/)
827+
or an Emulator URL (http://localhost:8080/?ns=foo-bar). In case of Emulator
828+
URL, the namespace is extracted from the query param ns. The resulting
829+
base_url never includes query params.
830+
831+
If url is a production URL and emulator_host is specified, the result
832+
base URL will use emulator_host instead. emulator_host is ignored
833+
if url is already an emulator URL.
834+
"""
798835
if not url or not isinstance(url, six.string_types):
799836
raise ValueError(
800837
'Invalid database URL: "{0}". Database URL must be a non-empty '
801838
'URL string.'.format(url))
802-
parsed = urllib.parse.urlparse(url)
803-
if parsed.scheme != 'https':
839+
parsed_url = urllib.parse.urlparse(url)
840+
if parsed_url.netloc.endswith('.firebaseio.com'):
841+
return cls._parse_production_url(parsed_url, emulator_host)
842+
else:
843+
return cls._parse_emulator_url(parsed_url)
844+
845+
@classmethod
846+
def _parse_production_url(cls, parsed_url, emulator_host):
847+
"""Parses production URL like https://foo-bar.firebaseio.com/"""
848+
if parsed_url.scheme != 'https':
804849
raise ValueError(
805-
'Invalid database URL: "{0}". Database URL must be an HTTPS URL.'.format(url))
806-
elif not parsed.netloc.endswith('.firebaseio.com'):
850+
'Invalid database URL scheme: "{0}". Database URL must be an HTTPS URL.'.format(
851+
parsed_url.scheme))
852+
namespace = parsed_url.netloc.split('.')[0]
853+
if not namespace:
807854
raise ValueError(
808855
'Invalid database URL: "{0}". Database URL must be a valid URL to a '
809-
'Firebase Realtime Database instance.'.format(url))
810-
return 'https://{0}'.format(parsed.netloc)
856+
'Firebase Realtime Database instance.'.format(parsed_url.geturl()))
857+
858+
if emulator_host:
859+
base_url = 'http://{0}'.format(emulator_host)
860+
else:
861+
base_url = 'https://{0}'.format(parsed_url.netloc)
862+
return base_url, namespace
863+
864+
@classmethod
865+
def _parse_emulator_url(cls, parsed_url):
866+
"""Parses emulator URL like http://localhost:8080/?ns=foo-bar"""
867+
query_ns = urllib.parse.parse_qs(parsed_url.query).get('ns')
868+
if parsed_url.scheme != 'http' or (not query_ns or len(query_ns) != 1 or not query_ns[0]):
869+
raise ValueError(
870+
'Invalid database URL: "{0}". Database URL must be a valid URL to a '
871+
'Firebase Realtime Database instance.'.format(parsed_url.geturl()))
872+
873+
namespace = query_ns[0]
874+
base_url = '{0}://{1}'.format(parsed_url.scheme, parsed_url.netloc)
875+
return base_url, namespace
811876

812877
@classmethod
813878
def _get_auth_override(cls, app):
@@ -833,7 +898,7 @@ class _Client(_http_client.JsonHttpClient):
833898
marshalling and unmarshalling of JSON data.
834899
"""
835900

836-
def __init__(self, credential, base_url, auth_override, timeout):
901+
def __init__(self, credential, base_url, timeout, params=None):
837902
"""Creates a new _Client from the given parameters.
838903
839904
This exists primarily to enable testing. For regular use, obtain _Client instances by
@@ -843,22 +908,21 @@ def __init__(self, credential, base_url, auth_override, timeout):
843908
credential: A Google credential that can be used to authenticate requests.
844909
base_url: A URL prefix to be added to all outgoing requests. This is typically the
845910
Firebase Realtime Database URL.
846-
auth_override: The encoded auth_variable_override query parameter to be included in
847-
outgoing requests.
848911
timeout: HTTP request timeout in seconds. If not set connections will never
849912
timeout, which is the default behavior of the underlying requests library.
913+
params: Dict of query parameters to add to all outgoing requests.
850914
"""
851915
_http_client.JsonHttpClient.__init__(
852916
self, credential=credential, base_url=base_url, headers={'User-Agent': _USER_AGENT})
853917
self.credential = credential
854-
self.auth_override = auth_override
855918
self.timeout = timeout
919+
self.params = params if params else {}
856920

857921
def request(self, method, url, **kwargs):
858922
"""Makes an HTTP call using the Python requests library.
859923
860-
Extends the request() method of the parent JsonHttpClient class. Handles auth overrides,
861-
and low-level exceptions.
924+
Extends the request() method of the parent JsonHttpClient class. Handles default
925+
params like auth overrides, and low-level exceptions.
862926
863927
Args:
864928
method: HTTP method name as a string (e.g. get, post).
@@ -872,13 +936,15 @@ def request(self, method, url, **kwargs):
872936
Raises:
873937
ApiCallError: If an error occurs while making the HTTP call.
874938
"""
875-
if self.auth_override:
876-
params = kwargs.get('params')
877-
if params:
878-
params += '&{0}'.format(self.auth_override)
939+
query = '&'.join('{0}={1}'.format(key, self.params[key]) for key in self.params)
940+
extra_params = kwargs.get('params')
941+
if extra_params:
942+
if query:
943+
query = extra_params + '&' + query
879944
else:
880-
params = self.auth_override
881-
kwargs['params'] = params
945+
query = extra_params
946+
kwargs['params'] = query
947+
882948
if self.timeout:
883949
kwargs['timeout'] = self.timeout
884950
try:
@@ -911,3 +977,12 @@ def extract_error_message(cls, error):
911977
except ValueError:
912978
pass
913979
return '{0}\nReason: {1}'.format(error, error.response.content.decode())
980+
981+
982+
class _EmulatorAdminCredentials(google.auth.credentials.Credentials):
983+
def __init__(self):
984+
google.auth.credentials.Credentials.__init__(self)
985+
self.token = 'owner'
986+
987+
def refresh(self, request):
988+
pass

integration/conftest.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,3 @@ def api_key(request):
7070
'command-line option.')
7171
with open(path) as keyfile:
7272
return keyfile.read().strip()
73-

0 commit comments

Comments
 (0)