Skip to content
Merged
Changes from 1 commit
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
ef29883
An initial stub.
weixifan Oct 26, 2018
8de2020
Reindent to 4 spaces; adjust column width
weixifan Oct 31, 2018
249b614
Remove unused imports
weixifan Oct 31, 2018
dd4894d
Handling HTTP errors from local metadata service
hiranya911 Nov 1, 2018
67ab552
Decoding the error response correctly
hiranya911 Nov 1, 2018
d0d5791
Merge branch 'hkj-travis-fix' into weixifan-project-management
weixifan Nov 1, 2018
be81da3
Writing the first RPC: getAndroidAppMetadata.
weixifan Nov 1, 2018
a5e294c
Resolving Lint issues.
weixifan Nov 1, 2018
2fd7878
Implement list_android_apps and create_android_app; refactor RPC-maki…
weixifan Nov 2, 2018
06de145
Add an integration test
weixifan Nov 2, 2018
c0bdf14
Fix a comment.
weixifan Nov 2, 2018
b83001a
Fix a bug where the statement to increment the attempt number occurs …
weixifan Nov 2, 2018
0911059
Addressing reviewer comments.
weixifan Nov 6, 2018
cde0367
Removing OperationPoller.
weixifan Nov 7, 2018
2c0710a
Fix lint issues.
weixifan Nov 7, 2018
7631ab6
Implement iOS equivalents of existing methods; implement set_display_…
weixifan Nov 7, 2018
532743d
Merge branch 'master' into weixifan-project-management
weixifan Nov 7, 2018
956dfca
Merge branch 'weixifan-project-management' into weixifan-project-mana…
weixifan Nov 7, 2018
15f0d50
Merge branch 'weixifan-project-management-2' into weixifan-project-ma…
weixifan Nov 7, 2018
c004b8c
Merge branch 'weixifan-project-management-3' into weixifan-project-ma…
weixifan Nov 7, 2018
1e60924
An initial stub.
weixifan Oct 26, 2018
7cb8518
Reindent to 4 spaces; adjust column width
weixifan Oct 31, 2018
b876b8c
Remove unused imports
weixifan Oct 31, 2018
783127e
Writing the first RPC: getAndroidAppMetadata.
weixifan Nov 1, 2018
26e8ba2
Resolving Lint issues.
weixifan Nov 1, 2018
246ceb8
Implement list_android_apps and create_android_app; refactor RPC-maki…
weixifan Nov 2, 2018
59cec74
Add an integration test
weixifan Nov 2, 2018
7d90594
Fix a comment.
weixifan Nov 2, 2018
b9c7587
Fix a bug where the statement to increment the attempt number occurs …
weixifan Nov 2, 2018
04ecbfd
Addressing reviewer comments.
weixifan Nov 6, 2018
e483e83
Removing OperationPoller.
weixifan Nov 7, 2018
b9fcf3a
Fix lint issues.
weixifan Nov 7, 2018
188ad37
Merge branch 'weixifan-project-management-3' of github.com:firebase/f…
weixifan Nov 7, 2018
50cb954
Implement iOS equivalents of existing methods; implement set_display_…
weixifan Nov 7, 2018
903266f
Merge branch 'weixifan-project-management-4' of github.com:firebase/f…
weixifan Nov 7, 2018
17b3fe8
Addressing reviewer comments.
weixifan Nov 7, 2018
1e83997
Fix staticmethod.
weixifan Nov 7, 2018
e4e3a4d
Add the ShaCertificate class. Fully implement methods pertaining to A…
weixifan Nov 8, 2018
b00879d
Implement get_config for both iOS and Android.
weixifan Nov 8, 2018
babdf70
Merge
weixifan Nov 8, 2018
f05b874
Merge branch 'weixifan-project-management-4' into weixifan-project-ma…
weixifan Nov 8, 2018
a3b6ff6
Convert to pytest.raises
weixifan Nov 9, 2018
595ec78
Fix the maximum page size.
weixifan Nov 9, 2018
99f18aa
Merge branch 'weixifan-project-management-4' into weixifan-project-ma…
weixifan Nov 9, 2018
aec320c
Fix the naming of updateMask to be consistent with precedent.
weixifan Nov 9, 2018
60c49e7
Merge branch 'weixifan-project-management-4' into weixifan-project-ma…
weixifan Nov 9, 2018
dc5ed75
Implement __eq__ and __hash__ for ShaCertificate
weixifan Nov 9, 2018
d686ce0
Addressing reviewer comments.
weixifan Nov 9, 2018
f1d7a3b
Fix a subtle Python 2.7 vs 3 compatibility problem.
weixifan Nov 10, 2018
4e59232
Fix lint
weixifan Nov 10, 2018
4e726d9
Fix lint
weixifan Nov 10, 2018
76280c8
Addressing reviewer comment.
weixifan Nov 16, 2018
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Implement list_android_apps and create_android_app; refactor RPC-maki…
…ng code.
  • Loading branch information
