Skip to content

Commit 3b6b613

Browse files
authored
Implemented IID delete API (#95)
* Implemented IID delete API * Implemented better error handling * Improved error handling * Resolved TODO regarding backend errors * Fixing typo * Improved error handling * Updated error message format
1 parent 6901b89 commit 3b6b613

File tree

3 files changed

+244
-0
lines changed

3 files changed

+244
-0
lines changed

firebase_admin/instance_id.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Copyright 2017 Google Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Firebase Instance ID module.
16+
17+
This module enables deleting instance IDs associated with Firebase projects.
18+
"""
19+
20+
import requests
21+
import six
22+
23+
from firebase_admin import _http_client
24+
from firebase_admin import _utils
25+
26+
27+
_IID_SERVICE_URL = 'https://console.firebase.google.com/v1/'
28+
_IID_ATTRIBUTE = '_iid'
29+
30+
31+
def _get_iid_service(app):
32+
return _utils.get_app_service(app, _IID_ATTRIBUTE, _InstanceIdService)
33+
34+
35+
def delete_instance_id(instance_id, app=None):
36+
"""Deletes the specified instance ID from Firebase.
37+
38+
This can be used to delete an instance ID and associated user data from a Firebase project,
39+
pursuant to the General Data Protection Regulation (GDPR).
40+
41+
Args:
42+
instance_id: A non-empty instance ID string.
43+
app: An App instance (optional).
44+
45+
Raises:
46+
InstanceIdError: If an error occurs while invoking the backend instance ID service.
47+
ValueError: If the specified instance ID or app is invalid.
48+
"""
49+
_get_iid_service(app).delete_instance_id(instance_id)
50+
51+
52+
class ApiCallError(Exception):
53+
"""Represents an Exception encountered while invoking the Firebase instance ID service."""
54+
55+
def __init__(self, message, error):
56+
Exception.__init__(self, message)
57+
self.detail = error
58+
59+
60+
class _InstanceIdService(object):
61+
"""Provides methods for interacting with the remote instance ID service."""
62+
63+
error_codes = {
64+
400: 'Malformed instance ID argument.',
65+
401: 'Request not authorized.',
66+
403: 'Project does not match instance ID or the client does not have '
67+
'sufficient privileges.',
68+
404: 'Failed to find the instance ID.',
69+
409: 'Already deleted.',
70+
429: 'Request throttled out by the backend server.',
71+
500: 'Internal server error.',
72+
503: 'Backend servers are over capacity. Try again later.'
73+
}
74+
75+
def __init__(self, app):
76+
project_id = app.project_id
77+
if not project_id:
78+
raise ValueError(
79+
'Project ID is required to access Instance ID service. Either set the projectId '
80+
'option, or use service account credentials. Alternatively, set the '
81+
'GCLOUD_PROJECT environment variable.')
82+
elif not isinstance(project_id, six.string_types):
83+
raise ValueError(
84+
'Invalid project ID: "{0}". project ID must be a string.'.format(project_id))
85+
self._project_id = project_id
86+
self._client = _http_client.JsonHttpClient(
87+
credential=app.credential.get_credential(), base_url=_IID_SERVICE_URL)
88+
89+
def delete_instance_id(self, instance_id):
90+
if not isinstance(instance_id, six.string_types) or not instance_id:
91+
raise ValueError('Instance ID must be a non-empty string.')
92+
path = 'project/{0}/instanceId/{1}'.format(self._project_id, instance_id)
93+
try:
94+
self._client.request('delete', path)
95+
except requests.exceptions.RequestException as error:
96+
raise ApiCallError(self._extract_message(instance_id, error), error)
97+
98+
def _extract_message(self, instance_id, error):
99+
if error.response is None:
100+
return str(error)
101+
status = error.response.status_code
102+
msg = self.error_codes.get(status)
103+
if msg:
104+
return 'Instance ID "{0}": {1}'.format(instance_id, msg)
105+
else:
106+
return str(error)

integration/test_instance_id.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Copyright 2017 Google Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Integration tests for firebase_admin.instance_id module."""
16+
17+
import pytest
18+
19+
from firebase_admin import instance_id
20+
21+
def test_delete_non_existing():
22+
with pytest.raises(instance_id.ApiCallError) as excinfo:
23+
instance_id.delete_instance_id('non-existing')
24+
assert str(excinfo.value) == 'Instance ID "non-existing": Failed to find the instance ID.'

tests/test_instance_id.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Copyright 2017 Google Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Tests for firebase_admin.instance_id."""
16+
17+
import os
18+
19+
import pytest
20+
21+
import firebase_admin
22+
from firebase_admin import instance_id
23+
from tests import testutils
24+
25+
26+
http_errors = {
27+
404: 'Instance ID "test_iid": Failed to find the instance ID.',
28+
409: 'Instance ID "test_iid": Already deleted.',
29+
429: 'Instance ID "test_iid": Request throttled out by the backend server.',
30+
500: 'Instance ID "test_iid": Internal server error.',
31+
}
32+
33+
class TestDeleteInstanceId(object):
34+
35+
def teardown_method(self):
36+
testutils.cleanup_apps()
37+
38+
def _instrument_iid_service(self, app, status=200, payload='True'):
39+
iid_service = instance_id._get_iid_service(app)
40+
recorder = []
41+
iid_service._client.session.mount(
42+
instance_id._IID_SERVICE_URL,
43+
testutils.MockAdapter(payload, status, recorder))
44+
return iid_service, recorder
45+
46+
def _get_url(self, project_id, iid):
47+
return instance_id._IID_SERVICE_URL + 'project/{0}/instanceId/{1}'.format(project_id, iid)
48+
49+
def test_no_project_id(self):
50+
env_var = 'GCLOUD_PROJECT'
51+
gcloud_project = os.environ.get(env_var)
52+
if gcloud_project:
53+
del os.environ[env_var]
54+
try:
55+
firebase_admin.initialize_app(testutils.MockCredential())
56+
with pytest.raises(ValueError):
57+
instance_id.delete_instance_id('test')
58+
finally:
59+
if gcloud_project:
60+
os.environ[env_var] = gcloud_project
61+
62+
def test_delete_instance_id(self):
63+
cred = testutils.MockCredential()
64+
app = firebase_admin.initialize_app(cred, {'projectId': 'explicit-project-id'})
65+
_, recorder = self._instrument_iid_service(app)
66+
instance_id.delete_instance_id('test_iid')
67+
assert len(recorder) == 1
68+
assert recorder[0].method == 'DELETE'
69+
assert recorder[0].url == self._get_url('explicit-project-id', 'test_iid')
70+
71+
def test_delete_instance_id_with_explicit_app(self):
72+
cred = testutils.MockCredential()
73+
app = firebase_admin.initialize_app(cred, {'projectId': 'explicit-project-id'})
74+
_, recorder = self._instrument_iid_service(app)
75+
instance_id.delete_instance_id('test_iid', app)
76+
assert len(recorder) == 1
77+
assert recorder[0].method == 'DELETE'
78+
assert recorder[0].url == self._get_url('explicit-project-id', 'test_iid')
79+
80+
@pytest.mark.parametrize('status', http_errors.keys())
81+
def test_delete_instance_id_error(self, status):
82+
cred = testutils.MockCredential()
83+
app = firebase_admin.initialize_app(cred, {'projectId': 'explicit-project-id'})
84+
_, recorder = self._instrument_iid_service(app, status, 'some error')
85+
with pytest.raises(instance_id.ApiCallError) as excinfo:
86+
instance_id.delete_instance_id('test_iid')
87+
assert str(excinfo.value) == http_errors.get(status)
88+
assert excinfo.value.detail is not None
89+
assert len(recorder) == 1
90+
assert recorder[0].method == 'DELETE'
91+
assert recorder[0].url == self._get_url('explicit-project-id', 'test_iid')
92+
93+
def test_delete_instance_id_unexpected_error(self):
94+
cred = testutils.MockCredential()
95+
app = firebase_admin.initialize_app(cred, {'projectId': 'explicit-project-id'})
96+
_, recorder = self._instrument_iid_service(app, 501, 'some error')
97+
with pytest.raises(instance_id.ApiCallError) as excinfo:
98+
instance_id.delete_instance_id('test_iid')
99+
url = self._get_url('explicit-project-id', 'test_iid')
100+
message = '501 Server Error: None for url: {0}'.format(url)
101+
assert str(excinfo.value) == message
102+
assert excinfo.value.detail is not None
103+
assert len(recorder) == 1
104+
assert recorder[0].method == 'DELETE'
105+
assert recorder[0].url == url
106+
107+
@pytest.mark.parametrize('iid', [None, '', 0, 1, True, False, list(), dict(), tuple()])
108+
def test_invalid_instance_id(self, iid):
109+
cred = testutils.MockCredential()
110+
app = firebase_admin.initialize_app(cred, {'projectId': 'explicit-project-id'})
111+
_, recorder = self._instrument_iid_service(app)
112+
with pytest.raises(ValueError):
113+
instance_id.delete_instance_id(iid)
114+
assert len(recorder) is 0

0 commit comments

Comments
 (0)