mesh-sandbox 0.1.34__py3-none-any.whl → 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mesh_sandbox/__init__.py +1 -1
- mesh_sandbox/common/messaging.py +398 -0
- mesh_sandbox/conftest.py +2 -1
- mesh_sandbox/dependencies.py +8 -3
- mesh_sandbox/handlers/admin.py +33 -15
- mesh_sandbox/handlers/inbox.py +13 -20
- mesh_sandbox/handlers/lookup.py +6 -8
- mesh_sandbox/handlers/outbox.py +17 -13
- mesh_sandbox/handlers/tracking.py +9 -10
- mesh_sandbox/routers/admin.py +33 -6
- mesh_sandbox/store/base.py +21 -139
- mesh_sandbox/store/canned_store.py +30 -34
- mesh_sandbox/store/file_store.py +22 -22
- mesh_sandbox/store/memory_store.py +22 -148
- mesh_sandbox/test_plugin/example_plugin.py +12 -2
- mesh_sandbox/tests/admin.py +63 -2
- mesh_sandbox/tests/messaging_tests.py +165 -0
- mesh_sandbox/views/admin.py +9 -1
- {mesh_sandbox-0.1.34.dist-info → mesh_sandbox-1.0.1.dist-info}/METADATA +1 -1
- {mesh_sandbox-0.1.34.dist-info → mesh_sandbox-1.0.1.dist-info}/RECORD +22 -21
- mesh_sandbox/tests/plugin_manager_tests.py +0 -41
- {mesh_sandbox-0.1.34.dist-info → mesh_sandbox-1.0.1.dist-info}/LICENSE +0 -0
- {mesh_sandbox-0.1.34.dist-info → mesh_sandbox-1.0.1.dist-info}/WHEEL +0 -0
mesh_sandbox/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.1
|
|
1
|
+
__version__ = "1.0.1"
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import importlib
|
|
3
|
+
import inspect
|
|
4
|
+
import pkgutil
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from functools import wraps
|
|
8
|
+
from types import ModuleType
|
|
9
|
+
from typing import Any, Callable, Literal, NamedTuple, Optional, TypeVar, cast
|
|
10
|
+
|
|
11
|
+
from fastapi import HTTPException, status
|
|
12
|
+
from starlette.background import BackgroundTasks
|
|
13
|
+
|
|
14
|
+
from .. import plugins as plugins_ns
|
|
15
|
+
from ..models.mailbox import Mailbox
|
|
16
|
+
from ..models.message import Message, MessageEvent, MessageStatus, MessageType
|
|
17
|
+
from ..store.base import Store
|
|
18
|
+
from . import constants, generate_cipher_text
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _SandboxPlugin(ABC):
|
|
22
|
+
|
|
23
|
+
triggers: list[
|
|
24
|
+
Literal[
|
|
25
|
+
"before_accept_message",
|
|
26
|
+
"after_accept_message",
|
|
27
|
+
"accept_message_error",
|
|
28
|
+
"before_save_message",
|
|
29
|
+
"after_save_message",
|
|
30
|
+
"save_message_error",
|
|
31
|
+
"before_send_message",
|
|
32
|
+
"after_send_message",
|
|
33
|
+
"send_message_error",
|
|
34
|
+
"before_acknowledge_message",
|
|
35
|
+
"after_acknowledge_message",
|
|
36
|
+
"acknowledge_message_error",
|
|
37
|
+
"before_save_chunk",
|
|
38
|
+
"after_save_chunk",
|
|
39
|
+
"save_chunk_error",
|
|
40
|
+
]
|
|
41
|
+
] = []
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
async def on_event(self, event: str, event_args: dict[str, Any], exception: Optional[Exception] = None):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _accepted_messages(msg: Message) -> bool:
|
|
49
|
+
return msg.status == MessageStatus.ACCEPTED
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
T_co = TypeVar("T_co", covariant=True)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AuthoriseHeaderParts(NamedTuple):
|
|
56
|
+
scheme: str
|
|
57
|
+
mailbox_id: str
|
|
58
|
+
nonce: str
|
|
59
|
+
nonce_count: str
|
|
60
|
+
timestamp: str
|
|
61
|
+
cipher_text: str
|
|
62
|
+
parts: int
|
|
63
|
+
|
|
64
|
+
def get_reasons_invalid(self) -> list[str]:
|
|
65
|
+
reasons = []
|
|
66
|
+
if self.parts != 5:
|
|
67
|
+
reasons.append(f"invalid num header parts: {self.parts}")
|
|
68
|
+
|
|
69
|
+
if not self.nonce_count.isdigit():
|
|
70
|
+
reasons.append("nonce count is not digits")
|
|
71
|
+
|
|
72
|
+
if self.scheme not in (MESH_AUTH_SCHEME, ""):
|
|
73
|
+
reasons.append("invalid auth scheme or mailbox_id contains a space")
|
|
74
|
+
|
|
75
|
+
if " " in self.mailbox_id:
|
|
76
|
+
reasons.append("mailbox_id contains a space")
|
|
77
|
+
|
|
78
|
+
return reasons
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_DEFAULT_PARTS_IF_MISSING = ["" for _ in range(5)]
|
|
82
|
+
MESH_AUTH_SCHEME = "NHSMESH"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def try_parse_authorisation_token(auth_token: str) -> Optional[AuthoriseHeaderParts]:
|
|
86
|
+
|
|
87
|
+
if not auth_token:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
auth_token = auth_token.strip()
|
|
91
|
+
|
|
92
|
+
scheme = MESH_AUTH_SCHEME if auth_token.upper().startswith(MESH_AUTH_SCHEME) else ""
|
|
93
|
+
|
|
94
|
+
if scheme:
|
|
95
|
+
auth_token = auth_token[len(MESH_AUTH_SCHEME) + 1 :]
|
|
96
|
+
|
|
97
|
+
auth_token_parts = auth_token.split(":")
|
|
98
|
+
|
|
99
|
+
num_parts = len(auth_token_parts)
|
|
100
|
+
auth_token_parts = auth_token_parts + _DEFAULT_PARTS_IF_MISSING
|
|
101
|
+
|
|
102
|
+
header_parts = AuthoriseHeaderParts(
|
|
103
|
+
scheme=scheme,
|
|
104
|
+
mailbox_id=auth_token_parts[0],
|
|
105
|
+
nonce=auth_token_parts[1],
|
|
106
|
+
nonce_count=auth_token_parts[2],
|
|
107
|
+
timestamp=auth_token_parts[3],
|
|
108
|
+
cipher_text=auth_token_parts[4],
|
|
109
|
+
parts=num_parts,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return header_parts
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Messaging:
|
|
116
|
+
def __init__(self, store: Store, plugins_module: ModuleType = plugins_ns):
|
|
117
|
+
self.store = store
|
|
118
|
+
self.logger = store.logger
|
|
119
|
+
self.config = store.config
|
|
120
|
+
self._plugin_registry: dict[str, list[type[_SandboxPlugin]]] = defaultdict(list)
|
|
121
|
+
self._plugin_instances: dict[str, list[_SandboxPlugin]] = {}
|
|
122
|
+
self._find_plugins(plugins_module)
|
|
123
|
+
|
|
124
|
+
class _TriggersEvent:
|
|
125
|
+
def __init__(self, event_name: str):
|
|
126
|
+
self.event_name = event_name
|
|
127
|
+
|
|
128
|
+
def __call__(self, func):
|
|
129
|
+
|
|
130
|
+
if not inspect.iscoroutinefunction(func):
|
|
131
|
+
raise ValueError(f"wrapped function is not awaitable: {func}")
|
|
132
|
+
|
|
133
|
+
@wraps(func)
|
|
134
|
+
async def _async_inner(*args, **kwargs):
|
|
135
|
+
if len(args) > 1:
|
|
136
|
+
raise ValueError(f"only call {func} with kwargs")
|
|
137
|
+
messaging = cast(Messaging, args[0])
|
|
138
|
+
|
|
139
|
+
kwargs_for_event = kwargs.copy()
|
|
140
|
+
background_tasks = kwargs_for_event.pop("background_tasks", None)
|
|
141
|
+
|
|
142
|
+
await messaging.on_event(f"before_{self.event_name}", kwargs_for_event)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
result = await func(*args, **kwargs)
|
|
146
|
+
if background_tasks:
|
|
147
|
+
background_tasks.add_task(messaging.on_event, f"after_{self.event_name}", kwargs_for_event)
|
|
148
|
+
return result
|
|
149
|
+
except Exception as err:
|
|
150
|
+
if background_tasks:
|
|
151
|
+
background_tasks.add_task(messaging.on_event, f"{self.event_name}_error", kwargs_for_event, err)
|
|
152
|
+
raise
|
|
153
|
+
|
|
154
|
+
return _async_inner
|
|
155
|
+
|
|
156
|
+
class _IfNotReadonly:
|
|
157
|
+
def __call__(self, func):
|
|
158
|
+
|
|
159
|
+
if not inspect.iscoroutinefunction(func):
|
|
160
|
+
raise ValueError(f"wrapped function is not awaitable: {func}")
|
|
161
|
+
|
|
162
|
+
@wraps(func)
|
|
163
|
+
async def _async_inner(*args, **kwargs):
|
|
164
|
+
messaging = cast(Messaging, args[0])
|
|
165
|
+
if messaging.readonly:
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
return await func(*args, **kwargs)
|
|
169
|
+
|
|
170
|
+
return _async_inner
|
|
171
|
+
|
|
172
|
+
def _find_plugins(self, package: ModuleType):
|
|
173
|
+
|
|
174
|
+
for _, name, _ in pkgutil.iter_modules(package.__path__, package.__name__ + "."):
|
|
175
|
+
module = importlib.import_module(name)
|
|
176
|
+
for _, plugin_type in inspect.getmembers(module):
|
|
177
|
+
|
|
178
|
+
if not inspect.isclass(plugin_type) or not plugin_type.__name__.endswith("Plugin"):
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
self.register_plugin(plugin_type)
|
|
182
|
+
|
|
183
|
+
def register_plugin(self, plugin_type: type):
|
|
184
|
+
|
|
185
|
+
self.logger.info(f"potential plugin: {plugin_type.__name__} found")
|
|
186
|
+
if not hasattr(plugin_type, "triggers"):
|
|
187
|
+
self.logger.warning(f"plugin: {plugin_type.__name__} has no class attr triggers .. not loading")
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
if not hasattr(plugin_type, "on_event"):
|
|
191
|
+
self.logger.warning(f"plugin: {plugin_type.__name__} has no class attr on_event .. not loading")
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
plugin_type = cast(type[_SandboxPlugin], plugin_type)
|
|
195
|
+
|
|
196
|
+
if not inspect.iscoroutinefunction(plugin_type.on_event):
|
|
197
|
+
self.logger.warning(f"plugin: {plugin_type.__name__} on_event is not a coroutine.. not loading")
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
on_event_args = cast(list[str], inspect.getfullargspec(plugin_type.on_event)[0])
|
|
201
|
+
|
|
202
|
+
msg_args = (
|
|
203
|
+
f"plugin: {plugin_type.__name__} on_event expected args "
|
|
204
|
+
f"(event: str, event_args: dict[str, Any], error: Exception = None) .. not loading"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if not on_event_args:
|
|
208
|
+
self.logger.warning(msg_args)
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
if not isinstance(inspect.getattr_static(plugin_type, "on_event"), staticmethod):
|
|
212
|
+
on_event_args.pop(0)
|
|
213
|
+
|
|
214
|
+
if len(on_event_args) not in (2, 3):
|
|
215
|
+
self.logger.warning(msg_args)
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
for trigger in plugin_type.triggers:
|
|
219
|
+
self._plugin_registry[trigger].append(plugin_type)
|
|
220
|
+
|
|
221
|
+
@staticmethod
|
|
222
|
+
async def _construct(plugin_type: type[_SandboxPlugin]) -> _SandboxPlugin:
|
|
223
|
+
created = plugin_type()
|
|
224
|
+
return created
|
|
225
|
+
|
|
226
|
+
async def on_event(self, event: str, event_args: dict[str, Any], exception: Optional[Exception] = None):
|
|
227
|
+
|
|
228
|
+
instances = self._plugin_instances.get(event, [])
|
|
229
|
+
if not instances:
|
|
230
|
+
registered = self._plugin_registry.get(event, [])
|
|
231
|
+
if not registered:
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
instances = await asyncio.gather(*[self._construct(plugin_type) for plugin_type in registered])
|
|
235
|
+
self._plugin_instances[event] = instances
|
|
236
|
+
|
|
237
|
+
if exception:
|
|
238
|
+
await asyncio.gather(*[plugin.on_event(event, event_args, exception) for plugin in instances])
|
|
239
|
+
else:
|
|
240
|
+
await asyncio.gather(*[plugin.on_event(event, event_args) for plugin in instances])
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def readonly(self) -> bool:
|
|
244
|
+
return self.store.readonly
|
|
245
|
+
|
|
246
|
+
@_TriggersEvent(event_name="send_message")
|
|
247
|
+
async def send_message(self, message: Message, body: bytes, background_tasks: BackgroundTasks) -> Message:
|
|
248
|
+
|
|
249
|
+
if message.total_chunks > 0:
|
|
250
|
+
await self.save_chunk(message=message, chunk_number=1, chunk=body, background_tasks=background_tasks)
|
|
251
|
+
|
|
252
|
+
if message.total_chunks == 1 or message.message_type == MessageType.REPORT:
|
|
253
|
+
await self.accept_message(message=message, file_size=len(body), background_tasks=background_tasks)
|
|
254
|
+
else:
|
|
255
|
+
await self.save_message(message=message, background_tasks=background_tasks)
|
|
256
|
+
|
|
257
|
+
await self.store.add_to_outbox(message)
|
|
258
|
+
|
|
259
|
+
return message
|
|
260
|
+
|
|
261
|
+
@_TriggersEvent(event_name="accept_message")
|
|
262
|
+
@_IfNotReadonly()
|
|
263
|
+
async def accept_message(self, message: Message, file_size: int, background_tasks: BackgroundTasks):
|
|
264
|
+
|
|
265
|
+
if message.status != MessageStatus.ACCEPTED:
|
|
266
|
+
message.events.insert(0, MessageEvent(status=MessageStatus.ACCEPTED))
|
|
267
|
+
|
|
268
|
+
message.file_size = file_size
|
|
269
|
+
|
|
270
|
+
await self.save_message(message=message, background_tasks=background_tasks)
|
|
271
|
+
await self.store.add_to_inbox(message)
|
|
272
|
+
|
|
273
|
+
@_TriggersEvent(event_name="acknowledge_message")
|
|
274
|
+
@_IfNotReadonly()
|
|
275
|
+
async def acknowledge_message(self, message: Message, background_tasks: BackgroundTasks) -> Message:
|
|
276
|
+
if message.status != MessageStatus.ACCEPTED:
|
|
277
|
+
return message
|
|
278
|
+
|
|
279
|
+
message.events.insert(0, MessageEvent(status=MessageStatus.ACKNOWLEDGED))
|
|
280
|
+
await self.save_message(message=message, background_tasks=background_tasks)
|
|
281
|
+
|
|
282
|
+
return message
|
|
283
|
+
|
|
284
|
+
async def add_message_event(
|
|
285
|
+
self, message: Message, event: MessageEvent, background_tasks: BackgroundTasks
|
|
286
|
+
) -> Message:
|
|
287
|
+
|
|
288
|
+
message.events.insert(0, event)
|
|
289
|
+
await self.save_message(message=message, background_tasks=background_tasks)
|
|
290
|
+
|
|
291
|
+
return message
|
|
292
|
+
|
|
293
|
+
@_TriggersEvent(event_name="save_chunk")
|
|
294
|
+
@_IfNotReadonly()
|
|
295
|
+
async def save_chunk(
|
|
296
|
+
self, message: Message, chunk_number: int, chunk: bytes, background_tasks: BackgroundTasks
|
|
297
|
+
): # pylint: disable=unused-argument
|
|
298
|
+
return await self.store.save_chunk(message=message, chunk_number=chunk_number, chunk=chunk)
|
|
299
|
+
|
|
300
|
+
@_TriggersEvent(event_name="save_message")
|
|
301
|
+
@_IfNotReadonly()
|
|
302
|
+
async def save_message(
|
|
303
|
+
self, message: Message, background_tasks: Optional[BackgroundTasks] = None
|
|
304
|
+
): # pylint: disable=unused-argument
|
|
305
|
+
return await self.store.save_message(message)
|
|
306
|
+
|
|
307
|
+
@_IfNotReadonly()
|
|
308
|
+
async def reset(self):
|
|
309
|
+
await self.store.reset()
|
|
310
|
+
|
|
311
|
+
@_IfNotReadonly()
|
|
312
|
+
async def reset_mailbox(self, mailbox_id: str):
|
|
313
|
+
await self.store.reset_mailbox(mailbox_id=mailbox_id)
|
|
314
|
+
|
|
315
|
+
async def get_chunk(self, message: Message, chunk_number: int) -> Optional[bytes]:
|
|
316
|
+
return await self.store.get_chunk(message=message, chunk_number=chunk_number)
|
|
317
|
+
|
|
318
|
+
async def get_mailbox(self, mailbox_id: str, accessed: bool = False) -> Optional[Mailbox]:
|
|
319
|
+
return await self.store.get_mailbox(mailbox_id=mailbox_id, accessed=accessed)
|
|
320
|
+
|
|
321
|
+
async def get_message(self, message_id: str) -> Optional[Message]:
|
|
322
|
+
return await self.store.get_message(message_id=message_id)
|
|
323
|
+
|
|
324
|
+
async def get_inbox_messages(
|
|
325
|
+
self, mailbox_id: str, predicate: Optional[Callable[[Message], bool]] = None
|
|
326
|
+
) -> list[Message]:
|
|
327
|
+
return await self.store.get_inbox_messages(mailbox_id=mailbox_id, predicate=predicate)
|
|
328
|
+
|
|
329
|
+
async def get_outbox(self, mailbox_id: str) -> list[Message]:
|
|
330
|
+
return await self.store.get_outbox(mailbox_id=mailbox_id)
|
|
331
|
+
|
|
332
|
+
async def get_by_local_id(self, mailbox_id: str, local_id: str) -> list[Message]:
|
|
333
|
+
return await self.store.get_by_local_id(mailbox_id=mailbox_id, local_id=local_id)
|
|
334
|
+
|
|
335
|
+
async def lookup_by_ods_code_and_workflow_id(self, ods_code: str, workflow_id: str) -> list[Mailbox]:
|
|
336
|
+
return await self.store.lookup_by_ods_code_and_workflow_id(ods_code=ods_code, workflow_id=workflow_id)
|
|
337
|
+
|
|
338
|
+
async def lookup_by_workflow_id(self, workflow_id: str) -> list[Mailbox]:
|
|
339
|
+
return await self.store.lookup_by_workflow_id(workflow_id=workflow_id)
|
|
340
|
+
|
|
341
|
+
async def get_accepted_inbox_messages(self, mailbox_id: str) -> list[Message]:
|
|
342
|
+
return await self.get_inbox_messages(mailbox_id, _accepted_messages)
|
|
343
|
+
|
|
344
|
+
async def _validate_auth_token(self, mailbox_id: str, authorization: str) -> Optional[Mailbox]:
|
|
345
|
+
|
|
346
|
+
if self.config.auth_mode == "none":
|
|
347
|
+
return await self.get_mailbox(mailbox_id, accessed=True)
|
|
348
|
+
|
|
349
|
+
authorization = (authorization or "").strip()
|
|
350
|
+
if not authorization:
|
|
351
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=constants.ERROR_READING_AUTH_HEADER)
|
|
352
|
+
|
|
353
|
+
header_parts = try_parse_authorisation_token(authorization)
|
|
354
|
+
if not header_parts:
|
|
355
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=constants.ERROR_READING_AUTH_HEADER)
|
|
356
|
+
|
|
357
|
+
if header_parts.mailbox_id != mailbox_id:
|
|
358
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=constants.ERROR_MAILBOX_TOKEN_MISMATCH)
|
|
359
|
+
|
|
360
|
+
if self.config.auth_mode == "canned":
|
|
361
|
+
|
|
362
|
+
if header_parts.nonce.upper() != "VALID":
|
|
363
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=constants.ERROR_INVALID_AUTH_TOKEN)
|
|
364
|
+
return await self.get_mailbox(mailbox_id, accessed=True)
|
|
365
|
+
|
|
366
|
+
if header_parts.get_reasons_invalid():
|
|
367
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=constants.ERROR_READING_AUTH_HEADER)
|
|
368
|
+
|
|
369
|
+
mailbox = await self.get_mailbox(mailbox_id, accessed=True)
|
|
370
|
+
|
|
371
|
+
if not mailbox:
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
cypher_text = generate_cipher_text(
|
|
375
|
+
self.config.shared_key,
|
|
376
|
+
header_parts.mailbox_id,
|
|
377
|
+
mailbox.password,
|
|
378
|
+
header_parts.timestamp,
|
|
379
|
+
header_parts.nonce,
|
|
380
|
+
header_parts.nonce_count,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
if header_parts.cipher_text != cypher_text:
|
|
384
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=constants.ERROR_INVALID_AUTH_TOKEN)
|
|
385
|
+
|
|
386
|
+
return mailbox
|
|
387
|
+
|
|
388
|
+
async def authorise_mailbox(self, mailbox_id: str, authorization: str) -> Optional[Mailbox]:
|
|
389
|
+
|
|
390
|
+
mailbox = await self._validate_auth_token(mailbox_id, authorization)
|
|
391
|
+
|
|
392
|
+
if not mailbox:
|
|
393
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=constants.ERROR_NO_MAILBOX_MATCHES)
|
|
394
|
+
|
|
395
|
+
return mailbox
|
|
396
|
+
|
|
397
|
+
async def get_file_size(self, message: Message) -> int:
|
|
398
|
+
return await self.store.get_file_size(message)
|
mesh_sandbox/conftest.py
CHANGED
|
@@ -11,7 +11,7 @@ from fastapi.testclient import TestClient
|
|
|
11
11
|
from uvicorn import Config, Server # type: ignore[import]
|
|
12
12
|
|
|
13
13
|
from .api import app
|
|
14
|
-
from .dependencies import get_env_config, get_store
|
|
14
|
+
from .dependencies import get_env_config, get_messaging, get_store
|
|
15
15
|
from .tests.helpers import temp_env_vars
|
|
16
16
|
|
|
17
17
|
|
|
@@ -20,6 +20,7 @@ def setup():
|
|
|
20
20
|
|
|
21
21
|
get_store.cache_clear()
|
|
22
22
|
get_env_config.cache_clear()
|
|
23
|
+
get_messaging.cache_clear()
|
|
23
24
|
|
|
24
25
|
with temp_env_vars(
|
|
25
26
|
ENV="local",
|
mesh_sandbox/dependencies.py
CHANGED
|
@@ -8,6 +8,7 @@ from fastapi import Depends, Header, HTTPException, Path, Query, Request
|
|
|
8
8
|
from .common import EnvConfig
|
|
9
9
|
from .common.constants import Headers
|
|
10
10
|
from .common.fernet import FernetHelper
|
|
11
|
+
from .common.messaging import Messaging
|
|
11
12
|
from .store.base import Store
|
|
12
13
|
from .store.canned_store import CannedStore
|
|
13
14
|
from .store.file_store import FileStore
|
|
@@ -132,6 +133,11 @@ def get_store() -> Store:
|
|
|
132
133
|
raise ValueError(f"unrecognised store mode {config.store_mode}")
|
|
133
134
|
|
|
134
135
|
|
|
136
|
+
@lru_cache()
|
|
137
|
+
def get_messaging() -> Messaging:
|
|
138
|
+
return Messaging(store=get_store())
|
|
139
|
+
|
|
140
|
+
|
|
135
141
|
@lru_cache()
|
|
136
142
|
def get_fernet() -> FernetHelper:
|
|
137
143
|
return FernetHelper()
|
|
@@ -149,7 +155,6 @@ async def authorised_mailbox(
|
|
|
149
155
|
),
|
|
150
156
|
default="",
|
|
151
157
|
),
|
|
152
|
-
|
|
158
|
+
messaging: Messaging = Depends(get_messaging),
|
|
153
159
|
):
|
|
154
|
-
|
|
155
|
-
request.state.authorised_mailbox = await store.authorise_mailbox(mailbox_id, authorization)
|
|
160
|
+
request.state.authorised_mailbox = await messaging.authorise_mailbox(mailbox_id, authorization)
|
mesh_sandbox/handlers/admin.py
CHANGED
|
@@ -3,8 +3,8 @@ from uuid import uuid4
|
|
|
3
3
|
|
|
4
4
|
from fastapi import BackgroundTasks, Depends, HTTPException, status
|
|
5
5
|
|
|
6
|
-
from ..common import
|
|
7
|
-
from ..dependencies import
|
|
6
|
+
from ..common.messaging import Messaging
|
|
7
|
+
from ..dependencies import get_messaging
|
|
8
8
|
from ..models.message import (
|
|
9
9
|
Message,
|
|
10
10
|
MessageEvent,
|
|
@@ -13,35 +13,33 @@ from ..models.message import (
|
|
|
13
13
|
MessageStatus,
|
|
14
14
|
MessageType,
|
|
15
15
|
)
|
|
16
|
-
from ..
|
|
17
|
-
from ..views.admin import PutReportRequest
|
|
16
|
+
from ..views.admin import AddMessageEventRequest, CreateReportRequest
|
|
18
17
|
|
|
19
18
|
|
|
20
19
|
class AdminHandler:
|
|
21
|
-
def __init__(self,
|
|
22
|
-
self.
|
|
23
|
-
self.store = store
|
|
20
|
+
def __init__(self, messaging: Messaging = Depends(get_messaging)):
|
|
21
|
+
self.messaging = messaging
|
|
24
22
|
|
|
25
23
|
async def reset(self, mailbox_id: Optional[str] = None):
|
|
26
24
|
|
|
27
|
-
if
|
|
25
|
+
if self.messaging.readonly:
|
|
28
26
|
raise HTTPException(
|
|
29
27
|
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
|
|
30
|
-
detail=
|
|
28
|
+
detail="reset not supported for current store mode",
|
|
31
29
|
)
|
|
32
30
|
|
|
33
31
|
if not mailbox_id:
|
|
34
|
-
await self.
|
|
32
|
+
await self.messaging.reset()
|
|
35
33
|
return
|
|
36
34
|
|
|
37
|
-
mailbox = await self.
|
|
35
|
+
mailbox = await self.messaging.get_mailbox(mailbox_id, accessed=False)
|
|
38
36
|
if not mailbox:
|
|
39
37
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="mailbox does not exist")
|
|
40
38
|
|
|
41
|
-
await self.
|
|
39
|
+
await self.messaging.reset_mailbox(mailbox.mailbox_id)
|
|
42
40
|
|
|
43
|
-
async def
|
|
44
|
-
recipient = await self.
|
|
41
|
+
async def create_report(self, request: CreateReportRequest, background_tasks: BackgroundTasks) -> Message:
|
|
42
|
+
recipient = await self.messaging.get_mailbox(request.mailbox_id, accessed=False)
|
|
45
43
|
if not recipient:
|
|
46
44
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="mailbox does not exist")
|
|
47
45
|
|
|
@@ -85,6 +83,26 @@ class AdminHandler:
|
|
|
85
83
|
),
|
|
86
84
|
)
|
|
87
85
|
|
|
88
|
-
await self.
|
|
86
|
+
await self.messaging.send_message(message=message, body=b"", background_tasks=background_tasks)
|
|
87
|
+
|
|
88
|
+
return message
|
|
89
|
+
|
|
90
|
+
async def add_message_event(
|
|
91
|
+
self, message_id: str, new_event: AddMessageEventRequest, background_tasks: BackgroundTasks
|
|
92
|
+
):
|
|
93
|
+
|
|
94
|
+
message = await self.messaging.get_message(message_id)
|
|
95
|
+
if not message:
|
|
96
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
97
|
+
|
|
98
|
+
event = MessageEvent(
|
|
99
|
+
status=new_event.status,
|
|
100
|
+
code=new_event.code,
|
|
101
|
+
event=new_event.event,
|
|
102
|
+
description=new_event.description,
|
|
103
|
+
linked_message_id=new_event.linked_message_id,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
message = await self.messaging.add_message_event(message, event, background_tasks)
|
|
89
107
|
|
|
90
108
|
return message
|
mesh_sandbox/handlers/inbox.py
CHANGED
|
@@ -8,20 +8,14 @@ from dateutil.tz import tzutc
|
|
|
8
8
|
from fastapi import BackgroundTasks, Depends, HTTPException, Response, status
|
|
9
9
|
from starlette.responses import JSONResponse
|
|
10
10
|
|
|
11
|
-
from ..common import
|
|
12
|
-
MESH_MEDIA_TYPES,
|
|
13
|
-
EnvConfig,
|
|
14
|
-
constants,
|
|
15
|
-
exclude_none_json_encoder,
|
|
16
|
-
index_of,
|
|
17
|
-
)
|
|
11
|
+
from ..common import MESH_MEDIA_TYPES, constants, exclude_none_json_encoder, index_of
|
|
18
12
|
from ..common.constants import Headers
|
|
19
13
|
from ..common.fernet import FernetHelper
|
|
20
14
|
from ..common.handler_helpers import get_handler_uri
|
|
21
|
-
from ..
|
|
15
|
+
from ..common.messaging import Messaging
|
|
16
|
+
from ..dependencies import get_fernet, get_messaging
|
|
22
17
|
from ..models.mailbox import Mailbox
|
|
23
18
|
from ..models.message import Message, MessageDeliveryStatus, MessageStatus, MessageType
|
|
24
|
-
from ..store.base import Store
|
|
25
19
|
from ..views.inbox import InboxV1, InboxV2, get_rich_inbox_view
|
|
26
20
|
|
|
27
21
|
HTTP_DATETIME_FORMAT = "%a, %d %b %Y %H:%M:%S %Z"
|
|
@@ -39,12 +33,11 @@ def to_http_datetime(maybe_naive_dt: datetime, as_timezone: Optional[tzinfo] = N
|
|
|
39
33
|
class InboxHandler:
|
|
40
34
|
def __init__(
|
|
41
35
|
self,
|
|
42
|
-
|
|
43
|
-
store: Store = Depends(get_store),
|
|
36
|
+
messaging: Messaging = Depends(get_messaging),
|
|
44
37
|
fernet: FernetHelper = Depends(get_fernet),
|
|
45
38
|
):
|
|
46
|
-
self.
|
|
47
|
-
|
|
39
|
+
self.messaging = messaging
|
|
40
|
+
|
|
48
41
|
self.fernet = fernet
|
|
49
42
|
|
|
50
43
|
@staticmethod
|
|
@@ -115,7 +108,7 @@ class InboxHandler:
|
|
|
115
108
|
|
|
116
109
|
async def head_message(self, mailbox: Mailbox, message_id: str):
|
|
117
110
|
|
|
118
|
-
message = await self.
|
|
111
|
+
message = await self.messaging.get_message(message_id)
|
|
119
112
|
|
|
120
113
|
if not message:
|
|
121
114
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=constants.ERROR_MESSAGE_DOES_NOT_EXIST)
|
|
@@ -166,7 +159,7 @@ class InboxHandler:
|
|
|
166
159
|
chunk_number: int = 1,
|
|
167
160
|
accepts_api_version: int = 1,
|
|
168
161
|
):
|
|
169
|
-
message = await self.
|
|
162
|
+
message = await self.messaging.get_message(message_id)
|
|
170
163
|
|
|
171
164
|
if not message:
|
|
172
165
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=constants.ERROR_MESSAGE_DOES_NOT_EXIST)
|
|
@@ -187,7 +180,7 @@ class InboxHandler:
|
|
|
187
180
|
|
|
188
181
|
status_code = status.HTTP_200_OK if chunk_number >= message.total_chunks else status.HTTP_206_PARTIAL_CONTENT
|
|
189
182
|
|
|
190
|
-
chunk = await self.
|
|
183
|
+
chunk = await self.messaging.get_chunk(message, chunk_number)
|
|
191
184
|
|
|
192
185
|
if chunk is None:
|
|
193
186
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=constants.ERROR_MESSAGE_DOES_NOT_EXIST)
|
|
@@ -218,7 +211,7 @@ class InboxHandler:
|
|
|
218
211
|
self, background_tasks: BackgroundTasks, mailbox: Mailbox, message_id: str, accepts_api_version: int = 1
|
|
219
212
|
):
|
|
220
213
|
|
|
221
|
-
message = await self.
|
|
214
|
+
message = await self.messaging.get_message(message_id)
|
|
222
215
|
if not message:
|
|
223
216
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=constants.ERROR_MESSAGE_DOES_NOT_EXIST)
|
|
224
217
|
|
|
@@ -234,7 +227,7 @@ class InboxHandler:
|
|
|
234
227
|
if message.status != MessageStatus.ACCEPTED:
|
|
235
228
|
return response()
|
|
236
229
|
|
|
237
|
-
await self.
|
|
230
|
+
await self.messaging.acknowledge_message(message=message, background_tasks=background_tasks)
|
|
238
231
|
|
|
239
232
|
return response()
|
|
240
233
|
|
|
@@ -248,9 +241,9 @@ class InboxHandler:
|
|
|
248
241
|
) -> tuple[list[Message], Optional[dict]]:
|
|
249
242
|
|
|
250
243
|
messages = (
|
|
251
|
-
await self.
|
|
244
|
+
await self.messaging.get_inbox_messages(mailbox.mailbox_id)
|
|
252
245
|
if rich
|
|
253
|
-
else await self.
|
|
246
|
+
else await self.messaging.get_accepted_inbox_messages(mailbox.mailbox_id)
|
|
254
247
|
)
|
|
255
248
|
|
|
256
249
|
if message_filter:
|
mesh_sandbox/handlers/lookup.py
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
from fastapi import Depends, HTTPException, status
|
|
2
2
|
|
|
3
|
-
from ..common import
|
|
4
|
-
from ..dependencies import
|
|
5
|
-
from ..store.base import Store
|
|
3
|
+
from ..common.messaging import Messaging
|
|
4
|
+
from ..dependencies import get_messaging
|
|
6
5
|
from ..views.lookup import endpoint_lookup_response, workflow_search_response
|
|
7
6
|
|
|
8
7
|
|
|
9
8
|
class LookupHandler:
|
|
10
|
-
def __init__(self,
|
|
11
|
-
self.
|
|
12
|
-
self.store = store
|
|
9
|
+
def __init__(self, messaging: Messaging = Depends(get_messaging)):
|
|
10
|
+
self.messaging = messaging
|
|
13
11
|
|
|
14
12
|
async def lookup_by_ods_code_and_workflow(self, ods_code: str, workflow_id: str, accepts_api_version: int = 1):
|
|
15
13
|
|
|
@@ -19,7 +17,7 @@ class LookupHandler:
|
|
|
19
17
|
if not workflow_id or (workflow_id and not workflow_id.strip()):
|
|
20
18
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="workflow id missing")
|
|
21
19
|
|
|
22
|
-
mailboxes = await self.
|
|
20
|
+
mailboxes = await self.messaging.lookup_by_ods_code_and_workflow_id(ods_code, workflow_id)
|
|
23
21
|
|
|
24
22
|
return endpoint_lookup_response(mailboxes, accepts_api_version)
|
|
25
23
|
|
|
@@ -28,6 +26,6 @@ class LookupHandler:
|
|
|
28
26
|
if not workflow_id or (workflow_id and not workflow_id.strip()):
|
|
29
27
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="workflow id missing")
|
|
30
28
|
|
|
31
|
-
mailboxes = await self.
|
|
29
|
+
mailboxes = await self.messaging.lookup_by_workflow_id(workflow_id)
|
|
32
30
|
|
|
33
31
|
return workflow_search_response(mailboxes, accepts_api_version)
|