weixifan committed Nov 2, 2018
commit 2fd7878864fa309a602df804342ee495fec143eb
204 changes: 187 additions & 17 deletions firebase_admin/project_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
This module enables management of resources in Firebase projects, such as Android and iOS Apps.
"""

import threading

import requests
import six

Expand Down Expand Up @@ -44,12 +46,45 @@ def android_app(app_id, app=None):
return AndroidApp(app_id=app_id, service=_get_project_management_service(app))


def list_android_apps(app=None):
"""Lists all Android Apps in the associated Firebase Project.

Args:
app: An App instance (optional).

Returns:
list: a list of ``AndroidApp`` instances referring to each Android App in the Firebase
Project.
"""
return _get_project_management_service(app).list_android_apps()


def create_android_app(package_name, display_name=None, app=None):
"""Creates a new Android App in the associated Firebase Project.

Args:
package_name: The package name of the Android App to be created.
display_name: A nickname for this Android App (optional).
app: An App instance (optional).

Returns:
AndroidApp: An ``AndroidApp`` instance that is a reference to the newly created App.
"""
return _get_project_management_service(app).create_android_app(package_name, display_name)


def _check_is_string(obj, field_name):
if isinstance(obj, six.string_types):
return obj
raise ValueError('{0} must be a string.'.format(field_name))


def _check_is_string_or_none(obj, field_name):
if obj is None:
return None
return _check_is_string(obj, field_name)


def _check_is_nonempty_string(obj, field_name):
if isinstance(obj, six.string_types) and obj:
return obj
Expand All @@ -64,8 +99,19 @@ def __init__(self, message, error):
self.detail = error


class PollingError(Exception):
"""An error encountered during the polling of an App's creation status."""

def __init__(self, message):
Exception.__init__(self, message)


class AndroidApp(object):
"""A reference to an Android App within a Firebase Project."""
"""A reference to an Android App within a Firebase Project.

Please use the module-level function ``android_app(app_id)`` to obtain instances of this class
instead of instantiating it directly.
"""

def __init__(self, app_id, service):
self._app_id = app_id
Expand Down Expand Up @@ -139,12 +185,13 @@ def package_name(self):
class _ProjectManagementService(object):
"""Provides methods for interacting with the Firebase Project Management Service."""

_base_url = 'https://firebase.googleapis.com/v1beta1'

