-
Notifications
You must be signed in to change notification settings - Fork 17
Expand file tree
/
Copy pathmesh.py
More file actions
879 lines (801 loc) · 34.9 KB
/
Copy pathmesh.py
File metadata and controls
879 lines (801 loc) · 34.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
import platform
from dataclasses import dataclass
from datetime import datetime
from .exceptions import (
MESHAuthenticationError,
MESHDownloadErrors,
MESHInvalidRecipient,
MESHMessageAlreadyDownloaded,
MESHMessageMissing,
MESHMultipleMatches,
MESHUnknownError,
)
from gzip import compress, decompress
from hashlib import md5
from hmac import new as hmac
from math import ceil
from os import path
from uuid import uuid4
import logging
from typing import Generator, Union
import requests as r
@dataclass
class MESHConnection:
"""Class for handling MESH API interactions.
Parameters
----------
mailbox : string
The MESH ID of the mailbox this client is for
password : string
The password to this mailbox
api_shared_key : string
The shared API key for the MESH environment the mailbox is in
cert_loc : string
Path to the MESH API certificate location
key_loc : string
Path to the MESH API certificate private key location
base_ca_loc : string
Path to the base MESH certificate authority certificate bundle.
Set to False to disable inbound SSL checks if necessary
root_url : string, default = "https://mesh-sync.national.ncrs.nhs.uk"
Root MESH URL. Default value is the live MESH service
org : string, default = "NHS Digital"
Name of organisation owning the mailbox
"""
mailbox: str
password: str
api_shared_key: str
cert_loc: str
key_loc: str
base_ca_loc: str
root_url: str = "https://mesh-sync.national.ncrs.nhs.uk"
org: str = "NHS Digital"
def check_authentication(self) -> bool:
"""
Check authentication with the MESH API.
This should be done at the start of any session (per the API docs)
Returns
----------
bool
Indicates if authentication was successful or not
Raises
----------
MESHUnknownError
There was an unexpected return status from the MESH API
Examples
----------
>>> client.check_authentication() #doctest: +SKIP
True
"""
resp = r.post(
f"{self.root_url}/messageexchange/{self.mailbox}",
headers={
"Authorization": generate_authorization(
self.mailbox, self.password, self.api_shared_key
),
"Mex-ClientVersion": f"pyMESHAPI0.1a",
"Mex-OSArchitecture": platform.machine(),
"Mex-OSName": platform.system(),
"Mex-OSVersion": platform.version(),
},
cert=(self.cert_loc, self.key_loc),
verify=self.base_ca_loc,
)
if resp.status_code == 403:
return False
if resp.status_code == 200:
return True
raise MESHUnknownError(response=resp)
def send_file(
self,
dest_mailbox: str,
message_location: str,
workflow_id: str,
message_subject: str = None,
message_id: str = None,
process_id: str = None,
compress_message: bool = True,
encrypted: bool = False,
):
"""
Send a file to the MESH API.
This will automatically chunk the message if required, splitting into chunks at 80MB (MESH API has a
chunk size limit of 100MB). If required, this will also compress the message before transmission using
gzip.
Parameters
----------
dest_mailbox : string
MESH Mailbox ID of the recipient
message_location : string
Path to the readable file to send as a message
workflow_id : string
DTS Workflow ID
message_subject : string, default = None
Optional subject line to use for the message, for SMTP (email) messages.
message_id : string, default = None
Optional local identifier for the message. Required to track the message later.
process_id : string, default = None
Optional process ID for the MESH message. Currently not used in MESH, but included to ensure
future compatibility.
compress_message : boolean, default = True
Indicates if the message should be compressed. If true, then the message will be compressed
using gzip before sending to MESH.
encrypted : boolean, default = False
Indicates if the file to send has been encrypted. This is solely used to pass a flag to MESH
and does not encrypt the file or otherwise alter processing.
Returns
----------
dict
Dictionary of returned values from the MESH API
* messageID (str): value of the MESH internal ID assigned to the sent message
Raises
----------
MESHAuthenticationError
There was an authentication error accessing this page. Either the SSL certificate used is invalid,
or the client provided the wrong Mailbox ID, Password, or Shared Key.
MESHInvalidRecipient
The mailbox ID provided is not a valid recipient for this message
MESHUnknownError
There was an unexpected return status from the MESH API
Examples
----------
>>> client.send_file("TEST", 'c:/test/test.txt', 'test_flow') #doctest: +SKIP
{'messageID': '20200211115928515346_9359E2'}
"""
with open(message_location, "rb") as file:
message = file.read()
filename = path.basename(message_location)
return self.send_message(
dest_mailbox=dest_mailbox,
message=message,
filename=filename,
workflow_id=workflow_id,
message_subject=message_subject,
message_id=message_id,
process_id=process_id,
compress_message=compress_message,
encrypted=encrypted,
)
def send_message(
self,
dest_mailbox: str,
message: bytes,
filename: str,
workflow_id: str,
message_subject: str = None,
message_id: str = None,
process_id: str = None,
compress_message: bool = True,
encrypted: bool = False,
):
"""
Send a message to the MESH API.
This will automatically chunk the message if required, splitting into chunks at 80MB (MESH API has a
chunk size limit of 100MB). If required, this will also compress the message before transmission using
gzip.
Parameters
----------
dest_mailbox : string
MESH Mailbox ID of the recipient
message : bytes
Bytes representation of the file to transmit
filename : string
Original filename for the message being transmitted
workflow_id : string
DTS Workflow ID
message_subject : string, default = None
Optional subject line to use for the message, for SMTP (email) messages.
message_id : string, default = None
Optional local identifier for the message. Required to track the message later.
process_id : string, default = None
Optional process ID for the MESH message. Currently not used in MESH, but included to ensure
future compatibility.
compress_message : boolean, default = True
Indicates if the message should be compressed. If true, then the message will be compressed
using gzip before sending to MESH.
encrypted : boolean, default = False
Indicates if the file to send has been encrypted. This is solely used to pass a flag to MESH
and does not encrypt the file or otherwise alter processing.
Returns
----------
dict
Dictionary of returned values from the MESH API
* messageID (str): value of the MESH internal ID assigned to the sent message
Raises
----------
MESHAuthenticationError
There was an authentication error accessing this page. Either the SSL certificate used is invalid,
or the client provided the wrong Mailbox ID, Password, or Shared Key.
MESHInvalidRecipient
The mailbox ID provided is not a valid recipient for this message
MESHUnknownError
There was an unexpected return status from the MESH API
Examples
----------
>>> client.send_message("TEST", b'test', 'test.txt', 'test_flow') #doctest: +SKIP
{'messageID': '20200211115928515346_9359E2'}
"""
checksum = md5(message).hexdigest()
if compress_message:
message = compress(message)
headers = {
"Authorization": generate_authorization(
self.mailbox, self.password, self.api_shared_key
),
"Content-Type": "application/octet-stream",
"Mex-From": self.mailbox,
"Mex-To": dest_mailbox,
"Mex-WorkflowID": workflow_id,
"Mex-Filename": filename,
"Mex-MessageType": "DATA",
"Mex-Version": "1.0",
"Mex-Checksum": f"md5 {checksum}",
}
if process_id is not None:
headers["Mex-ProcessID"] = process_id
if message_id is not None:
headers["Mex-LocalID"] = message_id
if compress_message:
headers["Mex-Content-Compressed"] = "Y"
headers["Content-Encoding"] = "gzip"
if encrypted:
headers["Mex-Content-Encrypted"] = "Y"
if message_subject is not None:
headers["Mex-Subject"] = message_subject
if len(message) > 80000000:
headers["Mex-Chunk-Range"] = f"1:{ceil(len(message)/80000000)}"
if len(message) > 80000000:
resp = r.post(
url=f"{self.root_url}/messageexchange/{self.mailbox}/outbox",
data=message[0:80000000],
headers=headers,
cert=(self.cert_loc, self.key_loc),
verify=self.base_ca_loc,
)
if resp.status_code == 403:
raise MESHAuthenticationError(response=resp)
if resp.status_code == 417:
raise MESHInvalidRecipient(response=resp)
if resp.status_code != 202:
raise MESHUnknownError(response=resp)
message_id = resp.json()["messageID"]
for chunk in range(2, ceil(len(message) / 80000000) + 1):
self._send_message_chunk(
message_id=message_id,
message_chunk=message[(chunk - 1) * 80000000:chunk * 80000000],
chunk_no=chunk,
chunk_range=ceil(len(message) / 80000000),
compressed=compress_message,
)
else:
resp = r.post(
url=f"{self.root_url}/messageexchange/{self.mailbox}/outbox",
data=message,
headers=headers,
cert=(self.cert_loc, self.key_loc),
verify=self.base_ca_loc,
)
if resp.status_code == 403:
raise MESHAuthenticationError(response=resp)
if resp.status_code == 417:
raise MESHInvalidRecipient(response=resp)
if resp.status_code != 202:
raise MESHUnknownError(response=resp)
return resp.json()
def _send_message_chunk(
self,
message_id: str,
message_chunk: bytes,
chunk_no: int,
chunk_range: int,
compressed: bool = True,
) -> None:
"""
Send a message chunk to the MESH API.
This is expected to only be called by the send_message method.
Parameters
----------
message_id : string
The internal MESH ID of the message to upload a chunk for
message_chunk : bytes
The data to send in this chunk
chunk_no : integer
The number of the chunk to upload
chunk_range : integer
How many chunks there are to upload in total
compressed : boolean, default = True
Is the message compressed?
Raises
----------
MESHAuthenticationError
There was an authentication error accessing this page. Either the SSL certificate used is invalid,
or the client provided the wrong Mailbox ID, Password, or Shared Key.
MESHUnknownError
There was an unexpected return status from the MESH API
Examples
----------
>>> client._send_message_chunk("20200211115754892283_BC7B68", b'test', 2) #doctest: +SKIP
"""
headers = {
"Authorization": generate_authorization(
self.mailbox, self.password, self.api_shared_key
),
"Mex-From": self.mailbox,
"Content-Type": "application/octet-stream",
"Mex-Chunk-Range": f"{chunk_no}:{chunk_range}",
}
if compressed:
headers["Content-Encoding"] = "gzip"
resp = r.post(
url=f"{self.root_url}/messageexchange/{self.mailbox}/outbox/{message_id}/{chunk_no}",
data=message_chunk,
headers=headers,
cert=(self.cert_loc, self.key_loc),
verify=self.base_ca_loc,
)
if resp.status_code == 403:
raise MESHAuthenticationError(response=resp)
if resp.status_code != 202:
raise MESHUnknownError(response=resp)
def check_message_status(self, message_id: str) -> dict:
"""
Check status of a sent message.
Parameters
----------
message_id : string
The local message ID, eg. as provided to send_message. Does NOT work with MESH Message IDs, only
the local ID optionally provided on sending the message.
Returns
----------
dict
The full response from the MESH API for this local ID. For details, consult the MESH API documentation
Raises
----------
MESHAuthenticationError
There was an authentication error accessing this page. Either the SSL certificate used is invalid,
or the client provided the wrong Mailbox ID, Password, or Shared Key.
MESHMultipleMatches
There are multiple messages in the outbox with this local ID
MESHUnknownError
There was an unexpected return status from the MESH API
Examples
----------
>>> client.check_message_status(test) #doctest: +SKIP
{"statusSuccess": ...}
"""
resp = r.get(
url=f"{self.root_url}/messageexchange/{self.mailbox}/outbox/tracking/{message_id}",
headers={
"Authorization": generate_authorization(
self.mailbox, self.password, self.api_shared_key
)
},
cert=(self.cert_loc, self.key_loc),
verify=self.base_ca_loc,
)
if resp.status_code == 403:
raise MESHAuthenticationError(response=resp)
# There is an error in the API itself - in case of multiple match
# will send an error page with status 200 instead of 300
if (resp.status_code == 300) or (
resp.text
== "<html><title>300: Multiple Choices</title><body>300: Multiple Choices</body></html>"
):
raise MESHMultipleMatches(response=resp)
if resp.status_code == 404:
raise MESHMessageMissing(response=resp)
if resp.status_code != 200:
raise MESHUnknownError(response=resp)
return resp.json()
def check_inbox(self) -> list:
"""
Determine the MESH IDs of the contents of the inbox.
This will return at most 500 entries, owing to the limitations of the API.
Returns
----------
list
The MESH IDs of the messages in the inbox (str)
Raises
----------
MESHAuthenticationError
There was an authentication error accessing this page. Either the SSL certificate used is invalid,
or the client provided the wrong Mailbox ID, Password, or Shared Key.
MESHUnknownError
There was an unexpected return status from the MESH API
Examples
----------
>>> client.check_inbox() #doctest: +SKIP
["20200211115754892283_BC7B68", "20200211115928515346_9359E2"]
"""
resp = r.get(
url=f"{self.root_url}/messageexchange/{self.mailbox}/inbox",
headers={
"Authorization": generate_authorization(
self.mailbox, self.password, self.api_shared_key
)
},
cert=(self.cert_loc, self.key_loc),
verify=self.base_ca_loc,
)
if resp.status_code == 403:
raise MESHAuthenticationError(response=resp)
if resp.status_code != 200:
raise MESHUnknownError(response=resp)
return resp.json()["messages"]
def check_inbox_count(self) -> int:
"""
Determine how many messages are in the MESH mailbox to download.
Returns
----------
int
The number of messages ready to download
Raises
----------
MESHAuthenticationError
There was an authentication error accessing this page. Either the SSL certificate used is invalid,
or the client provided the wrong Mailbox ID, Password, or Shared Key.
MESHUnknownError
There was an unexpected return status from the MESH API
Examples
----------
>>> client.check_inbox_count() #doctest: +SKIP
2
"""
resp = r.get(
url=f"{self.root_url}/messageexchange/{self.mailbox}/count",
headers={
"Authorization": generate_authorization(
self.mailbox, self.password, self.api_shared_key
)
},
cert=(self.cert_loc, self.key_loc),
verify=self.base_ca_loc,
)
if resp.status_code == 403:
raise MESHAuthenticationError(response=resp)
if resp.status_code != 200:
raise MESHUnknownError(response=resp)
return resp.json()["count"]
def check_and_download(
self, save_folder: str = None, recursive: bool = True
) -> Union[Generator[dict, None, None], None]:
"""
Download all messages in the inbox.
This will automatically handle reconstructing chunked messages, and automatically decompress any messages
which have Content-Encoding value of gzip.
WARNING: each downloaded message will be fully reconstructed and decompressed if needed. This may cause
issue for machines with very limited memory if there are very large files to download.
If save_folder is provided, then downloaded files will be saved into that folder with their original filenames
(and non-delivery receipts will be saved there). This may cause issue if there are multiple files with the
same filename.
If no save_folder is provided, then this function will return a generator which will yield each message in turn.
When the generator yields a message, it will send an acknowledgement to the MESH API for the previous
message; it is important that processing of the messages be complete and any required final outputs saved
before this - once acknowledged a message cannot be downloaded from MESH again.
Parameters
----------
save_folder : string, default = None
If provided, the folder to save all downloaded files to when this function is called. The function
will not yield intermediate results.
* For data files, the file will be saved in this folder with its original filename.
* For non-delivery reports, there will be a file created in the folder with filename
'Non delivery report: (MESH message ID of failed delivery).txt', and with
content 'Message not delivered. All known details below' followed by the full
dictionary of headers from the download response.
If not provided, then this function will instead yield results as documented below.
recursive : boolean, default = True
If true, then this method will be called recursively so long as there are more than 500 messages
in the inbox, the maximum number of messages the MESH API will provide IDs for at once. If false,
then only one call will be made to retrieve inbox contents, and at most 500 messages will be downloaded.
Yields
----------
dict
Dictionary of details about the downloaded file.
* filename (str): Filename of the original file (if provided).
* contents (bytes): Contents of the file (reconstructed and decompressed if necessary).
* headers (dict): Dictionary of headers returned by MESH on the initial download request.
For full details see the MESH API documentation.
* datafile (boolean): Indicates if this was a data file or a non-delivery report.
Raises
----------
MESHAuthenticationError
There was an authentication error accessing the inbox. Either the SSL certificate used is invalid,
or the client provided the wrong Mailbox ID, Password, or Shared Key.
MESHUnknownError
There was an unexpected return status from the MESH API when accessing the inbox
MESHDownloadErrors
There were errors during the download process. This exception has the attribute 'exceptions',
which contains a full list of messages which generated exceptions, along with the exception.
This is only raised after completion of all non-error downloads, and downloads which raise
an exception are not acknowledged to the MESH API.
Examples
----------
>>> client.check_and_download("C:/Test Folder/") #doctest: +SKIP
>>> for message in client.check_and_download(): #doctest: +SKIP
>>> print(message) #doctest: +SKIP
{'filename': 'test.txt', 'contents': b'test_message', 'headers': {...}, datafile: True}
{'filename': 'test2.txt', 'contents': b'test_message_2', 'headers': {...}, datafile: True}
{'filename': None, 'contents': b'', 'headers': {'Mex-Linkedmsgid': '1234567890', ...}, datafile: False}
"""
if save_folder is None:
return self._check_download_generator(recursive)
else:
self._check_download_save(save_folder, recursive)
def _check_download_generator(self, recursive: bool) -> Generator[dict, None, None]:
"""Internal only - generator to return for check_and_download"""
message_ids = self.check_inbox()
exceptions = []
if recursive:
repeat_needed = self.check_inbox_count() > 500
for message_id in message_ids:
try:
yield self.download_message(message_id, save_folder=None)
except Exception as e:
exceptions.append((message_id, e))
else:
self.ack_download_message(message_id)
# Force termination if there are enough messages failing to download that they fill the inbox
# Reduces risk of infinite loops
if len(exceptions) >= 500:
raise MESHDownloadErrors(exceptions)
if recursive and repeat_needed:
try:
for msg in self._check_download_generator(recursive=True):
yield msg
except MESHDownloadErrors as e:
exceptions.extend(e.exceptions)
if exceptions:
raise MESHDownloadErrors(exceptions)
def _check_download_save(self, save_folder: str, recursive: bool) -> None:
"""Internal only - function to save results for check_and_download"""
message_ids = self.check_inbox()
exceptions = []
if recursive:
repeat_needed = self.check_inbox_count() > 500
for message_id in message_ids:
try:
self.download_message(message_id, save_folder)
except Exception as e:
exceptions.append((message_id, e))
else:
self.ack_download_message(message_id)
# Force termination if there are enough messages failing to download that they fill the inbox
# Reduces risk of infinite loops
if len(exceptions) >= 500:
raise MESHDownloadErrors(exceptions)
if recursive and repeat_needed:
try:
self._check_download_save(save_folder, recursive=True)
except MESHDownloadErrors as e:
exceptions.extend(e.exceptions)
if exceptions:
raise MESHDownloadErrors(exceptions)
def download_message(self, message_id: str, save_folder: str = None) -> dict:
"""
Request a message from the MESH API.
This will automatically handle reconstructing chunked messages, and automatically decompress any messages
which have Content-Encoding value of gzip.
WARNING: the full, reconstructed message will be held in memory, including after decompression. This may
cause problems, if you are using the API to download very large files on a machine with very limited memory.
Parameters
----------
message_id : string
The internal MESH ID of the message to download
save_folder : string, default = None
Optional, the folder to save the downloaded message to. If not provided, then the files are not saved.
* For data files, the file will be saved in this folder with its original filename.
* For non-delivery reports, there will be a file created in the folder with filename
'Non delivery report: (MESH message ID of failed delivery).txt', and with
content 'Message not delivered. All known details below' followed by the full
dictionary of headers from the download response.
Returns
----------
dict
Dictionary of details about the downloaded file.
* filename (str): Filename of the original file (if provided).
* contents (bytes): Contents of the file (reconstructed and decompressed if necessary).
* headers (dict): Dictionary of headers returned by MESH on the initial download request.
For full details see the MESH API documentation.
* datafile (boolean): Indicates if this was a data file or a non-delivery report.
Raises
----------
MESHAuthenticationError
There was an authentication error accessing this page. Either the SSL certificate used is invalid,
or the client provided the wrong Mailbox ID, Password, or Shared Key.
MESHMessageMissing
There is no message with the provided message ID in the mailbox
MESHMessageAlreadyDownloaded
The message with the provided message ID has already been downloaded and acknowledged
MESHUnknownError
There was an unexpected return status from the MESH API
Examples
----------
>>> client.download_message("20200211115754892283_BC7B68", "C:/Test Folder/") #doctest: +SKIP
{'filename': 'test.txt', 'contents': b'test_message', 'headers': {'Mex-Filename': 'test.txt', ...}, data: True}
>>> client.download_message("20200211115754892283_BC7B69") #doctest: +SKIP
{'filename': None, 'contents': b'', 'headers': {'Mex-Linkedmsgid': '1234567890', ...}, data: False}
"""
resp = r.get(
url=f"{self.root_url}/messageexchange/{self.mailbox}/inbox/{message_id}",
headers={
"Authorization": generate_authorization(
self.mailbox, self.password, self.api_shared_key
),
"Accept-Encoding": "gzip",
},
cert=(self.cert_loc, self.key_loc),
verify=self.base_ca_loc,
stream=True,
)
if resp.status_code == 403:
raise MESHAuthenticationError(response=resp)
elif resp.status_code == 404:
raise MESHMessageMissing(response=resp)
elif resp.status_code == 410:
raise MESHMessageAlreadyDownloaded(response=resp)
elif resp.status_code == 206:
core_data = resp.raw.data
chunk_count = int(resp.headers["Mex-Chunk-Range"][2:])
for chunk in range(2, chunk_count + 1):
core_data += self._download_message_chunk(message_id, chunk)
elif resp.status_code == 200:
core_data = resp.raw.data
else:
raise MESHUnknownError(response=resp)
# If this header exists, the message is a non delivery report
if ("Mex-Linkedmsgid" in resp.headers) or (
resp.headers["Mex-MessageType"] == "REPORT"
):
logging.info(
f"Non delivery report for message {resp.headers['Mex-Linkedmsgid']}"
)
if save_folder is not None:
with open(
path.join(
save_folder,
f"Non delivery report: {resp.headers['Mex-Linkedmsgid']}.txt",
),
"w",
) as file:
file.write(
"Message not delivered. All known details below\n"
+ str(resp.headers)
)
return {
"filename": resp.headers.get("Mex-Filename"),
"contents": resp.content,
"headers": resp.headers,
"datafile": False,
}
if ("Content-Encoding" in resp.headers) and (
resp.headers["Content-Encoding"] == "gzip"
):
core_data = decompress(core_data)
if save_folder is not None:
with open(
path.join(save_folder, resp.headers["Mex-Filename"]), "wb"
) as file:
file.write(core_data)
return {
"filename": resp.headers["Mex-Filename"],
"contents": core_data,
"headers": resp.headers,
"datafile": True,
}
def _download_message_chunk(self, message_id: str, chunk_no: int) -> bytes:
"""
Request a message chunk from the MESH API.
This is expected to only be called by the download_message method.
Parameters
----------
message_id : string
The internal MESH ID of the message to download a chunk from
chunk_no : integer
The number of the chunk to download
Returns
----------
bytes
The raw content of the downloaded chunk
Raises
----------
MESHAuthenticationError
There was an authentication error accessing this page. Either the SSL certificate used is invalid,
or the client provided the wrong Mailbox ID, Password, or Shared Key.
MESHMessageMissing
There is no message with the provided message ID in the mailbox
MESHMessageAlreadyDownloaded
The message with the provided message ID has already been downloaded and acknowledged
MESHUnknownError
There was an unexpected return status from the MESH API
Examples
----------
>>> client._download_message_chunk("20200211115754892283_BC7B68", 1) #doctest: +SKIP
b'test_message'
"""
resp = r.get(
url=f"{self.root_url}/messageexchange/{self.mailbox}/inbox/{message_id}/{chunk_no}",
headers={
"Authorization": generate_authorization(
self.mailbox, self.password, self.api_shared_key
),
"Accept-Encoding": "gzip",
},
cert=(self.cert_loc, self.key_loc),
verify=self.base_ca_loc,
stream=True,
)
if resp.status_code == 403:
raise MESHAuthenticationError(response=resp)
elif resp.status_code == 404:
raise MESHMessageMissing(response=resp)
elif resp.status_code == 410:
raise MESHMessageAlreadyDownloaded(response=resp)
elif resp.status_code in (200, 206):
return resp.raw.data
else:
raise MESHUnknownError(response=resp)
def ack_download_message(self, message_id: str) -> None:
"""
Send acknowledgement to the MESH API that a message has finished downloading.
This should only be done after the message has successfully been saved -
once sent, the message is remvoed from the MESH server.
Per the API, this must be sent once a message has been successfully processed.
Parameters
----------
message_id : string
The internal MESH ID of the downloaded message
Raises
----------
MESHAuthenticationError
There was an authentication error accessing this page. Either the SSL certificate used is invalid,
or the client provided the wrong Mailbox ID, Password, or Shared Key.
MESHUnknownError
There was an unexpected return status from the MESH API
Examples
----------
>>> client.ack_download_message("20200211115754892283_BC7B68") #doctest: +SKIP
"""
resp = r.put(
url=f"{self.root_url}/messageexchange/{self.mailbox}/inbox/{message_id}/status/acknowledged",
headers={
"Authorization": generate_authorization(
self.mailbox, self.password, self.api_shared_key
)
},
cert=(self.cert_loc, self.key_loc),
verify=self.base_ca_loc,
)
if resp.status_code == 403:
raise MESHAuthenticationError(response=resp)
if resp.status_code != 200:
raise MESHUnknownError(response=resp)
def generate_authorization(mailbox: str, password: str, api_shared_key: str) -> str:
"""
Generate an authorization string as specified by the MESH API documentation v1.14
Parameters
----------
mailbox : string
The mailbox ID to generate authorization for
password : string
The password for the mailbox
api_shared_key : string
The shared API key for the MESH environment the request is being made to
Returns
----------
string
The generated authentication string
Examples
----------
>>> generate_authorization("TEST_BOX", "TEST_PW", "TEST_KEY") #doctest: +SKIP
"NHSMESH TEST_BOX:ccd54b96-ee41-4d34-9700-7f9ec63d0720:1:202002120857:763 ... 872c"
>>> generate_authorization("NEW_BOX", "NEW_PW", "TEST_KEY") #doctest: +SKIP
"NHSMESH NEW_BOX:662c4ffa-c85c-4858-bae8-7327e09aeeb5:1:202002120858:7f1 ... 0d95"
"""
nonce = uuid4()
time = datetime.now().strftime("%Y%m%d%H%M")
hash_out = hmac(
api_shared_key.encode(),
msg=f"{mailbox}:{nonce}:1:{password}:{time}".encode("utf8"),
digestmod="sha256",
).hexdigest()
return f"NHSMESH {mailbox}:{nonce}:1:{time}:{hash_out}"