mms-client 1.6.0__py3-none-any.whl → 1.7.0__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.
@@ -1,14 +1,24 @@
1
1
  """Contains functionality associated with certificates."""
2
2
 
3
3
  from pathlib import Path
4
+ from ssl import PROTOCOL_TLSv1_2
4
5
  from typing import Union
5
6
 
7
+ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
8
+ from cryptography.hazmat.primitives.serialization import Encoding
9
+ from cryptography.hazmat.primitives.serialization import NoEncryption
10
+ from cryptography.hazmat.primitives.serialization import PrivateFormat
11
+ from cryptography.hazmat.primitives.serialization import PublicFormat
12
+ from cryptography.hazmat.primitives.serialization.pkcs12 import load_key_and_certificates
6
13
  from requests_pkcs12 import Pkcs12Adapter
7
14
 
8
15
 
9
16
  class Certificate:
10
17
  """Describes a certificate composed of a cert file and a key file."""
11
18
 
19
+ # The encoding to use for MMS certificates
20
+ encoding = Encoding.PEM
21
+
12
22
  def __init__(self, cert: Union[str, Path, bytes], passphrase: str):
13
23
  """Create a new Certificate.
14
24
 
@@ -29,6 +39,13 @@ class Certificate:
29
39
  # Save the passphrase
30
40
  self._passphrase = passphrase
31
41
 
42
+ # Load the private key using the cryptography library
43
+ private_key, _, _ = load_key_and_certificates(self._cert, self._passphrase.encode("UTF-8"))
44
+ if isinstance(private_key, RSAPrivateKey):
45
+ self._private = private_key
46
+ else:
47
+ raise TypeError(f"Private key of type ({type(private_key).__name__}) was not expected.")
48
+
32
49
  @property
33
50
  def certificate(self) -> bytes:
34
51
  """Return the certificate data."""
@@ -39,6 +56,22 @@ class Certificate:
39
56
  """Return the full path to the passphrase."""
40
57
  return self._passphrase
41
58
 
59
+ def public_key(self) -> bytes:
60
+ """Extract the public key from the certificate.
61
+
62
+ Returns: The public key in PEM format.
63
+ """
64
+ return self._private.public_key().public_bytes(Certificate.encoding, PublicFormat.PKCS1)
65
+
66
+ def private_key(self) -> bytes:
67
+ """Extract the private key from the certificate.
68
+
69
+ THIS SHOULD NOT, UNDER ANY CIRCUMSTANCES, BE PRINTED OR LOGGED!!!
70
+
71
+ Returns: The private key in PEM format.
72
+ """
73
+ return self._private.private_bytes(Certificate.encoding, PrivateFormat.PKCS8, NoEncryption())
74
+
42
75
  def to_adapter(self) -> Pkcs12Adapter:
43
76
  """Convert the certificate to a Pkcs12Adapter."""
44
- return Pkcs12Adapter(pkcs12_data=self._cert, pkcs12_password=self._passphrase)
77
+ return Pkcs12Adapter(pkcs12_data=self._cert, pkcs12_password=self._passphrase, ssl_protocol=PROTOCOL_TLSv1_2)
@@ -1,13 +1,11 @@
1
1
  """Contains objects for cryptographic operations."""
2
2
 
3
+ from base64 import b64decode
3
4
  from base64 import b64encode
4
- from hashlib import sha256
5
5
 
6
- from cryptography.hazmat.backends import default_backend
7
- from cryptography.hazmat.primitives import hashes
8
- from cryptography.hazmat.primitives.asymmetric import padding
9
- from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
10
- from cryptography.hazmat.primitives.serialization.pkcs12 import load_key_and_certificates
6
+ from Cryptodome.Hash import SHA256
7
+ from Cryptodome.PublicKey import RSA
8
+ from Cryptodome.Signature import pkcs1_15
11
9
 
12
10
  from mms_client.security.certs import Certificate
13
11
 
@@ -21,23 +19,33 @@ class CryptoWrapper:
21
19
  Arguments:
22
20
  cert (Certificate): The certificate to use for cryptographic operations.
23
21
  """
24
- # First, save our certificate for later use
25
- self._cert = cert
22
+ # Extract the public key and private key data from the certificate
23
+ private_key = RSA.import_key(cert.private_key())
24
+ public_key = RSA.import_key(cert.public_key())
26
25
 
27
- # Next, import the private key from the certificate
28
- private_key, _, _ = load_key_and_certificates(
29
- self._cert.certificate, self._cert.passphrase.encode(), default_backend()
30
- )
26
+ # Create a new signer from the private key and a new verifier from the public key
27
+ self._signer = pkcs1_15.new(private_key)
28
+ self._verifier = pkcs1_15.new(public_key)
31
29
 
32
- # Now, we need to assert typing on this private key to make mypy happy
33
- if isinstance(private_key, RSAPrivateKey):
34
- self._private_key = private_key
35
- else:
36
- raise TypeError(f"Private key of type ({type(private_key).__name__}) was not expected.")
30
+ def verify(self, content: bytes, signature: bytes) -> bool:
31
+ """Verify a signature against the given content using the certificate.
37
32
 
38
- # Finally, save our padding and algorithm for later use
39
- self._padding = padding.PKCS1v15()
40
- self._algorithm = hashes.SHA256()
33
+ Arguments:
34
+ content (bytes): The content to verify.
35
+ signature (bytes): The signature to verify against the content.
36
+
37
+ Returns: True if the signature is valid, False otherwise.
38
+ """
39
+ # Hash the content using SHA256
40
+ hashed = SHA256.new(content)
41
+
42
+ # Verify the signature using the public key. This will raise a ValueError if the signature is invalid.
43
+ # We catch this exception and return False to indicate that the signature is invalid.
44
+ try:
45
+ self._verifier.verify(hashed, b64decode(signature))
46
+ return True
47
+ except ValueError:
48
+ return False
41
49
 
