Skip to content

Commit 8de04d9

Browse files
authored
Add endpoint to list clients (#268)
* Nest client metadata files in folders * Remove fallback code for missing owner metadata * Refactor from TextStorage to ObjectStorage * Fix listing of files that contain a dot * Add method to list domains * Add endpoint to list domains
1 parent ca05e9f commit 8de04d9

File tree

10 files changed

+138
-48
lines changed

10 files changed

+138
-48
lines changed

opwen_email_server/actions.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,19 @@ def _action(self, client, **auth_args): # type: ignore
362362
return 'accepted', 201
363363

364364

365+
class ListClients(_Action):
366+
def __init__(self, auth: AzureAuth):
367+
self._auth = auth
368+
369+
def _action(self, **auth_args): # type: ignore
370+
clients = [{'domain': domain} for domain in self._auth.domains()]
371+
372+
self.log_event(events.CLIENTS_FETCHED) # noqa: E501 # yapf: disable
373+
return {
374+
'clients': clients,
375+
}
376+
377+
365378
class GetClient(_Action):
366379
def __init__(self, auth: AzureAuth, client_storage: AzureObjectsStorage):
367380
self._auth = auth

opwen_email_server/constants/events.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
CLIENT_DELETED = 'client_deleted' # type: Final
44
CLIENT_FETCHED = 'client_fetched' # type: Final
5+
CLIENTS_FETCHED = 'clients_fetched' # type: Final
56
CLIENT_CREATED = 'client_created' # type: Final
67
NEW_CLIENT_REGISTERED = 'new_client_registered' # type: Final
78
UNREGISTERED_CLIENT = 'unregistered_client' # type: Final

opwen_email_server/integration/azure.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
@singleton
1515
def get_auth() -> AzureAuth:
16-
return AzureAuth(storage=AzureTextStorage(
16+
return AzureAuth(storage=AzureObjectStorage(
1717
account=config.TABLES_ACCOUNT,
1818
key=config.TABLES_KEY,
1919
host=config.TABLES_HOST,

opwen_email_server/integration/connexion.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from opwen_email_server.actions import DeleteClient
55
from opwen_email_server.actions import DownloadClientEmails
66
from opwen_email_server.actions import GetClient
7+
from opwen_email_server.actions import ListClients
78
from opwen_email_server.actions import Ping
89
from opwen_email_server.actions import ReceiveInboundEmail
910
from opwen_email_server.actions import UploadClientEmails
@@ -44,6 +45,8 @@
4445
task=register_client.delay,
4546
)
4647

48+
client_list = ListClients(auth=get_auth())
49+
4750
client_get = GetClient(
4851
auth=get_auth(),
4952
client_storage=get_client_storage(),

opwen_email_server/services/auth.py

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from json import JSONDecodeError
21
from typing import Callable
32
from typing import Dict
43
from typing import Iterable
@@ -11,10 +10,8 @@
1110

1211
from opwen_email_server.constants import events
1312
from opwen_email_server.constants import github
14-
from opwen_email_server.services.storage import AzureTextStorage
13+
from opwen_email_server.services.storage import AzureObjectStorage
1514
from opwen_email_server.utils.log import LogMixin
16-
from opwen_email_server.utils.serialization import from_json
17-
from opwen_email_server.utils.serialization import to_json
1815

1916

2017
class AnyOfBasicAuth(LogMixin):
@@ -135,56 +132,59 @@ def _fetch_team_members(self, access_token: str) -> Iterable[str]:
135132

136133

137134
class AzureAuth(LogMixin):
138-
def __init__(self, storage: AzureTextStorage) -> None:
135+
def __init__(self, storage: AzureObjectStorage) -> None:
139136
self._storage = storage
140137

141138
def insert(self, client_id: str, domain: str, owner: str):
142-
self._storage.store_text(client_id, domain)
143-
self._storage.store_text(domain, to_json({'client_id': client_id, 'owner': owner}))
139+
auth = {'client_id': client_id, 'owner': owner, 'domain': domain}
140+
self._storage.store_object(self._client_id_file(client_id), auth)
141+
self._storage.store_object(self._domain_file(domain), auth)
144142
self.log_info('Registered client %s at domain %s', client_id, domain)
145143

146144
def is_owner(self, domain: str, username: str) -> bool:
147145
try:
148-
raw_auth = self._storage.fetch_text(domain)
146+
auth = self._storage.fetch_object(self._domain_file(domain))
149147
except ObjectDoesNotExistError:
150148
self.log_warning('Unrecognized domain %s', domain)
151149
return False
152150

153-
try:
154-
auth = from_json(raw_auth)
155-
except JSONDecodeError:
156-
# fallback for clients registered before November 2019
157-
self.log_warning('Unable to lookup owner for domain %s', domain)
158-
return False
159-
160151
return auth.get('owner') == username
161152

162153
def delete(self, client_id: str, domain: str) -> bool:
163-
self._storage.delete(domain)
164-
self._storage.delete(client_id)
154+
self._storage.delete(self._domain_file(domain))
155+
self._storage.delete(self._client_id_file(client_id))
165156
return True
166157

167158
def client_id_for(self, domain: str) -> Optional[str]:
168159
try:
169-
raw_auth = self._storage.fetch_text(domain)
160+
auth = self._storage.fetch_object(self._domain_file(domain))
170161
except ObjectDoesNotExistError:
171162
self.log_warning('Unrecognized domain %s', domain)
172163
return None
173-
else:
174-
try:
175-
client_id = from_json(raw_auth)['client_id']
176-
except JSONDecodeError:
177-
# fallback for clients registered before November 2019
178-
client_id = raw_auth
179-
self.log_debug('Domain %s has client %s', domain, client_id)
180-
return client_id
164+
165+
client_id = auth['client_id']
166+
167+
self.log_debug('Domain %s has client %s', domain, client_id)
168+
return client_id
181169

182170
def domain_for(self, client_id: str) -> Optional[str]:
183171
try:
184-
domain = self._storage.fetch_text(client_id)
172+
auth = self._storage.fetch_object(self._client_id_file(client_id))
185173
except ObjectDoesNotExistError:
186174
self.log_warning('Unrecognized client %s', client_id)
187175
return None
188176
else:
177+
domain = auth['domain']
189178
self.log_debug('Client %s has domain %s', client_id, domain)
190179
return domain
180+
181+
def domains(self) -> Iterable[str]:
182+
return self._storage.iter(self._domain_file(''))
183+
184+
@classmethod
185+
def _domain_file(cls, domain: str) -> str:
186+
return f'domain/{domain}'
187+
188+
@classmethod
189+
def _client_id_file(cls, client_id: str) -> str:
190+
return f'client_id/{client_id}'

opwen_email_server/services/storage.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
from xtarfile.xtarfile import SUPPORTED_FORMATS
2222

2323
from opwen_email_server.utils.log import LogMixin
24-
from opwen_email_server.utils.path import get_extension
2524
from opwen_email_server.utils.serialization import from_msgpack_bytes
2625
from opwen_email_server.utils.serialization import gunzip_bytes
2726
from opwen_email_server.utils.serialization import gzip_bytes
@@ -67,6 +66,10 @@ def _client(self) -> Container:
6766
container = self._driver.get_container(self._container)
6867
return container
6968

69+
@property
70+
def _generated_suffix(self) -> str:
71+
return ''
72+
7073
def access_info(self) -> AccessInfo:
7174
return AccessInfo(
7275
account=self._account,
@@ -87,10 +90,25 @@ def delete(self, resource_id: str):
8790
resource.delete()
8891
self.log_debug('deleted %s', resource_id)
8992

90-
def iter(self) -> Iterator[str]:
91-
for resource in self._client.list_objects():
92-
extension = get_extension(resource.name)
93-
resource_id = resource.name.replace(extension, '')
93+
def iter(self, prefix: Optional[str] = None) -> Iterator[str]:
94+
try:
95+
# noinspection PyArgumentList
96+
resources = self._driver.iterate_container_objects(self._client, prefix)
97+
except TypeError:
98+
resources = self._driver.iterate_container_objects(self._client)
99+
100+
for resource in resources:
101+
resource_id = resource.name
102+
103+
if prefix is not None:
104+
if not resource_id.startswith(prefix):
105+
continue
106+
else:
107+
resource_id = resource_id[len(prefix):]
108+
109+
if resource_id.endswith(self._generated_suffix):
110+
resource_id = resource_id[:-len(self._generated_suffix)]
111+
94112
yield resource_id
95113
self.log_debug('listed %s', resource_id)
96114

@@ -135,10 +153,13 @@ def delete(self, resource_id: str):
135153
super().delete(filename)
136154

137155
def _to_filename(self, resource_id: str) -> str:
138-
extension = f'.{self._extension}.{self._compression}'
139-
if resource_id.endswith(extension):
156+
if resource_id.endswith(self._generated_suffix):
140157
return resource_id
141-
return f'{resource_id}{extension}'
158+
return f'{resource_id}{self._generated_suffix}'
159+
160+
@property
161+
def _generated_suffix(self) -> str:
162+
return f'.{self._extension}.{self._compression}'
142163

143164
@property
144165
def _extension(self) -> str:

opwen_email_server/swagger/client-register.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,19 @@ paths:
1010

1111
'/':
1212

13+
get:
14+
operationId: opwen_email_server.integration.connexion.client_list
15+
summary: Endpoint to list all registered Lokole clients.
16+
produces:
17+
- application/json
18+
responses:
19+
200:
20+
description: Information about the clients.
21+
schema:
22+
$ref: '#/definitions/RegistrationInfos'
23+
security:
24+
- basic: []
25+
1326
post:
1427
operationId: opwen_email_server.integration.connexion.client_create
1528
summary: Endpoint where Lokole clients register themselves.
@@ -100,6 +113,17 @@ definitions:
100113
required:
101114
- domain
102115

116+
RegistrationInfos:
117+
type: object
118+
properties:
119+
clients:
120+
description: Domains of all registered clients.
121+
type: array
122+
items:
123+
$ref: '#/definitions/RegistrationInfo'
124+
required:
125+
- clients
126+
103127
RegisteredClient:
104128
type: object
105129
properties:

tests/opwen_email_server/services/test_auth.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from opwen_email_server.services.auth import AnyOfBasicAuth
1111
from opwen_email_server.services.auth import BasicAuth
1212
from opwen_email_server.services.auth import GithubBasicAuth
13-
from opwen_email_server.services.storage import AzureTextStorage
13+
from opwen_email_server.services.storage import AzureObjectStorage
1414
from tests.opwen_email_server.helpers import MockResponses
1515

1616

@@ -170,7 +170,7 @@ def test_with_correct_password(self):
170170
class AzureAuthTests(TestCase):
171171
def setUp(self):
172172
self._folder = mkdtemp()
173-
self._storage = AzureTextStorage(
173+
self._storage = AzureObjectStorage(
174174
account=self._folder,
175175
key='key',
176176
container='auth',
@@ -191,17 +191,13 @@ def test_inserts_and_retrieves_client(self):
191191
self.assertFalse(self._auth.is_owner('domain', 'unknown-user'))
192192
self.assertFalse(self._auth.is_owner('unknown-domain', 'owner'))
193193

194+
def test_lists_domains(self):
195+
self._auth.insert('client1', 'domain1', 'owner1')
196+
self._auth.insert('client2', 'domain2', 'owner2')
197+
self.assertEqual(sorted(self._auth.domains()), sorted(['domain1', 'domain2']))
198+
194199
def test_deletes_client(self):
195200
self._auth.insert('client', 'domain', 'owner')
196201
self.assertIsNotNone(self._auth.domain_for('client'))
197202
self._auth.delete('client', 'domain')
198203
self.assertIsNone(self._auth.domain_for('client'))
199-
200-
def test_inserts_and_retrieves_client_backwards_compatibility_pre_november_2019(self):
201-
# emulate pre november 2019 version of self._auth.insert
202-
self._storage.store_text('client', 'domain')
203-
self._storage.store_text('domain', 'client')
204-
205-
self.assertEqual(self._auth.domain_for('client'), 'domain')
206-
self.assertEqual(self._auth.client_id_for('domain'), 'client')
207-
self.assertFalse(self._auth.is_owner('domain', 'owner'))

tests/opwen_email_server/services/test_storage.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,23 @@ def test_stores_fetches_and_deletes_text(self):
4545
def test_list(self):
4646
self._storage.store_text('resource1', 'a')
4747
self._storage.store_text('resource2.txt.gz', 'b')
48-
self.assertEqual(sorted(self._storage.iter()), sorted(['resource1', 'resource2']))
48+
self._storage.store_text('pa.th/to/re.sou.rce.txt.gz', 'b')
49+
self.assertEqual(sorted(self._storage.iter()), sorted(['resource1', 'resource2', 'pa.th/to/re.sou.rce']))
4950

5051
self._storage.delete('resource2')
52+
self._storage.delete('pa.th/to/re.sou.rce')
5153
self.assertEqual(sorted(self._storage.iter()), sorted(['resource1']))
5254

55+
def test_list_with_prefix(self):
56+
self._storage.store_text('one/a', 'a')
57+
self._storage.store_text('one/b.txt.gz', 'b')
58+
self._storage.store_text('two/c.txt.gz', 'c')
59+
self._storage.store_text('two/d', 'd')
60+
self._storage.store_text('two/e', 'e')
61+
self._storage.store_text('f', 'f')
62+
self.assertEqual(sorted(self._storage.iter('one/')), sorted(['a', 'b']))
63+
self.assertEqual(sorted(self._storage.iter('two/')), sorted(['c', 'd', 'e']))
64+
5365
def test_ensure_exists(self):
5466
self.assertFalse(isdir(join(self._folder, self._container)))
5567
self._storage.ensure_exists()

tests/opwen_email_server/test_actions.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,26 @@ def _execute_action(self, *args, **kwargs):
544544
return action(*args, **kwargs)
545545

546546

547+
class ListClientsTests(TestCase):
548+
def setUp(self):
549+
self.auth = Mock()
550+
551+
def test_200(self):
552+
domains = ['1.test.com', '2.test.com']
553+
554+
self.auth.domains.return_value = domains
555+
556+
response = self._execute_action()
557+
558+
self.assertEqual(response['clients'], [{'domain': '1.test.com'}, {'domain': '2.test.com'}])
559+
self.auth.domains.assert_called_once()
560+
561+
def _execute_action(self, *args, **kwargs):
562+
action = actions.ListClients(auth=self.auth)
563+
564+
return action(*args, **kwargs)
565+
566+
547567
class GetClientTests(TestCase):
548568
def setUp(self):
549569
self.auth = Mock()

0 commit comments

Comments
 (0)