_error_codes = {
BASE_URL = 'https://firebase.googleapis.com'
MAXIMUM_LIST_APPS_PAGE_SIZE = 1
ERROR_CODES = {
401: 'Request not authorized.',
403: 'Client does not have sufficient privileges.',
404: 'Failed to find the App.',
404: 'Failed to find the resource.',
409: 'The resource already exists.',
429: 'Request throttled out by the backend server.',
500: 'Internal server error.',
503: 'Backend servers are over capacity. Try again later.'
Expand All @@ -160,28 +207,151 @@ def __init__(self, app):
self._project_id = project_id
self._client = _http_client.JsonHttpClient(
credential=app.credential.get_credential(),
base_url=_ProjectManagementService._base_url)
base_url=_ProjectManagementService.BASE_URL)
self._timeout = app.options.get('httpTimeout')

def get_android_app_metadata(self, app_id):
if not isinstance(app_id, six.string_types) or not app_id:
raise ValueError('App ID must be a non-empty string.')
path = '/projects/-/androidApps/{0}'.format(app_id)
try:
response = self._client.body('get', url=path, timeout=self._timeout)
except requests.exceptions.RequestException as error:
raise ApiCallError(self._extract_message(app_id, error), error)
"""Retrieves detailed information about an Android App."""
_check_is_nonempty_string(app_id, 'app_id')
path = '/v1beta1/projects/-/androidApps/{0}'.format(app_id)
response = self._make_request('get', path, app_id, 'App ID')
return AndroidAppMetadata(
name=response['name'],
app_id=response['appId'],
display_name=response['displayName'],
project_id=response['projectId'],
package_name=response['packageName'])

def _extract_message(self, app_id, error):
if error.response is None:
def list_android_apps(self):
"""Lists all the Android Apps within the Firebase Project."""
path = '/v1beta1/projects/{0}/androidApps?pageSize={1}'.format(
self._project_id, _ProjectManagementService.MAXIMUM_LIST_APPS_PAGE_SIZE)
response = self._make_request('get', path, self._project_id, 'Project ID')
apps_list = []
while True:
apps = response.get('apps')
if not apps:
break
apps_list.extend(AndroidApp(app_id=app['appId'], service=self) for app in apps)
next_page_token = response.get('nextPageToken')
if not next_page_token:
break
# Retrieve the next page of Apps.
path = '/v1beta1/projects/{0}/androidApps?pageToken={1}&pageSize={2}'.format(
self._project_id,
next_page_token,
_ProjectManagementService.MAXIMUM_LIST_APPS_PAGE_SIZE)
response = self._make_request('get', path, self._project_id, 'Project ID')
return apps_list

def create_android_app(self, package_name, display_name=None):
"""Creates an Android App."""
_check_is_string_or_none(display_name, 'display_name')
path = '/v1beta1/projects/{0}/androidApps'.format(self._project_id)
request_body = {'displayName': display_name, 'packageName': package_name}
response = self._make_request('post', path, package_name, 'Package name', json=request_body)
operation_name = response['name']
poller = _OperationPoller(operation_name, self._timeout, self._client)
polling_thread = threading.Thread(target=poller.run)
polling_thread.start()
polling_thread.join()
poller_response = poller.response
if poller_response:
return AndroidApp(app_id=poller_response['appId'], service=self)
if poller.error:
raise ApiCallError(
self._extract_message(operation_name, 'Operation name', poller.error), poller.error)

def _make_request(self, method, url, resource_identifier, resource_identifier_label, json=None):
try:
return self._client.body(method=method, url=url, json=json, timeout=self._timeout)
except requests.exceptions.RequestException as error:
raise ApiCallError(
self._extract_message(resource_identifier, resource_identifier_label, error), error)

def _extract_message(self, identifier, identifier_label, error):
if not isinstance(error, requests.exceptions.RequestException) or error.response is None:
return str(error)
status = error.response.status_code
message = self._error_codes.get(status)
message = _ProjectManagementService.ERROR_CODES.get(status)
if message:
return 'App ID "{0}": {1}'.format(app_id, message)
return '{0} "{1}": {2}'.format(identifier_label, identifier, message)
return '{0} "{1}": Error {2}.'.format(identifier_label, identifier, status)


class _OperationPoller(object):
"""Polls the Long-Running Operation repeatedly until it is done, with exponential backoff.

Currently, this class is somewhat redundant, since all functionality operates synchronously;
however, in the future, if we offer an asynchronous API, this class can become useful.

Args:
operation_name: The Long-Running Operation name to poll.
rpc_timeout: The number of seconds to wait for the polling RPC to complete.
client: A JsonHttpClient to make the RPC calls with.
"""

MAXIMUM_POLLING_ATTEMPTS = 8
POLL_BASE_WAIT_TIME_SECONDS = 0.5
POLL_EXPONENTIAL_BACKOFF_FACTOR = 1.5

def __init__(self, operation_name, rpc_timeout, client):
self._operation_name = operation_name
self._rpc_timeout = rpc_timeout
self._client = client
self._current_attempt = 0
self._done = False
self._waiting_thread_cv = threading.Condition()
self._error = None
self._response = None

@property
def current_wait_time(self):
delay_factor = pow(_OperationPoller.POLL_EXPONENTIAL_BACKOFF_FACTOR, self._current_attempt)
return _OperationPoller.POLL_BASE_WAIT_TIME_SECONDS * delay_factor

@property
def error(self):
return self._error

@property
def response(self):
return self._response

def run(self):
with self._waiting_thread_cv:
# Repeatedly poll (with exponential backoff) until the Operation is done.
while not self._done:
# Note that it is impossible for poll_and_notify to execute its body earlier than
# the wait() call below because we still have the CV's lock.
timer = threading.Timer(
interval=self.current_wait_time, function=self.poll_and_notify)
timer.start()
self._waiting_thread_cv.wait()

def poll_and_notify(self):
with self._waiting_thread_cv:
try:
path = '/v1/{0}'.format(self._operation_name)
poll_response = self._client.body('get', url=path, timeout=self._rpc_timeout)
done = poll_response.get('done')
self._current_attempt += 1
# If either the Operation is done or we have exceeded our retry limit, we set one of
# _response or _error, and set the _done_event (to True).
if done or self._current_attempt >= _OperationPoller.MAXIMUM_POLLING_ATTEMPTS:
if done:
response = poll_response.get('response')
if response:
self._response = response
else:
self._error = PollingError('Operation terminated in an error.')
else:
self._error = PollingError('Polling deadline exceeded.')
self._done = True
except requests.exceptions.RequestException as error:
# If any attempt results in an RPC error, we stop the retries.
self._error = error # pylint: disable=redefined-variable-type
self._done = True
finally:
# We must always reawaken the thread that calls run().
self._waiting_thread_cv.notify()