42
50
  def sign(self, data: bytes) -> bytes:
43
51
  """Create a signature from the given data using the certificate.
@@ -48,10 +56,10 @@ class CryptoWrapper:
48
56
  Returns: A base64-encoded string containing the signature.
49
57
  """
50
58
  # First, hash the data using SHA256
51
- hashed = sha256(data)
59
+ hashed = SHA256.new(data)
52
60
 
53
61
  # Next, sign the hash using the private key
54
- signature = self._private_key.sign(hashed.digest(), self._padding, self._algorithm)
62
+ signature = self._signer.sign(hashed)
55
63
 
56
64
  # Finally, return the base64-encoded signature
57
65
  return b64encode(signature)
@@ -1,5 +1,6 @@
1
1
  """Contains the client layer for communicating with the MMS server."""
2
2
 
3
+ from base64 import b64decode
3
4
  from dataclasses import dataclass
4
5
  from logging import getLogger
5
6
  from typing import Dict
@@ -248,6 +249,7 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
248
249
 
249
250
  def __init__(
250
251
  self,
252
+ domain: str,
251
253
  participant: str,
252
254
  user: str,
253
255
  client_type: ClientType,
@@ -259,6 +261,7 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
259
261
  """Create a new MMS client with the given participant, user, client type, and authentication.
260
262
 
261
263
  Arguments:
264
+ domain (str): The domain to use when signing the content ID to MTOM attachments.
262
265
  participant (str): The MMS code of the business entity to which the requesting user belongs.
263
266
  user (str): The user name of the person making the request.
264
267
  client_type (ClientType): The type of client to use for making requests to the MMS server.
@@ -269,6 +272,7 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
269
272
  test (bool): Whether to use the test server.
270
273
  """
271
274
  # First, save the base field associated with the client
275
+ self._domain = domain
272
276
  self._participant = participant
273
277
  self._user = user
274
278
  self._client_type = client_type
@@ -329,18 +333,23 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
329
333
 
330
334
  Returns: The response from the MMS server.
331
335
  """
336
+ # Create a new ZWrapper for the given service
337
+ wrapper = self._get_wrapper(config.service)
338
+
332
339
  # First, create the MMS request from the payload and data.
333
340
  logger.debug(
334
341
  f"{config.name}: Starting request. Envelope: {type(envelope).__name__}, Data: {type(payload).__name__}",
335
342
  )
336
- request = self._to_mms_request(config.request_type, config.service.serializer.serialize(envelope, payload))
343
+ request = self._to_mms_request(
344
+ wrapper, config.request_type, config.service.serializer.serialize(envelope, payload)
345
+ )
337
346
 
338
347
  # Next, submit the request to the MMS server and get and verify the response.
339
- resp = self._get_wrapper(config.service).submit(request)
348
+ resp = wrapper.submit(request)
340
349
  self._verify_mms_response(resp, config)
341
350
 
342
351
  # Now, extract the attachments from the response
343
- attachments = {a.name: a.data for a in resp.attachments}
352
+ attachments = {a.name: b64decode(a.data) for a in resp.attachments}
344
353
 
345
354
  # Finally, deserialize and verify the response
346
355
  envelope_type = config.response_envelope_type or type(envelope)
@@ -369,6 +378,9 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
369
378
 
370
379
  Returns: The multi-response from the MMS server.
371
380
  """
381
+ # Create a new ZWrapper for the given service
382
+ wrapper = self._get_wrapper(config.service)
383
+
372
384
  # First, create the MMS request from the payload and data.
373
385
  is_list = isinstance(payload, list)
374
386
  data_type = type(payload[0]) if is_list else type(payload) # type: ignore[index]
@@ -383,14 +395,14 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
383
395
  if is_list
384
396
  else config.service.serializer.serialize(envelope, payload) # type: ignore[type-var]
385
397
  )
386
- request = self._to_mms_request(config.request_type, serialized)
398
+ request = self._to_mms_request(wrapper, config.request_type, serialized)
387
399
 
388
400
  # Next, submit the request to the MMS server and get and verify the response.
389
- resp = self._get_wrapper(config.service).submit(request)
401
+ resp = wrapper.submit(request)
390
402
  self._verify_mms_response(resp, config)
391
403
 
392
404
  # Now, extract the attachments from the response
393
- attachments = {a.name: a.data for a in resp.attachments}
405
+ attachments = {a.name: b64decode(a.data) for a in resp.attachments}
394
406
 
395
407
  # Finally, deserialize and verify the response
396
408
  envelope_type = config.response_envelope_type or type(envelope)
@@ -410,6 +422,7 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
410
422
 
