Skip to content

Commit 5b349b8

Browse files
Added list_tenants() API (#429)
* Added list_tenants() API * Update firebase_admin/tenant_mgt.py Co-Authored-By: Lahiru Maramba <[email protected]> * Updated error message Co-authored-by: Lahiru Maramba <[email protected]>
1 parent 4c8f86e commit 5b349b8

File tree

2 files changed

+305
-2
lines changed

2 files changed

+305
-2
lines changed

firebase_admin/tenant_mgt.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,19 @@
2727

2828

2929
_TENANT_MGT_ATTRIBUTE = '_tenant_mgt'
30+
_MAX_LIST_TENANTS_RESULTS = 100
3031

3132

3233
__all__ = [
34+
'ListTenantsPage',
3335
'Tenant',
3436
'TenantNotFoundError',
3537

38+
'create_tenant',
3639
'delete_tenant',
3740
'get_tenant',
41+
'list_tenants',
42+
'update_tenant',
3843
]
3944

4045
TenantNotFoundError = _auth_utils.TenantNotFoundError
@@ -128,6 +133,34 @@ def delete_tenant(tenant_id, app=None):
128133
tenant_mgt_service.delete_tenant(tenant_id)
129134

130135

136+
def list_tenants(page_token=None, max_results=_MAX_LIST_TENANTS_RESULTS, app=None):
137+
"""Retrieves a page of tenants from a Firebase project.
138+
139+
The ``page_token`` argument governs the starting point of the page. The ``max_results``
140+
argument governs the maximum number of tenants that may be included in the returned page.
141+
This function never returns None. If there are no user accounts in the Firebase project, this
142+
returns an empty page.
143+
144+
Args:
145+
page_token: A non-empty page token string, which indicates the starting point of the page
146+
(optional). Defaults to ``None``, which will retrieve the first page of users.
147+
max_results: A positive integer indicating the maximum number of users to include in the
148+
returned page (optional). Defaults to 100, which is also the maximum number allowed.
149+
app: An App instance (optional).
150+
151+
Returns:
152+
ListTenantsPage: A ListTenantsPage instance.
153+
154+
Raises:
155+
ValueError: If ``max_results`` or ``page_token`` are invalid.
156+
FirebaseError: If an error occurs while retrieving the user accounts.
157+
"""
158+
tenant_mgt_service = _get_tenant_mgt_service(app)
159+
def download(page_token, max_results):
160+
return tenant_mgt_service.list_tenants(page_token, max_results)
161+
return ListTenantsPage(download, page_token, max_results)
162+
163+
131164
def _get_tenant_mgt_service(app):
132165
return _utils.get_app_service(app, _TENANT_MGT_ATTRIBUTE, _TenantManagementService)
133166

@@ -254,3 +287,106 @@ def delete_tenant(self, tenant_id):
254287
self.client.request('delete', '/tenants/{0}'.format(tenant_id))
255288
except requests.exceptions.RequestException as error:
256289
raise _auth_utils.handle_auth_backend_error(error)
290+
291+
def list_tenants(self, page_token=None, max_results=_MAX_LIST_TENANTS_RESULTS):
292+
"""Retrieves a batch of tenants."""
293+
if page_token is not None:
294+
if not isinstance(page_token, str) or not page_token:
295+
raise ValueError('Page token must be a non-empty string.')
296+
if not isinstance(max_results, int):
297+
raise ValueError('Max results must be an integer.')
298+
if max_results < 1 or max_results > _MAX_LIST_TENANTS_RESULTS:
299+
raise ValueError(
300+
'Max results must be a positive integer less than or equal to '
301+
'{0}.'.format(_MAX_LIST_TENANTS_RESULTS))
302+
303+
payload = {'pageSize': max_results}
304+
if page_token:
305+
payload['pageToken'] = page_token
306+
try:
307+
return self.client.body('get', '/tenants', params=payload)
308+
except requests.exceptions.RequestException as error:
309+
raise _auth_utils.handle_auth_backend_error(error)
310+
311+
312+
class ListTenantsPage:
313+
"""Represents a page of tenants fetched from a Firebase project.
314+
315+
Provides methods for traversing tenants included in this page, as well as retrieving
316+
subsequent pages of tenants. The iterator returned by ``iterate_all()`` can be used to iterate
317+
through all tenants in the Firebase project starting from this page.
318+
"""
319+
320+
def __init__(self, download, page_token, max_results):
321+
self._download = download
322+
self._max_results = max_results
323+
self._current = download(page_token, max_results)
324+
325+
@property
326+
def tenants(self):
327+
"""A list of ``ExportedUserRecord`` instances available in this page."""
328+
return [Tenant(data) for data in self._current.get('tenants', [])]
329+
330+
@property
331+
def next_page_token(self):
332+
"""Page token string for the next page (empty string indicates no more pages)."""
333+
return self._current.get('nextPageToken', '')
334+
335+
@property
336+
def has_next_page(self):
337+
"""A boolean indicating whether more pages are available."""
338+
return bool(self.next_page_token)
339+
340+
def get_next_page(self):
341+
"""Retrieves the next page of tenants, if available.
342+
343+
Returns:
344+
ListTenantsPage: Next page of tenants, or None if this is the last page.
345+
"""
346+
if self.has_next_page:
347+
return ListTenantsPage(self._download, self.next_page_token, self._max_results)
348+
return None
349+
350+
def iterate_all(self):
351+
"""Retrieves an iterator for tenants.
352+
353+
Returned iterator will iterate through all the tenants in the Firebase project
354+
starting from this page. The iterator will never buffer more than one page of tenants
355+
in memory at a time.
356+
357+
Returns:
358+
iterator: An iterator of Tenant instances.
359+
"""
360+
return _TenantIterator(self)
361+
362+
363+
class _TenantIterator:
364+
"""An iterator that allows iterating over tenants.
365+
366+
This implementation loads a page of tenants into memory, and iterates on them. When the whole
367+
page has been traversed, it loads another page. This class never keeps more than one page
368+
of entries in memory.
369+
"""
370+
371+
def __init__(self, current_page):
372+
if not current_page:
373+
raise ValueError('Current page must not be None.')
374+
self._current_page = current_page
375+
self._index = 0
376+
377+
def next(self):
378+
if self._index == len(self._current_page.tenants):
379+
if self._current_page.has_next_page:
380+
self._current_page = self._current_page.get_next_page()
381+
self._index = 0
382+
if self._index < len(self._current_page.tenants):
383+
result = self._current_page.tenants[self._index]
384+
self._index += 1
385+
return result
386+
raise StopIteration
387+
388+
def __next__(self):
389+
return self.next()
390+
391+
def __iter__(self):
392+
return self

tests/test_tenant_mgt.py

Lines changed: 169 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"""Test cases for the firebase_admin.tenant_mgt module."""
1616

1717
import json
18+
from urllib import parse
1819

1920
import pytest
2021

@@ -37,6 +38,38 @@
3738
}
3839
}"""
3940

41+
LIST_TENANTS_RESPONSE = """{
42+
"tenants": [
43+
{
44+
"name": "projects/mock-project-id/tenants/tenant0",
45+
"displayName": "Test Tenant",
46+
"allowPasswordSignup": true,
47+
"enableEmailLinkSignin": true
48+
},
49+
{
50+
"name": "projects/mock-project-id/tenants/tenant1",
51+
"displayName": "Test Tenant",
52+
"allowPasswordSignup": true,
53+
"enableEmailLinkSignin": true
54+
}
55+
]
56+
}"""
57+
58+
LIST_TENANTS_RESPONSE_WITH_TOKEN = """{
59+
"tenants": [
60+
{
61+
"name": "projects/mock-project-id/tenants/tenant0"
62+
},
63+
{
64+
"name": "projects/mock-project-id/tenants/tenant1"
65+
},
66+
{
67+
"name": "projects/mock-project-id/tenants/tenant2"
68+
}
69+
],
70+
"nextPageToken": "token"
71+
}"""
72+
4073
INVALID_TENANT_IDS = [None, '', 0, 1, True, False, list(), tuple(), dict()]
4174
INVALID_BOOLEANS = ['', 1, 0, list(), tuple(), dict()]
4275

@@ -309,8 +342,142 @@ def test_tenant_not_found(self, tenant_mgt_app):
309342
assert excinfo.value.cause is not None
310343

311344

312-
def _assert_tenant(tenant):
313-
assert tenant.tenant_id == 'tenant-id'
345+
class TestListTenants:
346+
347+
@pytest.mark.parametrize('arg', [None, 'foo', list(), dict(), 0, -1, 101, False])
348+
def test_invalid_max_results(self, tenant_mgt_app, arg):
349+
with pytest.raises(ValueError):
350+
tenant_mgt.list_tenants(max_results=arg, app=tenant_mgt_app)
351+
352+
@pytest.mark.parametrize('arg', ['', list(), dict(), 0, -1, True, False])
353+
def test_invalid_page_token(self, tenant_mgt_app, arg):
354+
with pytest.raises(ValueError):
355+
tenant_mgt.list_tenants(page_token=arg, app=tenant_mgt_app)
356+
357+
def test_list_single_page(self, tenant_mgt_app):
358+
_, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, LIST_TENANTS_RESPONSE)
359+
page = tenant_mgt.list_tenants(app=tenant_mgt_app)
360+
self._assert_tenants_page(page)
361+
assert page.next_page_token == ''
362+
assert page.has_next_page is False
363+
assert page.get_next_page() is None
364+
tenants = [tenant for tenant in page.iterate_all()]
365+
assert len(tenants) == 2
366+
self._assert_request(recorder)
367+
368+
def test_list_multiple_pages(self, tenant_mgt_app):
369+
# Page 1
370+
_, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, LIST_TENANTS_RESPONSE_WITH_TOKEN)
371+
page = tenant_mgt.list_tenants(app=tenant_mgt_app)
372+
assert len(page.tenants) == 3
373+
assert page.next_page_token == 'token'
374+
assert page.has_next_page is True
375+
self._assert_request(recorder)
376+
377+
# Page 2 (also the last page)
378+
response = {'tenants': [{'name': 'projects/mock-project-id/tenants/tenant3'}]}
379+
_, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, json.dumps(response))
380+
page = page.get_next_page()
381+
assert len(page.tenants) == 1
382+
assert page.next_page_token == ''
383+
assert page.has_next_page is False
384+
assert page.get_next_page() is None
385+
self._assert_request(recorder, {'pageSize': '100', 'pageToken': 'token'})
386+
387+
def test_list_tenants_paged_iteration(self, tenant_mgt_app):
388+
# Page 1
389+
_, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, LIST_TENANTS_RESPONSE_WITH_TOKEN)
390+
page = tenant_mgt.list_tenants(app=tenant_mgt_app)
391+
iterator = page.iterate_all()
392+
for index in range(3):
393+
tenant = next(iterator)
394+
assert tenant.tenant_id == 'tenant{0}'.format(index)
395+
self._assert_request(recorder)
396+
397+
# Page 2 (also the last page)
398+
response = {'tenants': [{'name': 'projects/mock-project-id/tenants/tenant3'}]}
399+
_, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, json.dumps(response))
400+
tenant = next(iterator)
401+
assert tenant.tenant_id == 'tenant3'
402+
403+
with pytest.raises(StopIteration):
404+
next(iterator)
405+
self._assert_request(recorder, {'pageSize': '100', 'pageToken': 'token'})
406+
407+
def test_list_tenants_iterator_state(self, tenant_mgt_app):
408+
_, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, LIST_TENANTS_RESPONSE)
409+
page = tenant_mgt.list_tenants(app=tenant_mgt_app)
410+
411+
# Advance iterator.
412+
iterator = page.iterate_all()
413+
tenant = next(iterator)
414+
assert tenant.tenant_id == 'tenant0'
415+
416+
# Iterator should resume from where left off.
417+
tenant = next(iterator)
418+
assert tenant.tenant_id == 'tenant1'
419+
420+
with pytest.raises(StopIteration):
421+
next(iterator)
422+
self._assert_request(recorder)
423+
424+
def test_list_tenants_stop_iteration(self, tenant_mgt_app):
425+
_, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, LIST_TENANTS_RESPONSE)
426+
page = tenant_mgt.list_tenants(app=tenant_mgt_app)
427+
iterator = page.iterate_all()
428+
tenants = [tenant for tenant in iterator]
429+
assert len(tenants) == 2
430+
431+
with pytest.raises(StopIteration):
432+
next(iterator)
433+
self._assert_request(recorder)
434+
435+
def test_list_tenants_no_tenants_response(self, tenant_mgt_app):
436+
response = {'tenants': []}
437+
_instrument_tenant_mgt(tenant_mgt_app, 200, json.dumps(response))
438+
page = tenant_mgt.list_tenants(app=tenant_mgt_app)
439+
assert len(page.tenants) == 0
440+
tenants = [tenant for tenant in page.iterate_all()]
441+
assert len(tenants) == 0
442+
443+
def test_list_tenants_with_max_results(self, tenant_mgt_app):
444+
_, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, LIST_TENANTS_RESPONSE)
445+
page = tenant_mgt.list_tenants(max_results=50, app=tenant_mgt_app)
446+
self._assert_tenants_page(page)
447+
self._assert_request(recorder, {'pageSize' : '50'})
448+
449+
def test_list_tenants_with_all_args(self, tenant_mgt_app):
450+
_, recorder = _instrument_tenant_mgt(tenant_mgt_app, 200, LIST_TENANTS_RESPONSE)
451+
page = tenant_mgt.list_tenants(page_token='foo', max_results=50, app=tenant_mgt_app)
452+
self._assert_tenants_page(page)
453+
self._assert_request(recorder, {'pageToken' : 'foo', 'pageSize' : '50'})
454+
455+
def test_list_tenants_error(self, tenant_mgt_app):
456+
_instrument_tenant_mgt(tenant_mgt_app, 500, '{"error":"test"}')
457+
with pytest.raises(exceptions.InternalError) as excinfo:
458+
tenant_mgt.list_tenants(app=tenant_mgt_app)
459+
assert str(excinfo.value) == 'Unexpected error response: {"error":"test"}'
460+
461+
def _assert_tenants_page(self, page):
462+
assert isinstance(page, tenant_mgt.ListTenantsPage)
463+
assert len(page.tenants) == 2
464+
for idx, tenant in enumerate(page.tenants):
465+
_assert_tenant(tenant, 'tenant{0}'.format(idx))
466+
467+
def _assert_request(self, recorder, expected=None):
468+
if expected is None:
469+
expected = {'pageSize' : '100'}
470+
471+
assert len(recorder) == 1
472+
req = recorder[0]
473+
assert req.method == 'GET'
474+
request = dict(parse.parse_qsl(parse.urlsplit(req.url).query))
475+
assert request == expected
476+
477+
478+
def _assert_tenant(tenant, tenant_id='tenant-id'):
479+
assert isinstance(tenant, tenant_mgt.Tenant)
480+
assert tenant.tenant_id == tenant_id
314481
assert tenant.display_name == 'Test Tenant'
315482
assert tenant.allow_password_sign_up is True
316483
assert tenant.enable_email_link_sign_in is True

0 commit comments

Comments
 (0)