mms-client 1.0.5__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.
@@ -0,0 +1,591 @@
1
+ """Contains the client layer for communicating with the MMS server."""
2
+
3
+ from dataclasses import dataclass
4
+ from logging import Logger
5
+ from logging import getLogger
6
+ from typing import Dict
7
+ from typing import Generic
8
+ from typing import List
9
+ from typing import Optional
10
+ from typing import Protocol
11
+ from typing import Tuple
12
+ from typing import Type
13
+
14
+ from mms_client.security.crypto import Certificate
15
+ from mms_client.security.crypto import CryptoWrapper
16
+ from mms_client.types.base import BaseResponse
17
+ from mms_client.types.base import E
18
+ from mms_client.types.base import MultiResponse
19
+ from mms_client.types.base import P
20
+ from mms_client.types.base import Response
21
+ from mms_client.types.base import ResponseCommon
22
+ from mms_client.types.base import ValidationStatus
23
+ from mms_client.types.transport import Attachment
24
+ from mms_client.types.transport import MmsRequest
25
+ from mms_client.types.transport import MmsResponse
26
+ from mms_client.types.transport import RequestDataType
27
+ from mms_client.types.transport import RequestType
28
+ from mms_client.types.transport import ResponseDataType
29
+ from mms_client.utils.errors import AudienceError
30
+ from mms_client.utils.errors import MMSClientError
31
+ from mms_client.utils.errors import MMSValidationError
32
+ from mms_client.utils.serialization import Serializer
33
+ from mms_client.utils.web import ClientType
34
+ from mms_client.utils.web import Interface
35
+ from mms_client.utils.web import ZWrapper
36
+
37
+ # Set the default logger for the MMS client
38
+ default_logger = getLogger("MMS Client")
39
+
40
+
41
+ @dataclass
42
+ class ServiceConfiguration:
43
+ """Configuration for a service on the MMS server."""
44
+
45
+ # The interface for the service
46
+ interface: Interface
47
+
48
+ # A serializer used to serialize and deserialize the data for the service
49
+ serializer: Serializer
50
+
51
+
52
+ @dataclass
53
+ class EndpointConfiguration(Generic[E, P]):
54
+ """Configuration for an endpoint on the MMS server."""
55
+
56
+ # The name of the endpoint
57
+ name: str
58
+
59
+ # The allowed client types for the endpoint
60
+ allowed_client: Optional[ClientType]
61
+
62
+ # The service for the endpoint
63
+ service: ServiceConfiguration
64
+
65
+ # The type of request to submit to the MMS server
66
+ request_type: RequestType
67
+
68
+ # The type of payload to expect in the response
69
+ response_envelope_type: Optional[Type[E]]
70
+
71
+ # The type of data to expect in the response
72
+ response_data_type: Optional[Type[P]]
73
+
74
+
75
+ class ClientProto(Protocol):
76
+ """Protocol for the MMS client, allowing for proper typing of the mixins."""
77
+
78
+ @property
79
+ def participant(self) -> str:
80
+ """Return the MMS code of the business entity to which the requesting user belongs."""
81
+
82
+ @property
83
+ def user(self) -> str:
84
+ """Return the user name of the person making the request."""
85
+
86
+ @property
87
+ def logger(self) -> Logger:
88
+ """Return the logger for the client."""
89
+
90
+ def verify_audience(self, config: EndpointConfiguration) -> None:
91
+ """Verify that the client type is allowed.
92
+
93
+ Some MMS endpoints are only accessible to certain client types. This method is used to verify that the client
94
+ type is allowed to access the endpoint.
95
+
96
+ Arguments:
97
+ config (EndpointConfiguration): The configuration for the endpoint.
98
+
99
+ Raises:
100
+ ValueError: If the client type is not allowed.
101
+ """
102
+
103
+ def request_one(
104
+ self,
105
+ envelope: E,
106
+ data: P,
107
+ config: EndpointConfiguration,
108
+ ) -> Tuple[Response[E, P], Dict[str, bytes]]:
109
+ """Submit a request to the MMS server and return the response.
110
+
111
+ Arguments:
112
+ envelope (Envelope): The payload envelope to submit to the MMS server.
113
+ data (Payload): The data to submit to the MMS server.
114
+ config (EndpointConfiguration): The configuration for the endpoint.
115
+
116
+ Returns: The response from the MMS server.
117
+ """
118
+
119
+ def request_many(
120
+ self,
121
+ envelope: E,
122
+ data: P,
123
+ config: EndpointConfiguration,
124
+ ) -> Tuple[MultiResponse[E, P], Dict[str, bytes]]:
125
+ """Submit a request to the MMS server and return the multi-response.
126
+
127
+ Arguments:
128
+ envelope (Envelope): The payload envelope to submit to the MMS server.
129
+ data (Payload): The data to submit to the MMS server.
130
+ config (EndpointConfiguration): The configuration for the endpoint.
131
+
132
+ Returns: The multi-response from the MMS server.
133
+ """
134
+
135
+
136
+ def mms_endpoint(
137
+ name: str,
138
+ service: ServiceConfiguration,
139
+ request_type: RequestType,
140
+ allowed_client: Optional[ClientType] = None,
141
+ resp_envelope_type: Optional[Type[E]] = None,
142
+ resp_data_type: Optional[Type[P]] = None,
143
+ ):
144
+ """Create a decorator for an MMS endpoint.
145
+
146
+ This decorator is used to mark a method as an MMS endpoint. It will add the endpoint configuration to the function
147
+ and submit the request to the MMS server when the function is called. The response will be extracted and returned.
148
+ Decorated functions will only be responsible for creating the payload envelope to submit to the MMS server.
149
+
150
+ Arguments:
151
+ name (str): The name of the endpoint.
152
+ service (ServiceConfiguration): The configuration for the service.
153
+ request_type (RequestType): The type of request to submit to the MMS server.
154
+ allowed_client (ClientType): The type of client that is allowed to access the endpoint. If this is not provided,
155
+ then any client type is allowed.
156
+ resp_envelope_type (Type[E]): The type of payload to expect in the response. If this is not provided, then the
157
+ the response envelope will be assumed to have the same type as the request envelope.
158
+ resp_data_type (Type[P]): The type of data to expect in the response. If this is not provided, then the
159
+ response data will be assumed to have the same type as the request data.
160
+ """
161
+ # First, create the endpoint configuration from the given parameters
162
+ config = EndpointConfiguration(name, allowed_client, service, request_type, resp_envelope_type, resp_data_type)
163
+
164
+ # Next, create a decorator that will add the endpoint configuration to the function
165
+ def decorator(func):
166
+ def wrapper(self: ClientProto, *args, **kwargs) -> Optional[P]:
167
+
168
+ # First, verify that the client type is allowed
169
+ self.verify_audience(config)
170
+
171
+ # Next, call the wrapped function to get the envelope
172
+ envelope = func(self, *args, **kwargs)
173
+
174
+ # Now, submit the request to the MMS server and get the response
175
+ resp, _ = self.request_one(envelope, args[0], config)
176
+
177
+ # Finally, extract the data from the response and return it
178
+ return resp.data
179
+
180
+ return wrapper
181
+
182
+ # Finally, return the decorator
183
+ return decorator
184
+
185
+
186
+ def mms_multi_endpoint(
187
+ name: str,
188
+ service: ServiceConfiguration,
189
+ request_type: RequestType,
190
+ allowed_client: Optional[ClientType] = None,
191
+ resp_envelope_type: Optional[Type[E]] = None,
192
+ resp_data_type: Optional[Type[P]] = None,
193
+ ):
194
+ """Create a decorator for an MMS multi-response endpoint.
195
+
196
+ This decorator is used to mark a method as an MMS multi-response endpoint. It will add the endpoint configuration to
197
+ the function and submit the request to the MMS server when the function is called. The multi-response will be
198
+ extracted and returned. Decorated functions will only be responsible for creating the payload envelope to submit to
199
+ the MMS server.
200
+
201
+ Arguments:
202
+ name (str): The name of the endpoint.
203
+ service (ServiceConfiguration): The configuration for the service.
204
+ request_type (RequestType): The type of request to submit to the MMS server.
205
+ allowed_client (ClientType): The type of client that is allowed to access the endpoint. If this is not provided,
206
+ then any client type is allowed.
207
+ resp_envelope_type (Type[E]): The type of payload to expect in the response. If this is not provided, then the
208
+ the response envelope will be assumed to have the same type as the request envelope.
209
+ resp_data_type (Type[P]): The type of data to expect in the response. If this is not provided, then the
210
+ response data will be assumed to have the same type as the request data. Note, that
211
+ this is not intended to account for the expected sequence type of the response data.
212
+ That is already handled in the wrapped function, so this should only be set if the
213
+ inner data type being returned differs from what was sent.
214
+ """
215
+ # First, create the endpoint configuration from the given parameters
216
+ config = EndpointConfiguration(name, allowed_client, service, request_type, resp_envelope_type, resp_data_type)
217
+
218
+ # Next, create a decorator that will add the endpoint configuration to the function
219
+ def decorator(func):
220
+ def wrapper(self: ClientProto, *args, **kwargs) -> List[P]:
221
+ self.logger.info(f"{config.name}: Called with args: {args[1:]}...")
222
+
223
+ # First, verify that the client type is allowed
224
+ self.verify_audience(config)
225
+
226
+ # Next, call the wrapped function to get the envelope
227
+ envelope = func(self, *args, **kwargs)
228
+
229
+ # Now, submit the request to the MMS server and get the response
230
+ resp, _ = self.request_many(envelope, args[0], config)
231
+
232
+ # Finally, extract the data from the response and return it
233
+ self.logger.info(f"{config.name}: Returning {len(resp.data)} item(s).")
234
+ return resp.data
235
+
236
+ return wrapper
237
+
238
+ # Finally, return the decorator
239
+ return decorator
240
+
241
+
242
+ class BaseClient: # pylint: disable=too-many-instance-attributes
243
+ """Base end-client for the MMS server.
244
+
245
+ This class is used to communicate with the MMS server.
246
+ """
247
+
248
+ def __init__(
249
+ self,
250
+ participant: str,
251
+ user: str,
252
+ client_type: ClientType,
253
+ cert: Certificate,
254
+ logger: Optional[Logger] = None,
255
+ is_admin: bool = False,
256
+ test: bool = False,
257
+ ):
258
+ """Create a new MMS client with the given participant, user, client type, and authentication.
259
+
260
+ Arguments:
261
+ participant (str): The MMS code of the business entity to which the requesting user belongs.
262
+ user (str): The user name of the person making the request.
263
+ client_type (ClientType): The type of client to use for making requests to the MMS server.
264
+ cert (Certificate): The certificate to use for signing requests.
265
+ logger (Logger): The logger to use for instrumentation. If this is not provided, then the default
266
+ logger will be used.
267
+ is_admin (bool): Whether the user is an admin (i.e. is a market operator).
268
+ test (bool): Whether to use the test server.
269
+ """
270
+ # First, save the base field associated with the client
271
+ self._participant = participant
272
+ self._user = user
273
+ self._client_type = client_type
274
+ self._is_admin = is_admin
275
+ self._test = test
276
+
277
+ # Next, save the security-related fields associated with the client
278
+ self._cert = cert
279
+ self._signer = CryptoWrapper(cert)
280
+
281
+ # Now, set our logger to either the provided logger or the default logger
282
+ self._logger = logger or default_logger
283
+
284
+ # Finally, create a list of wrappers for the different interfaces
285
+ self._wrappers: Dict[Interface, ZWrapper] = {}
286
+
287
+ @property
288
+ def participant(self) -> str:
289
+ """Return the MMS code of the business entity to which the requesting user belongs."""
290
+ return self._participant
291
+
292
+ @property
293
+ def user(self) -> str:
294
+ """Return the user name of the person making the request."""
295
+ return self._user
296
+
297
+ @property
298
+ def logger(self) -> Logger:
299
+ """Return the logger for the client."""
300
+ return self._logger
301
+
302
+ def verify_audience(self, config: EndpointConfiguration) -> None:
303
+ """Verify that the client type is allowed.
304
+
305
+ Some MMS endpoints are only accessible to certain client types. This method is used to verify that the client
306
+ type is allowed to access the endpoint.
307
+
308
+ Arguments:
309
+ config (EndpointConfiguration): The configuration for the endpoint.
310
+
311
+ Raises:
312
+ ValueError: If the client type is not allowed.
313
+ """
314
+ self._logger.debug(
315
+ f"{config.name}: Verifying audience. Allowed client: "
316
+ f"{config.allowed_client.name if config.allowed_client else 'Any'}."
317
+ )
318
+ if config.allowed_client and self._client_type != config.allowed_client:
319
+ raise AudienceError(config.name, config.allowed_client, self._client_type)
320
+
321
+ def request_one(
322
+ self,
323
+ envelope: E,
324
+ payload: P,
325
+ config: EndpointConfiguration[E, P],
326
+ ) -> Tuple[Response[E, P], Dict[str, bytes]]:
327
+ """Submit a request to the MMS server and return the response.
328
+
329
+ Arguments:
330
+ envelope (Envelope): The payload envelope to submit to the MMS server.
331
+ payload (Payload): The data to submit to the MMS server.
332
+ config (EndpointConfiguration): The configuration for the endpoint.
333
+
334
+ Returns: The response from the MMS server.
335
+ """
336
+ # First, create the MMS request from the payload and data.
337
+ self._logger.debug(
338
+ f"{config.name}: Starting request. Envelope: {type(envelope).__name__}, Data: {type(payload).__name__}",
339
+ )
340
+ request = self._to_mms_request(config.request_type, config.service.serializer.serialize(envelope, payload))
341
+
342
+ # Next, submit the request to the MMS server and get and verify the response.
343
+ resp = self._get_wrapper(config.service).submit(request)
344
+ self._verify_mms_response(resp, config)
345
+
346
+ # Now, extract the attachments from the response
347
+ attachments = {a.name: a.data for a in resp.attachments}
348
+
349
+ # Finally, deserialize and verify the response
350
+ envelope_type = config.response_envelope_type or type(envelope)
351
+ data_type = config.response_data_type or type(payload)
352
+ data: Response[E, P] = config.service.serializer.deserialize(resp.payload, envelope_type, data_type)
353
+ self._verify_response(data, config)
354
+
355
+ # Return the response data and any attachments
356
+ self._logger.debug(
357
+ f"{config.name}: Returning response. Envelope: {envelope_type.__name__}, Data: {data_type.__name__}",
358
+ )
359
+ return data, attachments
360
+
361
+ def request_many(
362
+ self,
363
+ envelope: E,
364
+ payload: P,
365
+ config: EndpointConfiguration[E, P],
366
+ ) -> Tuple[MultiResponse[E, P], Dict[str, bytes]]:
367
+ """Submit a request to the MMS server and return the multi-response.
368
+
369
+ Arguments:
370
+ envelope (Envelope): The payload envelope to submit to the MMS server.
371
+ payload (Payload): The data to submit to the MMS server.
372
+ config (EndpointConfiguration): The configuration for the endpoint.
373
+
374
+ Returns: The multi-response from the MMS server.
375
+ """
376
+ # First, create the MMS request from the payload and data.
377
+ self._logger.debug(
378
+ (
379
+ f"{config.name}: Starting multi-request. Envelope: {type(envelope).__name__}, "
380
+ f"Data: {type(payload).__name__}"
381
+ ),
382
+ )
383
+ request = self._to_mms_request(config.request_type, config.service.serializer.serialize(envelope, payload))
384
+
385
+ # Next, submit the request to the MMS server and get and verify the response.
386
+ resp = self._get_wrapper(config.service).submit(request)
387
+ self._verify_mms_response(resp, config)
388
+
389
+ # Now, extract the attachments from the response
390
+ attachments = {a.name: a.data for a in resp.attachments}
391
+
392
+ # Finally, deserialize and verify the response
393
+ envelope_type = config.response_envelope_type or type(envelope)
394
+ data_type = config.response_data_type or type(payload)
395
+ data: MultiResponse[E, P] = config.service.serializer.deserialize_multi(resp.payload, envelope_type, data_type)
396
+ self._verify_multi_response(data, config)
397
+
398
+ # Return the response data and any attachments
399
+ self._logger.debug(
400
+ f"{config.name}: Returning multi-response. Envelope: {envelope_type.__name__}, Data: {data_type.__name__}",
401
+ )
402
+ return data, attachments
403
+
404
+ def _to_mms_request(
405
+ self,
406
+ req_type: RequestType,
407
+ data: bytes,
408
+ return_req: bool = False,
409
+ attachments: Optional[Dict[str, bytes]] = None,
410
+ ) -> MmsRequest:
411
+ """Convert the given data to an MMS request.
412
+
413
+ Arguments:
414
+ req_type (RequestType): The type of request to submit to the MMS server.
415
+ data (bytes): The data to submit to the MMS server.
416
+ return_req (bool): Whether to return the request data in the response. This is False by default.
417
+ attachments (Dict[str, bytes]): The attachments to send with the request.
418
+
419
+ Arguments: The MMS request to submit to the MMS server.
420
+ """
421
+ # Convert the attachments to the correct the MMS format
422
+ attachment_data = (
423
+ [
424
+ Attachment(signature=self._signer.sign(data), name=name, binaryData=data)
425
+ for name, data in attachments.items()
426
+ ]
427
+ if attachments
428
+ else []
429
+ )
430
+
431
+ # Embed the data and the attachments in the MMS request and return it
432
+ self._logger.debug(
433
+ f"Creating MMS request of type {req_type.name} to send {len(data)} bytes of data and "
434
+ f"{len(attachment_data)} attachments."
435
+ )
436
+ return MmsRequest(
437
+ requestType=req_type,
438
+ adminRole=self._is_admin,
439
+ requestDataType=RequestDataType.XML,
440
+ sendRequestDataOnSuccess=return_req,
441
+ requestSignature=self._signer.sign(data),
442
+ requestData=data,
443
+ attachmentData=attachment_data,
444
+ )
445
+
446
+ def _verify_mms_response(self, resp: MmsResponse, config: EndpointConfiguration) -> None:
447
+ """Verify that the given MMS response is valid.
448
+
449
+ Arguments:
450
+ resp (MmsResponse): The MMS response to verify.
451
+
452
+ Raises:
453
+ MMSClientError: If the response is not valid.
454
+ """
455
+ # Verify that the response is in the correct format. If it's not, raise an error.
456
+ if resp.data_type != ResponseDataType.XML:
457
+ raise MMSClientError(
458
+ config.name,
459
+ f"Invalid MMS response data type: {resp.data_type.name}. Only XML is supported.",
460
+ )
461
+ if resp.compressed:
462
+ raise MMSClientError(config.name, "Invalid MMS response. Compressed responses are not supported.")
463
+
464
+ # Check the response status flags and log any warnings or errors
465
+ if resp.warnings:
466
+ self._logger.warning(f"{config.name}: MMS response contained warnings.")
467
+ if not resp.success:
468
+ self._logger.error(f"{config.name}: MMS response was unsuccessful.")
469
+
470
+ def _verify_response(self, resp: Response[E, P], config: EndpointConfiguration) -> None:
471
+ """Verify that the given response is valid.
472
+
473
+ Arguments:
474
+ resp (Response): The response to verify.
475
+
476
+ Raises:
477
+ MMSValidationError: If the response is not valid.
478
+ """
479
+ # First, verify the base response data to make sure we haven't missed some request details
480
+ valid = self._verify_base_response(resp, config)
481
+
482
+ # Next, if we received data back with the request then verify that it's valid
483
+ if resp.payload:
484
+ valid = valid and self._verify_response_common(config, type(resp.data), resp.payload.data_validation)
485
+
486
+ # Now, log any messages that were returned with the response
487
+ self._verify_messages(config, resp)
488
+
489
+ # Finally, if the response is not valid, raise an error
490
+ if not valid:
491
+ raise MMSValidationError(config.name, resp.envelope, resp.data, resp.messages)
492
+
493
+ def _verify_multi_response(self, resp: MultiResponse[E, P], config: EndpointConfiguration) -> None:
494
+ """Verify that the given multi-response is valid.
495
+
496
+ Arguments:
497
+ resp (MultiResponse): The multi-response to verify.
498
+
499
+ Raises:
500
+ MMSValidationError: If the response is not valid.
501
+ """
502
+ # First, verify the base response data to make sure we haven't missed some request details
503
+ valid = self._verify_base_response(resp, config)
504
+
505
+ # Next, if we received data back with the request then verify that it's valid
506
+ if resp.payload:
507
+ valid = valid and all(
508
+ self._verify_response_common(config, type(data), resp.payload[i].data_validation, i)
509
+ for i, data in enumerate(resp.data)
510
+ )
511
+
512
+ # Now, log any messages that were returned with the response
513
+ self._verify_messages(config, resp)
514
+
515
+ # Finally, if the response is not valid, raise an error
516
+ if not valid:
517
+ raise MMSValidationError(config.name, resp.envelope, resp.data, resp.messages)
518
+
519
+ def _verify_base_response(self, resp: BaseResponse[E], config: EndpointConfiguration) -> bool:
520
+ """Verify that the given base response is valid.
521
+
522
+ Arguments:
523
+ resp (BaseResponse): The base response to verify.
524
+ config (EndpointConfiguration): The configuration for the endpoint.
525
+
526
+ Returns: True to indicate that the response is valid, False otherwise.
527
+ """
528
+ # Log the request's processing statistics
529
+ self._logger.info(
530
+ f"{config.name} ({resp.statistics.timestamp_xml}): Recieved {resp.statistics.received}, "
531
+ f"Valid: {resp.statistics.valid}, Invalid: {resp.statistics.invalid}, "
532
+ f"Successful: {resp.statistics.successful}, Unsuccessful: {resp.statistics.unsuccessful} "
533
+ f"in {resp.statistics.time_ms}ms"
534
+ )
535
+
536
+ # Check if the response is invalid and if the envelope had any validation issues. If not, then we have a
537
+ # valid base response so return True. Otherwise, return False.
538
+ return resp.statistics.invalid == 0 and self._verify_response_common(
539
+ config, type(resp.envelope), resp.envelope_validation
540
+ )
541
+
542
+ def _verify_messages(self, config: EndpointConfiguration, resp: BaseResponse[E]) -> None:
543
+ """Verify the messages in the given response.
544
+
545
+ Arguments:
546
+ config (EndpointConfiguration): The configuration for the endpoint.
547
+ resp (BaseResponse): The response to verify.
548
+ """
549
+ for path, messages in resp.messages.items():
550
+ for info in messages.information:
551
+ self._logger.info(f"{config.name} - {path}: {info.code}")
552
+ for warning in messages.warnings:
553
+ self._logger.warning(f"{config.name} - {path}: {warning.code}")
554
+ for error in messages.errors:
555
+ self._logger.error(f"{config.name} - {path}: {error.code}")
556
+
557
+ def _verify_response_common(
558
+ self, config: EndpointConfiguration, payload_type: type, resp: ResponseCommon, index: Optional[int] = None
559
+ ) -> bool:
560
+ """Verify the common response data in the given response.
561
+
562
+ Arguments:
563
+ config (EndpointConfiguration): The configuration for the endpoint.
564
+ payload_type (type): The type of payload that was sent with the request.
565
+ resp (ResponseCommon): The common response data to verify.
566
+ index (int): The index of the response in the multi-response. This is None for single
567
+ responses.
568
+
569
+ Returns: True to indicate that the response is valid, False otherwise.
570
+ """
571
+ # Log the status of the response validation
572
+ self._logger.info(
573
+ f"{config.name}: {payload_type.__name__}{f'[{index}]' if index is not None else ''} was valid? "
574
+ f"{resp.success} ({resp.validation.value})",
575
+ )
576
+
577
+ # Verify that the response was successful and that the validation status is not a failed status
578
+ return resp.success and (resp.validation not in (ValidationStatus.FAILED, ValidationStatus.PASSED_PARTIAL))
579
+
580
+ def _get_wrapper(self, service: ServiceConfiguration) -> ZWrapper:
581
+ """Get the wrapper for the given service.
582
+
583
+ Arguments:
584
+ service (ServiceConfiguration): The service for which to get the wrapper.
585
+ """
586
+ if service.interface not in self._wrappers:
587
+ self._logger.debug(f"Creating wrapper for {service.interface.name} interface.")
588
+ self._wrappers[service.interface] = ZWrapper(
589
+ self._client_type, service.interface, self._cert.to_adapter(), self._logger, True, self._test
590
+ )
591
+ return self._wrappers[service.interface]