411
423
  def _to_mms_request(
412
424
  self,
425
+ client: ZWrapper,
413
426
  req_type: RequestType,
414
427
  data: bytes,
415
428
  return_req: bool = False,
@@ -418,6 +431,7 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
418
431
  """Convert the given data to an MMS request.
419
432
 
420
433
  Arguments:
434
+ client (ZWrapper): The Zeep client to use for submitting the request.
421
435
  req_type (RequestType): The type of request to submit to the MMS server.
422
436
  data (bytes): The data to submit to the MMS server.
423
437
  return_req (bool): Whether to return the request data in the response. This is False by default.
@@ -425,16 +439,14 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
425
439
 
426
440
  Arguments: The MMS request to submit to the MMS server.
427
441
  """
428
- # Convert the attachments to the correct the MMS format
442
+ # First, convert the attachments to the correct the MMS format
429
443
  attachment_data = (
430
- [
431
- Attachment(signature=self._signer.sign(data), name=name, binaryData=data)
432
- for name, data in attachments.items()
433
- ]
434
- if attachments
435
- else []
444
+ [self._to_mms_attachment(client, name, data) for name, data in attachments.items()] if attachments else []
436
445
  )
437
446
 
447
+ # Next, convert the payload to a base-64 string
448
+ tag, signature = self._register_and_sign(client, "payload", data)
449
+
438
450
  # Embed the data and the attachments in the MMS request and return it
439
451
  logger.debug(
440
452
  f"Creating MMS request of type {req_type.name} to send {len(data)} bytes of data and "
@@ -445,11 +457,36 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
445
457
  adminRole=self._is_admin,
446
458
  requestDataType=RequestDataType.XML,
447
459
  sendRequestDataOnSuccess=return_req,
448
- requestSignature=self._signer.sign(data),
449
- requestData=data,
460
+ requestSignature=signature,
461
+ requestData=tag,
450
462
  attachmentData=attachment_data,
451
463
  )
452
464
 
465
+ def _to_mms_attachment(self, client: ZWrapper, name: str, data: bytes) -> Attachment: # pragma: no cover
466
+ """Convert the given data to an MMS attachment.
467
+
468
+ Arguments:
469
+ client (ZWrapper): The Zeep client to use for submitting the request.
470
+ name (str): The name of the attachment.
471
+ data (bytes): The data to be attached.
472
+
473
+ Returns: The MMS attachment.
474
+ """
475
+ # Convert the data to a base-64 string
476
+ tag, signature = self._register_and_sign(client, name, data)
477
+
478
+ # Create the MMS attachment and return it
479
+ return Attachment(signature=signature, name=name, binaryData=tag)
480
+
481
+ def _register_and_sign(self, client: ZWrapper, name: str, data: bytes) -> Tuple[str, str]:
482
+ tag = client.register_attachment(name, data)
483
+
484
+ # Next, sign the data
485
+ signature = self._signer.sign(data)
486
+
487
+ # Finally, convert the encoded data to a string and return it and the signature
488
+ return tag, signature.decode("UTF-8")
489
+
453
490
  def _verify_mms_response(self, resp: MmsResponse, config: EndpointConfiguration) -> None:
454
491
  """Verify that the given MMS response is valid.
455
492
 
@@ -597,6 +634,7 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
597
634
  if service.interface not in self._wrappers:
598
635
  logger.debug(f"Creating wrapper for {service.interface.name} interface.")
599
636
  self._wrappers[service.interface] = ZWrapper(
637
+ self._domain,
600
638
  self._client_type,
601
639
  service.interface,
602
640
  self._cert.to_adapter(),
@@ -44,7 +44,7 @@ class MarketClientMixin: # pylint: disable=unused-argument
44
44
  resp_data_type=ReserveRequirement,
45
45
  )
46
46
  def query_reserve_requirements(
47
- self: ClientProto, request: ReserveRequirementQuery, date: Optional[Date] = None
47
+ self: ClientProto, request: ReserveRequirementQuery, days: int, date: Optional[Date] = None
48
48
  ) -> List[ReserveRequirement]:
49
49
  """Query the MMS server for reserve requirements.
50
50
 
@@ -54,6 +54,7 @@ class MarketClientMixin: # pylint: disable=unused-argument
54
54
  request (ReserveRequirementQuery): The query to submit to the MMS server.
55
55
  date (Date): The date of the transaction in the format "YYYY-MM-DD". This value defaults
56
56
  to the current date.
57
+ days (int): The number of days ahead for which the data is being queried.
57
58
 
58
59
  Returns: A list of reserve requirements that match the query.
59
60
  """
@@ -62,7 +63,7 @@ class MarketClientMixin: # pylint: disable=unused-argument
62
63
  date=date or Date.today(),
63
64
  participant=self.participant,
64
65
  user=self.user,
65
- days=1,
66
+ days=days,
66
67
  )
67
68
 
68
69
  @mms_endpoint("MarketSubmit_OfferData", config, RequestType.INFO, [ClientType.BSP])
@@ -167,7 +168,7 @@ class MarketClientMixin: # pylint: disable=unused-argument
167
168
  days=days,
168
169
  )
169
170
 
170
- @mms_endpoint("MarketQuery_AwardResultsQuery", config, RequestType.INFO, resp_data_type=AwardResponse)
171
+ @mms_endpoint("MarketQuery_AwardResultsQuery", config, RequestType.MARKET, resp_data_type=AwardResponse)
171
172
  def query_awards(self: ClientProto, request: AwardQuery, days: int, date: Optional[Date] = None) -> AwardResponse:
172
173
  """Query the MMS server for award results.
173
174
 
mms_client/types/base.py CHANGED
@@ -115,12 +115,9 @@ class SchemaType(Enum):
115
115
  OMI = "omi.xsd"
116
116
 
117
117
 
118
- class PayloadBase(BaseXmlModel, nsmap={"xsi": "http://www.w3.org/2001/XMLSchema-instance"}):
118
+ class PayloadBase(BaseXmlModel):
119
119
  """Represents the base fields for an MMS request payload."""
120
120
 
121
- # The XML schema to use for validation
122
- location: SchemaType = attr(name="noNamespaceSchemaLocation", ns="xsi")
123
-
124
121
 
125
122
  class BaseResponse(BaseXmlModel, Generic[E], tag="BaseResponse"):
126
123
  """Contains the base data extracted from the MMS response in a format we can use."""
@@ -55,7 +55,7 @@ class Attachment(BaseModel):
55
55
  name: str = Field(alias="name")
56
56
 
57
57
  # The attachment file data
58
- data: bytes = Field(alias="binaryData")
58
+ data: str = Field(alias="binaryData")
59
59
 
60
60
 
61
61
  class MmsRequest(BaseModel):
@@ -83,7 +83,7 @@ class MmsRequest(BaseModel):
83
83
  signature: str = Field(alias="requestSignature")
84
84
 
85
85
  # The base-64 encoded payload of the request
86
- payload: bytes = Field(alias="requestData")
86
+ payload: str = Field(alias="requestData")
87
87
 
88
88
  # Any attached files to be sent with the request. Only 20 of these are allowed for OMI requests. For MI requests,
89
89
  # the limit is 40.
@@ -0,0 +1,259 @@
1
+ """Contains a transport override supporting MTOMS attachments."""
2
+
3
+ import string
4
+ from base64 import b64encode
5
+ from email.encoders import encode_7or8bit
6
+ from email.mime.application import MIMEApplication
7
+ from email.mime.base import MIMEBase
8
+ from email.mime.multipart import MIMEMultipart
9
+ from logging import getLogger
10
+ from random import SystemRandom
11
+ from typing import Dict
12
+ from typing import Optional
13
+
14
+ from lxml import etree
15
+ from lxml.etree import _Element as Element
16
+ from pendulum import now
17
+ from requests import Session
18
+ from zeep.cache import VersionedCacheBase
19
+ from zeep.transports import Transport
20
+ from zeep.wsdl.utils import etree_to_string
21
+
22
+ # Set the default logger for the MMS client
23
+ logger = getLogger(__name__)
24
+
25
+
26
+ # Define the namespaces used in the XML
27
+ XOP = "http://www.w3.org/2004/08/xop/include"
28
+ XMIME5 = "http://www.w3.org/2005/05/xmlmime"
29
+ FILETAG = "xop:Include:"
30
+ ID_LEN = 16
31
+
32
+
33
+ # Define a function to generate a randomized ID string
34
+ def get_id(length: int = ID_LEN) -> str:
35
+ """Generate a randomized ID string.
36
+
37
+ Arguments:
38
+ length (int): The length of the ID string to generate.
39
+
40
+ Returns: The randomized ID string.
41
+ """
42
+ return "".join(SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(length))
43
+
44
+
45
+ def now_b64():
46
+ """Return a base64 encoded string of the current timestamp."""
47
+ return b64encode(f"{now().timestamp()}".replace(".", "").encode("UTF-8")).decode("UTF-8")
48
+
49
+
50
+ def get_boundary() -> str:
51
+ """Return a randomized MIME boundary string."""
52
+ return f"MIMEBoundary_{now_b64()}".center(33, "=")
53
+
54
+
55
+ def get_content_id(domain: str) -> str:
56
+ """Return a randomized MIME content ID string.
57
+
58
+ Arguments:
59
+ domain (str): The domain of the content ID.
60
+
61
+ Returns: The randomized MIME content ID string.
62
+ """
63
+ return f"<{now_b64()}@{domain}>"
64
+
65
+
66
+ def overwrite_attachnode(node):
67
+ """Overwrite the attachment node.
68
+
69
+ Arguments:
70
+ node (Element): The XML node to be overwritten.
71
+
72
+ Returns: The attachment node.
73
+ """
74
+ cid = node.text[len(FILETAG) :]
75
+ node.text = None
76
+ etree.SubElement(node, f"{{{XOP}}}Include", nsmap={"xop": XOP}, href=f"cid:{cid}")
77
+ return cid
78
+
79
+
80
+ def get_multipart(content_id: str) -> MIMEMultipart:
81
+ """Create a MIME multipart object.
82
+
83
+ Arguments:
84
+ content_id (str): The content ID to be used for the attachment.
85
+
86
+ Returns: The MIME multipart object.
87
+ """
88
+ part = MIMEMultipart(
89
+ "related", charset="UTF-8", type="application/xop+xml", boundary=get_boundary(), start=content_id
90
+ )
91
+ part.set_param("start-info", "application/soap+xml")
92
+ return part
93
+
94
+
95
+ def get_envelope_part(envelope: Element, content_id: str) -> MIMEApplication:
96
+ """Create the MIME envelope part.
97
+
98
+ Arguments:
99
+ envelope (Element): The XML envelope to be attached.
100
+ content_id (str): The content ID to be used for the attachment.
101
+
102
+ Returns: The MIME envelope part.
103
+ """
104
+ part = MIMEApplication(etree_to_string(envelope), "xop+xml", encode_7or8bit)
105
+ part.set_param("charset", "utf-8")
106
+ part.set_param("type", "text/xml")
107
+ part.add_header("Content-ID", content_id)
108
+ part.add_header("Content-Transfer-Encoding", "binary")
109
+ return part
110
+
111
+
112
+ class Attachment:
113
+ """Represents an attachment to be sent with a multipart request."""
114
+
115
+ def __init__(self, name: str, data: bytes, domain: str):
116
+ """Create a new attachment.
117
+
118
+ Arguments:
119
+ name (str): The name of the attachment.
120
+ data (bytes): The data to be attached.
121
+ domain (str): The domain of the content ID.
122
+ """
123
+ self.name = name
124
+ self.data = data
125
+ self.cid = f"{get_id()}-{name}@{domain}"
126
+ self.tag = FILETAG + self.cid
127
+
128
+
129
+ class MultipartTransport(Transport):
130
+ """A transport that supports MTOMS attachments."""
131
+
132
+ def __init__(
133
+ self,
134
+ domain: str,
135
+ cache: Optional[VersionedCacheBase] = None,
136
+ timeout: int = 300,
137
+ operation_timeout: Optional[int] = None,
138
+ session: Optional[Session] = None,
139
+ ):
140
+ """Create a new MTOMS transport.
141
+
142
+ Arguments:
143
+ domain (str): The domain of the content ID to use.
144
+ cache (VersionedCacheBase, optional): The cache to be used for the transport.
145
+ timeout (int): The timeout for the transport.
146
+ operation_timeout (int, optional): The operation timeout for the transport.
147
+ session (Session, optional): The session to be used for the transport.
148
+ """
149
+ # Save the domain for later use
150
+ self._domain = domain
151
+
152
+ # Setup a dictionary to store the attachments after they're registered
153
+ self._attachments: Dict[str, Attachment] = {}
154
+
155
+ # Call the parent constructor
156
+ super().__init__(
157
+ cache=cache,
158
+ timeout=timeout,
159
+ operation_timeout=operation_timeout,
160
+ session=session,
161
+ )
162
+
163
+ def register_attachment(self, name: str, data: bytes) -> str:
164
+ """Register an attachment.
165
+
166
+ Registered attachments will be sent with the request as MTOMS attachments. The content ID of the attachment
167
+ will be returned so that it can be used in the request.
168
+
169
+ Arguments:
170
+ name (str): The name of the attachment.
171
+ data (bytes): The data to be attached.
172
+
173
+ Returns: The content ID of the attachment, which should be used in place of the attachment data.
174
+ """
175
+ attachment = Attachment(name, data, self._domain)
176
+ self._attachments[attachment.cid] = attachment
177
+ return attachment.tag
178
+
179
+ def post_xml(self, address: str, envelope: Element, headers: dict):
180
+ """Post the XML envelope and attachments.
181
+
182
+ Arguments:
183
+ address (str): The address to post the data to.
184
+ envelope (Element): The XML envelope to be attached.
185
+ headers (dict): The headers to be used for the request.
186
+
187
+ Returns: The response from the server.
188
+ """
189
+ # Search for values that start with our FILETAG
190
+ filetags = envelope.xpath(f"//*[starts-with(text(), '{FILETAG}')]")
191
+
192
+ # if there are any attached files, we will set the attachments. Otherwise, just the envelope
193
+ if filetags:
194
+ message = self.create_mtom_request(filetags, envelope, headers).encode("UTF-8")
195
+ else:
196
+ message = etree_to_string(envelope)
197
+
198
+ # Post the request and return the response
199
+ return self.post(address, message, headers)
200
+
201
+ def create_mtom_request(self, filetags, envelope: Element, headers: dict) -> str:
202
+ """Set MTOM attachments and return the right envelope.
203
+
204
+ Arguments:
205
+ filetags (list): The list of XML paths to the attachments.
206
+ envelope (Element): The XML envelope to be attached.
207
+ headers (dict): The headers to be used for the request.
208
+
209
+ Returns: The XML envelope with the attachments.
210
+ """
211
+ # First, get an identifier for the request and then use it to create a new multipart request
212
+ content_id = get_content_id(self._domain)
213
+ mtom_part = get_multipart(content_id)
214
+
215
+ # Next, let's set the XOP:Include nodes for each attachment
216
+ files = [overwrite_attachnode(f) for f in filetags]
217
+
218
+ # Now, create the request envelope and attach it to the multipart request
219
+ env_part = get_envelope_part(envelope, content_id)
220
+ mtom_part.attach(env_part)
221
+
222
+ # Attach each file to the multipart request
223
+ for cid in files:
224
+ mtom_part.attach(self.create_attachment(cid))
225
+
226
+ # Finally, create the final multipart request string
227
+ bound = f"--{mtom_part.get_boundary()}"
228
+ marray = mtom_part.as_string().split(bound)
229
+ mtombody = bound + bound.join(marray[1:])
230
+
231
+ # Set the content length and add the MTOM headers to the request
232
+ mtom_part.add_header("Content-Length", str(len(mtombody)))
233
+ headers.update(dict(mtom_part.items()))
234
+
235
+ # Decode the XML and return the request
236
+ message = mtom_part.as_string().split("\n\n", 1)[1]
237
+ message = message.replace("\n", "\r\n", 5)
238
+ return message
239
+
240
+ def create_attachment(self, cid):
241
+ """Create an attachment for the multipart request.
242
+
243
+ Arguments:
244
+ cid (str): The content ID of the attachment.
245
+
246
+ Returns: The attachment.
247
+ """
248
+ # First, get the attachment from the cache
249
+ attach = self._attachments[cid]
250
+
251
+ # Next, create the attachment
252
+ part = MIMEBase("application", "octet-stream")
253
+ part["Content-Transfer-Encoding"] = "binary"
254
+ part["Content-ID"] = f"<{attach.cid}>"
255
+ part.set_payload(attach.data, charset="utf-8")
256
+ del part["mime-version"]
257
+
258
+ # Finally, return the attachment
259
+ return part
@@ -80,7 +80,7 @@ class Serializer:
80
80
 
81
81
  # Finally, convert the payload to XML and return it
82
82
  # NOTE: we provided the encoding here so this will return bytes, not a string
83
- return payload.to_xml(skip_empty=True, encoding="utf-8", xml_declaration=True) # type: ignore[return-value]
83
+ return self._to_canoncialized_xml(payload)
84
84
 
85
85
  def serialize_multi(self, request_envelope: E, request_data: List[P], request_type: Type[P]) -> bytes:
86
86
  """Serialize the envelope and data to a byte string for sending to the MMS server.
@@ -104,7 +104,7 @@ class Serializer:
104
104
 
105
105
  # Finally, convert the payload to XML and return it
106
106
  # NOTE: we provided the encoding here so this will return bytes, not a string
107
- return payload.to_xml(skip_empty=True, encoding="utf-8", xml_declaration=True) # type: ignore[return-value]
107
+ return self._to_canoncialized_xml(payload)
108
108
 
109
109
  def deserialize(self, data: bytes, envelope_type: Type[E], data_type: Type[P]) -> Response[E, P]:
110
110
  """Deserialize the data to a response object.
@@ -132,6 +132,30 @@ class Serializer:
132
132
  tree = self._from_xml(data)
133
133
  return self._from_tree_multi(tree, envelope_type, data_type)
134
134
 
135
+ def _to_canoncialized_xml(self, payload: PayloadBase) -> bytes:
136
+ """Convert the payload to a canonicalized XML string.
137
+
138
+ Arguments:
139
+ payload (PayloadBase): The payload to be converted.
140
+
141
+ Returns: The canonicalized XML string.
142
+ """
143
+ # First, convert the payload to a raw XML string
144
+ raw: bytes = payload.to_xml(
145
+ skip_empty=True,
146
+ encoding="utf-8",
147
+ xml_declaration=False,
148
+ ) # type: ignore[assignment]
149
+
150
+ # Next, parse it back into an XML tree
151
+ unparsed = parse(BytesIO(raw))
152
+
153
+ # Finally, convert the XML tree to a canonicalized XML string and return it
154
+ buffer = BytesIO()
155
+ unparsed.write_c14n(buffer)
156
+ buffer.seek(0)
157
+ return buffer.read()
158
+
135
159
  def _from_tree(self, raw: Element, envelope_type: Type[E], data_type: Type[P]) -> Response[E, P]:
136
160
  """Convert the raw data to a response object.
137
161
 
mms_client/utils/web.py CHANGED
@@ -12,13 +12,13 @@ from requests import Session
12
12
  from requests_pkcs12 import Pkcs12Adapter
13
13
  from zeep import Client
14
14
  from zeep import Plugin
15
- from zeep import Transport
16
15
  from zeep.cache import SqliteCache
17
16
  from zeep.exceptions import TransportError
18
17
  from zeep.xsd.valueobjects import CompoundValue
19
18
 
20
19
  from mms_client.types.transport import MmsRequest
21
20
  from mms_client.types.transport import MmsResponse
21
+ from mms_client.utils.multipart_transport import MultipartTransport
22
22
 
23
23
  # Set the default logger for the MMS client
24
24
  logger = getLogger(__name__)
@@ -134,6 +134,7 @@ class ZWrapper:
134
134
 
135
135
  def __init__(
136
136
  self,
137
+ domain: str,
137
138
  client: ClientType,
138
139
  interface: Interface,
139
140
  adapter: Pkcs12Adapter,
@@ -144,6 +145,7 @@ class ZWrapper:
144
145
  """Create a new Zeep wrapper object for a specific MMS service endpoint.
145
146
 
146
147
  Arguments:
148
+ domain (str): The domain to use when signing the content ID to MTOM attachments.
147
149
  client (ClientType): The type of client to use. This can be either "bsp" (Balancing Service Provider) or
148
150
  "tso" (Transmission System Operator). This will determine which service endpoint to
149
151
  use.
@@ -191,13 +193,25 @@ class ZWrapper:
191
193
 
192
194
  # Finally, we create the Zeep client with the given WSDL file location, session, and cache settings and then,
193
195
  # from that client, we create the SOAP service with the given service binding and selected endpoint.
196
+ self._transport = MultipartTransport(domain, cache=SqliteCache() if cache else None, session=sess)
194
197
  self._client = Client(
195
198
  wsdl=str(location.resolve()),
196
- transport=Transport(cache=SqliteCache() if cache else None, session=sess),
199
+ transport=self._transport,
197
200
  plugins=plugins,
198
201
  )
199
202
  self._create_service()
200
203
 
204
+ def register_attachment(self, name: str, attachment: bytes) -> str:
205
+ """Register a multipart attachment.
206
+
207
+ Arguments:
208
+ name (str): The name of the attachment.
209
+ attachment (bytes): The data to be attached.
210
+
211
+ Returns: The content ID of the attachment, which should be used in place of the attachment data.
212
+ """
213
+ return self._transport.register_attachment(name, attachment)
214
+
201
215
  @on_exception(expo, TransportError, max_tries=3, giveup=fatal_code, logger=logger) # type: ignore[arg-type]
202
216
  def submit(self, req: MmsRequest) -> MmsResponse:
203
217
  """Submit the given request to the MMS server and return the response.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: mms-client
3
- Version: 1.6.0
3
+ Version: 1.7.0
4
4
  Summary: API client for accessing the MMS
5
5
  Home-page: https://github.com/ElectroRoute-Japan/mms-client
6
6
  Author: Ryan Wood
@@ -22,6 +22,7 @@ Requires-Dist: backoff (>=2.2.1,<3.0.0)
22
22
  Requires-Dist: cryptography (>=42.0.5,<43.0.0)
23
23
  Requires-Dist: lxml (>=5.1.0,<6.0.0)
24
24
  Requires-Dist: pendulum (>=3.0.0,<4.0.0)
25
+ Requires-Dist: pycryptodomex (>=3.20.0,<4.0.0)
25
26
  Requires-Dist: pydantic (>=2.6.3,<3.0.0)
26
27
  Requires-Dist: pydantic-xml (>=2.9.0,<3.0.0)
27
28
  Requires-Dist: requests (>=2.31.0,<3.0.0)
@@ -43,6 +44,9 @@ The underlying API sends and receives XML documents. Each of these request or re
43
44
 
44
45
  After the data has been converted and added to the outer request object, it is sent to the appropriate server endpoint via a Zeep client. The client certificate is also injected into the request using a PCKS12 adaptor.
45
46
 
47
+ ## Domain
48
+ The domain to use when signing the content ID to MTOM attachments is specified when creating the client. This is not verified by the server, but it is used to generate the content ID for the MTOM attachments. As such, it is important to ensure that the domain is correct.
49
+
46
50
  # Serialization
47
51
  This library relies on Pydantic 2 and the pydantic-xml library for serialization/deserialization. As such, any type in this library can be converted to not only XML, but to JSON as well. This is extremely useful if you're trying to build a pass-through API service or something similar.
48
52
 
@@ -142,7 +146,7 @@ from mms_client.utils.web import ClientType
142
146
  cert = Certificate("/path/to/my/cert.p12", "fake_passphrase")
143
147
 
144
148
  # Create a new MMS client
145
- client = MmsClient(participant="F100", user="FAKEUSER", client_type=ClientType.BSP, cert)
149
+ client = MmsClient(domain="mydomain.com", participant="F100", user="FAKEUSER", client_type=ClientType.BSP, cert)
146
150
 
147
151
  # Create our request offer
148
152
  request_offer = OfferData(
@@ -176,14 +180,14 @@ There's a lot of code here but it's not terribly difficult to understand. All th
176
180
  If you want to test your MMS connection, you can try using the test server:
177
181
 
178
182
  ```python
179
- client = MmsClient(participant="F100", user="FAKEUSER", client_type=ClientType.BSP, cert, test=True)
183
+ client = MmsClient(domain="mydomain.com", participant="F100", user="FAKEUSER", client_type=ClientType.BSP, cert, test=True)
180
184
  ```
181
185
 
182
186
  ## Connecting as a Market Admin
183
187
  If you're connecting as a market operator (MO), you can connect in admin mode:
184
188
 
185
189
  ```python
186
- client = MmsClient(participant="F100", user="FAKEUSER", client_type=ClientType.BSP, cert, is_admin=True)
190
+ client = MmsClient(domain="mydomain.com", participant="F100", user="FAKEUSER", client_type=ClientType.BSP, cert, is_admin=True)
187
191
  ```
188
192
 
189
193
  ## Auditing XML Requests & Responses
@@ -202,7 +206,7 @@ class TestAuditPlugin(AuditPlugin):
202
206
  def audit_response(self, mms_response: bytes) -> None:
203
207
  self.response = mms_response
204
208
 
205
- client = MmsClient(participant="F100", user="FAKEUSER", client_type=ClientType.BSP, cert, plugins=[TestAuditPlugin()])
209
+ client = MmsClient(domain="mydomain.com", participant="F100", user="FAKEUSER", client_type=ClientType.BSP, cert, plugins=[TestAuditPlugin()])
206
210
  ```
207
211
 
208
212
  This same input allows for the user to create their own plugins and add them to the Zeep client, allowing for a certain amount of extensibility.
@@ -9,17 +9,17 @@ mms_client/schemas/xsd/mi-report.xsd,sha256=XEHhHCGgK4aeYsmObIrlzkvV8UhbirgoysAE
9
9
  mms_client/schemas/xsd/mpr.xsd,sha256=QcnuKFm1WkyZfT_56cINHldZkR-3pU4nHSbFKHkIQS0,69149
10
10
  mms_client/schemas/xsd/omi.xsd,sha256=benkYeno_HF6BOIv7gAT79dWz00JccS3XilPLBCKVIU,31524
11
11
  mms_client/security/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- mms_client/security/certs.py,sha256=kNCUFmy18YIxkWKu3mdMmlxmHdft4a6BvtyJ46rA9I4,1489
13
- mms_client/security/crypto.py,sha256=M7aIllM3_ZwZgm9nH6QQ6Ig14XCAd6e6WGwqqUbbI1Q,2149
12
+ mms_client/security/certs.py,sha256=Gy-CuSsdLPFeoPH_sEYhY67dI5sy6yJ8iTwlysRKT1s,3018
13
+ mms_client/security/crypto.py,sha256=u9Z6nkAW6LbBqUzjIEbZ-CcqdkMJ9fqvdX7IXTTh1EI,2345
14
14
  mms_client/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- mms_client/services/base.py,sha256=DavXAfWqSqcxfgbAFDj-9nj2DYL5YTMTPWHUM8aaL9s,26309
16
- mms_client/services/market.py,sha256=7eVqbgkfSip-GAAFnjetFbtmFILOGI6Gb8YISTiMh6w,9031
15
+ mms_client/services/base.py,sha256=T6D9vDsNmz68rLAVbzCl9WQAHqYQ7D18JVFi-L_LkSs,27914
16
+ mms_client/services/market.py,sha256=hQnMZ52abjcQO3L0d5bPuJz7hmBEAbDq2l12I9YUG7k,9153
17
17
  mms_client/services/omi.py,sha256=UG1zYkFz0sFsEbhE6P0CLoAOZZOyEshkZ_b7D_e3CjQ,626
18
18
  mms_client/services/registration.py,sha256=ryj2WKJoBzRU1r6Svbl2MyGOG0H5aEYAK6HQWL9kYaA,3815
19
19
  mms_client/services/report.py,sha256=ZXYDaknPCnSYZI857QHbkzst1Pez_PrKFudVF9bQB7Q,642
20
20
  mms_client/types/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
21
  mms_client/types/award.py,sha256=BWE9V_KHXpg_cW1LZsetVrPs2hZDOklvRpNnoZtmR3k,14139
22
- mms_client/types/base.py,sha256=VQCr50CL1SnnPcO1EYGHa4rrkHBtXs-J8psWLJWoHJY,9710
22
+ mms_client/types/base.py,sha256=YMRGCBBDMGRlR7YpDBSDVDvhjEJrxA32YhmiHoXXz-Q,9530
23
23
  mms_client/types/enums.py,sha256=YJ58FbhyQ0TVlf8Z-Dg1UfVu8CurY5b21Cy5-WYkJ0I,1629
24
24
  mms_client/types/fields.py,sha256=pa5qvQVwEr8dh44IGHyYqgJYTYyTIeAjBW6CylXrkP0,14785
25
25
  mms_client/types/market.py,sha256=IbXsH4Q5MJI-CEvGvZlzv2S36mX_Ea02U11Ik-NwSxQ,2706
@@ -27,13 +27,14 @@ mms_client/types/offer.py,sha256=KosFiKRMnt7XwlLBUfjHUGHiWzrMJUPPhGQMxgdeepM,679
27
27
  mms_client/types/registration.py,sha256=Nir73S3ffpk0O_fnTD2alFaqV1k67_8dcyyduXvPBI4,1381
28
28
  mms_client/types/reserve.py,sha256=pLV47w_749EIVhj0tUuJdWdHBBEl0-v10oVioccgxnU,2667
29
29
  mms_client/types/resource.py,sha256=TQnY3JLHRgQhQrG6ISquw-BQgKSr8TGuqn9ItWxWz_w,65974
30
- mms_client/types/transport.py,sha256=DPjWs34UW915GkUCJWKuDZmsjS6mRdRXgcGISduN_Bc,4399
30
+ mms_client/types/transport.py,sha256=PZ7mDKeH8rGOVONk0ZH5herft71PFF-MUpp3uB57WXo,4395
31
31
  mms_client/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
32
32
  mms_client/utils/auditing.py,sha256=JDcvNo4ul66xPtDeeocn568yIe8fhh-jM11MWP-Kfes,2057
33
33
  mms_client/utils/errors.py,sha256=6k-NOjGZyTbTUISzN7B4JrmU2P8cwjpFFmFC7kJOQFQ,3005
34
- mms_client/utils/serialization.py,sha256=k0_fBm-yoRZV2AMiickSyauoDyA8i7uIPU6JjfQWx4Q,29638
35
- mms_client/utils/web.py,sha256=-abdVxSi7c6xQYsZObbj0yXwGI5VWthr5KtWDyLBCM4,10156
36
- mms_client-1.6.0.dist-info/LICENSE,sha256=awOCsWJ58m_2kBQwBUGWejVqZm6wuRtCL2hi9rfa0X4,1211
37
- mms_client-1.6.0.dist-info/METADATA,sha256=FSz-AvooX7RCU5vZ8BC1nhXINBlF3EFhitTc3ZngIh8,16025
38
- mms_client-1.6.0.dist-info/WHEEL,sha256=Zb28QaM1gQi8f4VCBhsUklF61CTlNYfs9YAZn-TOGFk,88
39
- mms_client-1.6.0.dist-info/RECORD,,
34
+ mms_client/utils/multipart_transport.py,sha256=O374vPh2j29_CSjkWnIaPJfiabRSGNpQvNQcUODdkDM,8871
35
+ mms_client/utils/serialization.py,sha256=kGPOWqOxHJ4IPyVB2xRiG6cKMqhUBSNBWJ4bF6xbxH8,30293
36
+ mms_client/utils/web.py,sha256=7c6Ghs3Y52cm2ge-9svR39uQjr2Pm2LhX9Wz-S1APa4,10816
37
+ mms_client-1.7.0.dist-info/LICENSE,sha256=awOCsWJ58m_2kBQwBUGWejVqZm6wuRtCL2hi9rfa0X4,1211
38
+ mms_client-1.7.0.dist-info/METADATA,sha256=HWPxyyd84Fr9HnM7058QKp_yQRsnw0l5sefy8gb2yto,16447
39
+ mms_client-1.7.0.dist-info/WHEEL,sha256=Zb28QaM1gQi8f4VCBhsUklF61CTlNYfs9YAZn-TOGFk,88
40
+ mms_client-1.7.0.dist-info/RECORD,,