mms-client 1.5.1__tar.gz → 1.7.0__tar.gz
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.
- {mms_client-1.5.1 → mms_client-1.7.0}/PKG-INFO +10 -5
- {mms_client-1.5.1 → mms_client-1.7.0}/README.md +8 -4
- {mms_client-1.5.1 → mms_client-1.7.0}/pyproject.toml +2 -1
- mms_client-1.7.0/src/mms_client/security/certs.py +77 -0
- mms_client-1.7.0/src/mms_client/security/crypto.py +65 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/services/base.py +59 -16
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/services/market.py +35 -1
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/types/base.py +1 -4
- mms_client-1.7.0/src/mms_client/types/reserve.py +71 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/types/transport.py +2 -2
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/utils/errors.py +15 -0
- mms_client-1.7.0/src/mms_client/utils/multipart_transport.py +259 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/utils/serialization.py +26 -2
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/utils/web.py +16 -2
- mms_client-1.5.1/src/mms_client/security/certs.py +0 -44
- mms_client-1.5.1/src/mms_client/security/crypto.py +0 -57
- {mms_client-1.5.1 → mms_client-1.7.0}/LICENSE +0 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/__init__.py +0 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/client.py +0 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/py.typed +0 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/schemas/wsdl/mi-web-service-jbms.wsdl +0 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/schemas/wsdl/omi-web-service.wsdl +0 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/schemas/xsd/mi-market.xsd +0 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/schemas/xsd/mi-outbnd-reports.xsd +0 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/schemas/xsd/mi-report.xsd +0 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/schemas/xsd/mpr.xsd +0 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/schemas/xsd/omi.xsd +0 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/security/__init__.py +0 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/services/__init__.py +0 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/services/omi.py +0 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/services/registration.py +0 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/services/report.py +0 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/types/__init__.py +0 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/types/award.py +0 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/types/enums.py +0 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/types/fields.py +0 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/types/market.py +0 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/types/offer.py +0 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/types/registration.py +0 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/types/resource.py +0 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/utils/__init__.py +0 -0
- {mms_client-1.5.1 → mms_client-1.7.0}/src/mms_client/utils/auditing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: mms-client
|
|
3
|
-
Version: 1.
|
|
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,13 +206,14 @@ 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.
|
|
209
213
|
|
|
210
214
|
# Completeness
|
|
211
215
|
This client is not complete. Currently, it supports the following endpoints:
|
|
216
|
+
- MarketQuery_ReserveRequirementQuery
|
|
212
217
|
- MarketSubmit_OfferData
|
|
213
218
|
- MarketQuery_OfferQuery
|
|
214
219
|
- MarketCancel_OfferCancel
|
|
@@ -11,6 +11,9 @@ The underlying API sends and receives XML documents. Each of these request or re
|
|
|
11
11
|
|
|
12
12
|
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.
|
|
13
13
|
|
|
14
|
+
## Domain
|
|
15
|
+
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.
|
|
16
|
+
|
|
14
17
|
# Serialization
|
|
15
18
|
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.
|
|
16
19
|
|
|
@@ -110,7 +113,7 @@ from mms_client.utils.web import ClientType
|
|
|
110
113
|
cert = Certificate("/path/to/my/cert.p12", "fake_passphrase")
|
|
111
114
|
|
|
112
115
|
# Create a new MMS client
|
|
113
|
-
client = MmsClient(participant="F100", user="FAKEUSER", client_type=ClientType.BSP, cert)
|
|
116
|
+
client = MmsClient(domain="mydomain.com", participant="F100", user="FAKEUSER", client_type=ClientType.BSP, cert)
|
|
114
117
|
|
|
115
118
|
# Create our request offer
|
|
116
119
|
request_offer = OfferData(
|
|
@@ -144,14 +147,14 @@ There's a lot of code here but it's not terribly difficult to understand. All th
|
|
|
144
147
|
If you want to test your MMS connection, you can try using the test server:
|
|
145
148
|
|
|
146
149
|
```python
|
|
147
|
-
client = MmsClient(participant="F100", user="FAKEUSER", client_type=ClientType.BSP, cert, test=True)
|
|
150
|
+
client = MmsClient(domain="mydomain.com", participant="F100", user="FAKEUSER", client_type=ClientType.BSP, cert, test=True)
|
|
148
151
|
```
|
|
149
152
|
|
|
150
153
|
## Connecting as a Market Admin
|
|
151
154
|
If you're connecting as a market operator (MO), you can connect in admin mode:
|
|
152
155
|
|
|
153
156
|
```python
|
|
154
|
-
client = MmsClient(participant="F100", user="FAKEUSER", client_type=ClientType.BSP, cert, is_admin=True)
|
|
157
|
+
client = MmsClient(domain="mydomain.com", participant="F100", user="FAKEUSER", client_type=ClientType.BSP, cert, is_admin=True)
|
|
155
158
|
```
|
|
156
159
|
|
|
157
160
|
## Auditing XML Requests & Responses
|
|
@@ -170,13 +173,14 @@ class TestAuditPlugin(AuditPlugin):
|
|
|
170
173
|
def audit_response(self, mms_response: bytes) -> None:
|
|
171
174
|
self.response = mms_response
|
|
172
175
|
|
|
173
|
-
client = MmsClient(participant="F100", user="FAKEUSER", client_type=ClientType.BSP, cert, plugins=[TestAuditPlugin()])
|
|
176
|
+
client = MmsClient(domain="mydomain.com", participant="F100", user="FAKEUSER", client_type=ClientType.BSP, cert, plugins=[TestAuditPlugin()])
|
|
174
177
|
```
|
|
175
178
|
|
|
176
179
|
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.
|
|
177
180
|
|
|
178
181
|
# Completeness
|
|
179
182
|
This client is not complete. Currently, it supports the following endpoints:
|
|
183
|
+
- MarketQuery_ReserveRequirementQuery
|
|
180
184
|
- MarketSubmit_OfferData
|
|
181
185
|
- MarketQuery_OfferQuery
|
|
182
186
|
- MarketCancel_OfferCancel
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "mms_client"
|
|
3
|
-
version = "v1.
|
|
3
|
+
version = "v1.7.0"
|
|
4
4
|
description = "API client for accessing the MMS"
|
|
5
5
|
authors = ["Ryan Wood <ryan.wood@electroroute.co.jp>"]
|
|
6
6
|
readme = "README.md"
|
|
@@ -32,6 +32,7 @@ cryptography = "^42.0.5"
|
|
|
32
32
|
pydantic-xml = "^2.9.0"
|
|
33
33
|
lxml = "^5.1.0"
|
|
34
34
|
backoff = "^2.2.1"
|
|
35
|
+
pycryptodomex = "^3.20.0"
|
|
35
36
|
|
|
36
37
|
[tool.poetry.group.dev.dependencies]
|
|
37
38
|
black = "^24.2.0"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Contains functionality associated with certificates."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from ssl import PROTOCOL_TLSv1_2
|
|
5
|
+
from typing import Union
|
|
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
|
|
13
|
+
from requests_pkcs12 import Pkcs12Adapter
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Certificate:
|
|
17
|
+
"""Describes a certificate composed of a cert file and a key file."""
|
|
18
|
+
|
|
19
|
+
# The encoding to use for MMS certificates
|
|
20
|
+
encoding = Encoding.PEM
|
|
21
|
+
|
|
22
|
+
def __init__(self, cert: Union[str, Path, bytes], passphrase: str):
|
|
23
|
+
"""Create a new Certificate.
|
|
24
|
+
|
|
25
|
+
Creates a new certificate from the absolute path to the certificate file and a passpharse.
|
|
26
|
+
|
|
27
|
+
Arguments:
|
|
28
|
+
cert: The certificate file. If a string, it should be the absolute path to the certificate file. If
|
|
29
|
+
bytes, it should be the contents of the certificate file.
|
|
30
|
+
passphrase: The passphrase the certificate is encrypted with
|
|
31
|
+
"""
|
|
32
|
+
# Get the certificate file contents
|
|
33
|
+
if isinstance(cert, (str, Path)):
|
|
34
|
+
with open(cert, "rb") as file:
|
|
35
|
+
self._cert = file.read()
|
|
36
|
+
else:
|
|
37
|
+
self._cert = cert
|
|
38
|
+
|
|
39
|
+
# Save the passphrase
|
|
40
|
+
self._passphrase = passphrase
|
|
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
|
+
|
|
49
|
+
@property
|
|
50
|
+
def certificate(self) -> bytes:
|
|
51
|
+
"""Return the certificate data."""
|
|
52
|
+
return self._cert
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def passphrase(self) -> str:
|
|
56
|
+
"""Return the full path to the passphrase."""
|
|
57
|
+
return self._passphrase
|
|
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
|
+
|
|
75
|
+
def to_adapter(self) -> Pkcs12Adapter:
|
|
76
|
+
"""Convert the certificate to a Pkcs12Adapter."""
|
|
77
|
+
return Pkcs12Adapter(pkcs12_data=self._cert, pkcs12_password=self._passphrase, ssl_protocol=PROTOCOL_TLSv1_2)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Contains objects for cryptographic operations."""
|
|
2
|
+
|
|
3
|
+
from base64 import b64decode
|
|
4
|
+
from base64 import b64encode
|
|
5
|
+
|
|
6
|
+
from Cryptodome.Hash import SHA256
|
|
7
|
+
from Cryptodome.PublicKey import RSA
|
|
8
|
+
from Cryptodome.Signature import pkcs1_15
|
|
9
|
+
|
|
10
|
+
from mms_client.security.certs import Certificate
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CryptoWrapper:
|
|
14
|
+
"""Wraps the cryptographic operations necessary for signing and encrypting MMS payload data."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, cert: Certificate):
|
|
17
|
+
"""Create a new CryptoWrapper with the given certificate.
|
|
18
|
+
|
|
19
|
+
Arguments:
|
|
20
|
+
cert (Certificate): The certificate to use for cryptographic operations.
|
|
21
|
+
"""
|
|
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())
|
|
25
|
+
|
|
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)
|
|
29
|
+
|
|
30
|
+
def verify(self, content: bytes, signature: bytes) -> bool:
|
|
31
|
+
"""Verify a signature against the given content using the certificate.
|
|
32
|
+
|
|
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
|
|
49
|
+
|
|
50
|
+
def sign(self, data: bytes) -> bytes:
|
|
51
|
+
"""Create a signature from the given data using the certificate.
|
|
52
|
+
|
|
53
|
+
Arguments:
|
|
54
|
+
data (bytes): The data to be encrypted.
|
|
55
|
+
|
|
56
|
+
Returns: A base64-encoded string containing the signature.
|
|
57
|
+
"""
|
|
58
|
+
# First, hash the data using SHA256
|
|
59
|
+
hashed = SHA256.new(data)
|
|
60
|
+
|
|
61
|
+
# Next, sign the hash using the private key
|
|
62
|
+
signature = self._signer.sign(hashed)
|
|
63
|
+
|
|
64
|
+
# Finally, return the base64-encoded signature
|
|
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
|
|
@@ -28,6 +29,7 @@ from mms_client.types.transport import RequestType
|
|
|
28
29
|
from mms_client.types.transport import ResponseDataType
|
|
29
30
|
from mms_client.utils.errors import AudienceError
|
|
30
31
|
from mms_client.utils.errors import MMSClientError
|
|
32
|
+
from mms_client.utils.errors import MMSServerError
|
|
31
33
|
from mms_client.utils.errors import MMSValidationError
|
|
32
34
|
from mms_client.utils.serialization import Serializer
|
|
33
35
|
from mms_client.utils.web import ClientType
|
|
@@ -247,6 +249,7 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
|
|
|
247
249
|
|
|
248
250
|
def __init__(
|
|
249
251
|
self,
|
|
252
|
+
domain: str,
|
|
250
253
|
participant: str,
|
|
251
254
|
user: str,
|
|
252
255
|
client_type: ClientType,
|
|
@@ -258,6 +261,7 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
|
|
|
258
261
|
"""Create a new MMS client with the given participant, user, client type, and authentication.
|
|
259
262
|
|
|
260
263
|
Arguments:
|
|
264
|
+
domain (str): The domain to use when signing the content ID to MTOM attachments.
|
|
261
265
|
participant (str): The MMS code of the business entity to which the requesting user belongs.
|
|
262
266
|
user (str): The user name of the person making the request.
|
|
263
267
|
client_type (ClientType): The type of client to use for making requests to the MMS server.
|
|
@@ -268,6 +272,7 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
|
|
|
268
272
|
test (bool): Whether to use the test server.
|
|
269
273
|
"""
|
|
270
274
|
# First, save the base field associated with the client
|
|
275
|
+
self._domain = domain
|
|
271
276
|
self._participant = participant
|
|
272
277
|
self._user = user
|
|
273
278
|
self._client_type = client_type
|
|
@@ -328,18 +333,23 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
|
|
|
328
333
|
|
|
329
334
|
Returns: The response from the MMS server.
|
|
330
335
|
"""
|
|
336
|
+
# Create a new ZWrapper for the given service
|
|
337
|
+
wrapper = self._get_wrapper(config.service)
|
|
338
|
+
|
|
331
339
|
# First, create the MMS request from the payload and data.
|
|
332
340
|
logger.debug(
|
|
333
341
|
f"{config.name}: Starting request. Envelope: {type(envelope).__name__}, Data: {type(payload).__name__}",
|
|
334
342
|
)
|
|
335
|
-
request = self._to_mms_request(
|
|
343
|
+
request = self._to_mms_request(
|
|
344
|
+
wrapper, config.request_type, config.service.serializer.serialize(envelope, payload)
|
|
345
|
+
)
|
|
336
346
|
|
|
337
347
|
# Next, submit the request to the MMS server and get and verify the response.
|
|
338
|
-
resp =
|
|
348
|
+
resp = wrapper.submit(request)
|
|
339
349
|
self._verify_mms_response(resp, config)
|
|
340
350
|
|
|
341
351
|
# Now, extract the attachments from the response
|
|
342
|
-
attachments = {a.name: a.data for a in resp.attachments}
|
|
352
|
+
attachments = {a.name: b64decode(a.data) for a in resp.attachments}
|
|
343
353
|
|
|
344
354
|
# Finally, deserialize and verify the response
|
|
345
355
|
envelope_type = config.response_envelope_type or type(envelope)
|
|
@@ -368,6 +378,9 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
|
|
|
368
378
|
|
|
369
379
|
Returns: The multi-response from the MMS server.
|
|
370
380
|
"""
|
|
381
|
+
# Create a new ZWrapper for the given service
|
|
382
|
+
wrapper = self._get_wrapper(config.service)
|
|
383
|
+
|
|
371
384
|
# First, create the MMS request from the payload and data.
|
|
372
385
|
is_list = isinstance(payload, list)
|
|
373
386
|
data_type = type(payload[0]) if is_list else type(payload) # type: ignore[index]
|
|
@@ -382,14 +395,14 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
|
|
|
382
395
|
if is_list
|
|
383
396
|
else config.service.serializer.serialize(envelope, payload) # type: ignore[type-var]
|
|
384
397
|
)
|
|
385
|
-
request = self._to_mms_request(config.request_type, serialized)
|
|
398
|
+
request = self._to_mms_request(wrapper, config.request_type, serialized)
|
|
386
399
|
|
|
387
400
|
# Next, submit the request to the MMS server and get and verify the response.
|
|
388
|
-
resp =
|
|
401
|
+
resp = wrapper.submit(request)
|
|
389
402
|
self._verify_mms_response(resp, config)
|
|
390
403
|
|
|
391
404
|
# Now, extract the attachments from the response
|
|
392
|
-
attachments = {a.name: a.data for a in resp.attachments}
|
|
405
|
+
attachments = {a.name: b64decode(a.data) for a in resp.attachments}
|
|
393
406
|
|
|
394
407
|
# Finally, deserialize and verify the response
|
|
395
408
|
envelope_type = config.response_envelope_type or type(envelope)
|
|
@@ -409,6 +422,7 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
|
|
|
409
422
|
|
|
410
423
|
def _to_mms_request(
|
|
411
424
|
self,
|
|
425
|
+
client: ZWrapper,
|
|
412
426
|
req_type: RequestType,
|
|
413
427
|
data: bytes,
|
|
414
428
|
return_req: bool = False,
|
|
@@ -417,6 +431,7 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
|
|
|
417
431
|
"""Convert the given data to an MMS request.
|
|
418
432
|
|
|
419
433
|
Arguments:
|
|
434
|
+
client (ZWrapper): The Zeep client to use for submitting the request.
|
|
420
435
|
req_type (RequestType): The type of request to submit to the MMS server.
|
|
421
436
|
data (bytes): The data to submit to the MMS server.
|
|
422
437
|
return_req (bool): Whether to return the request data in the response. This is False by default.
|
|
@@ -424,16 +439,14 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
|
|
|
424
439
|
|
|
425
440
|
Arguments: The MMS request to submit to the MMS server.
|
|
426
441
|
"""
|
|
427
|
-
#
|
|
442
|
+
# First, convert the attachments to the correct the MMS format
|
|
428
443
|
attachment_data = (
|
|
429
|
-
[
|
|
430
|
-
Attachment(signature=self._signer.sign(data), name=name, binaryData=data)
|
|
431
|
-
for name, data in attachments.items()
|
|
432
|
-
]
|
|
433
|
-
if attachments
|
|
434
|
-
else []
|
|
444
|
+
[self._to_mms_attachment(client, name, data) for name, data in attachments.items()] if attachments else []
|
|
435
445
|
)
|
|
436
446
|
|
|
447
|
+
# Next, convert the payload to a base-64 string
|
|
448
|
+
tag, signature = self._register_and_sign(client, "payload", data)
|
|
449
|
+
|
|
437
450
|
# Embed the data and the attachments in the MMS request and return it
|
|
438
451
|
logger.debug(
|
|
439
452
|
f"Creating MMS request of type {req_type.name} to send {len(data)} bytes of data and "
|
|
@@ -444,11 +457,36 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
|
|
|
444
457
|
adminRole=self._is_admin,
|
|
445
458
|
requestDataType=RequestDataType.XML,
|
|
446
459
|
sendRequestDataOnSuccess=return_req,
|
|
447
|
-
requestSignature=
|
|
448
|
-
requestData=
|
|
460
|
+
requestSignature=signature,
|
|
461
|
+
requestData=tag,
|
|
449
462
|
attachmentData=attachment_data,
|
|
450
463
|
)
|
|
451
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
|
+
|
|
452
490
|
def _verify_mms_response(self, resp: MmsResponse, config: EndpointConfiguration) -> None:
|
|
453
491
|
"""Verify that the given MMS response is valid.
|
|
454
492
|
|
|
@@ -459,7 +497,11 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
|
|
|
459
497
|
MMSClientError: If the response is not valid.
|
|
460
498
|
"""
|
|
461
499
|
# Verify that the response is in the correct format. If it's not, raise an error.
|
|
462
|
-
if
|
|
500
|
+
# NOTE: We're disabling the no-else-raise rule here because both comparisons are on the same enum so if one is
|
|
501
|
+
# removed then the other will raise an error. This is a false positive.
|
|
502
|
+
if resp.data_type == ResponseDataType.TXT: # pylint: disable=no-else-raise
|
|
503
|
+
raise MMSServerError(config.name, resp.payload.decode("UTF-8"))
|
|
504
|
+
elif resp.data_type != ResponseDataType.XML:
|
|
463
505
|
raise MMSClientError(
|
|
464
506
|
config.name,
|
|
465
507
|
f"Invalid MMS response data type: {resp.data_type.name}. Only XML is supported.",
|
|
@@ -592,6 +634,7 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
|
|
|
592
634
|
if service.interface not in self._wrappers:
|
|
593
635
|
logger.debug(f"Creating wrapper for {service.interface.name} interface.")
|
|
594
636
|
self._wrappers[service.interface] = ZWrapper(
|
|
637
|
+
self._domain,
|
|
595
638
|
self._client_type,
|
|
596
639
|
service.interface,
|
|
597
640
|
self._cert.to_adapter(),
|
|
@@ -18,6 +18,8 @@ from mms_client.types.market import MarketType
|
|
|
18
18
|
from mms_client.types.offer import OfferCancel
|
|
19
19
|
from mms_client.types.offer import OfferData
|
|
20
20
|
from mms_client.types.offer import OfferQuery
|
|
21
|
+
from mms_client.types.reserve import ReserveRequirement
|
|
22
|
+
from mms_client.types.reserve import ReserveRequirementQuery
|
|
21
23
|
from mms_client.types.transport import RequestType
|
|
22
24
|
from mms_client.utils.serialization import SchemaType
|
|
23
25
|
from mms_client.utils.serialization import Serializer
|
|
@@ -34,6 +36,36 @@ class MarketClientMixin: # pylint: disable=unused-argument
|
|
|
34
36
|
# The configuration for the market service
|
|
35
37
|
config = ServiceConfiguration(Interface.MI, Serializer(SchemaType.MARKET, "MarketData"))
|
|
36
38
|
|
|
39
|
+
@mms_endpoint(
|
|
40
|
+
"MarketQuery_ReserveRequirementQuery",
|
|
41
|
+
config,
|
|
42
|
+
RequestType.INFO,
|
|
43
|
+
resp_envelope_type=MarketSubmit,
|
|
44
|
+
resp_data_type=ReserveRequirement,
|
|
45
|
+
)
|
|
46
|
+
def query_reserve_requirements(
|
|
47
|
+
self: ClientProto, request: ReserveRequirementQuery, days: int, date: Optional[Date] = None
|
|
48
|
+
) -> List[ReserveRequirement]:
|
|
49
|
+
"""Query the MMS server for reserve requirements.
|
|
50
|
+
|
|
51
|
+
This endpoint is accessible to all client types.
|
|
52
|
+
|
|
53
|
+
Arguments:
|
|
54
|
+
request (ReserveRequirementQuery): The query to submit to the MMS server.
|
|
55
|
+
date (Date): The date of the transaction in the format "YYYY-MM-DD". This value defaults
|
|
56
|
+
to the current date.
|
|
57
|
+
days (int): The number of days ahead for which the data is being queried.
|
|
58
|
+
|
|
59
|
+
Returns: A list of reserve requirements that match the query.
|
|
60
|
+
"""
|
|
61
|
+
# NOTE: The return type does not match the method definition but the decorator will return the correct type
|
|
62
|
+
return MarketQuery( # type: ignore[return-value]
|
|
63
|
+
date=date or Date.today(),
|
|
64
|
+
participant=self.participant,
|
|
65
|
+
user=self.user,
|
|
66
|
+
days=days,
|
|
67
|
+
)
|
|
68
|
+
|
|
37
69
|
@mms_endpoint("MarketSubmit_OfferData", config, RequestType.INFO, [ClientType.BSP])
|
|
38
70
|
def put_offer(
|
|
39
71
|
self: ClientProto, request: OfferData, market_type: MarketType, days: int, date: Optional[Date] = None
|
|
@@ -124,6 +156,8 @@ class MarketClientMixin: # pylint: disable=unused-argument
|
|
|
124
156
|
days (int): The number of days ahead for which the data is being cancelled.
|
|
125
157
|
date (Date): The date of the transaction in the format "YYYY-MM-DD". This value defaults to the
|
|
126
158
|
current date.
|
|
159
|
+
|
|
160
|
+
Returns: Data identifying the offer that was cancelled.
|
|
127
161
|
"""
|
|
128
162
|
# NOTE: The return type does not match the method definition but the decorator will return the correct type
|
|
129
163
|
return MarketCancel( # type: ignore[return-value]
|
|
@@ -134,7 +168,7 @@ class MarketClientMixin: # pylint: disable=unused-argument
|
|
|
134
168
|
days=days,
|
|
135
169
|
)
|
|
136
170
|
|
|
137
|
-
@mms_endpoint("MarketQuery_AwardResultsQuery", config, RequestType.
|
|
171
|
+
@mms_endpoint("MarketQuery_AwardResultsQuery", config, RequestType.MARKET, resp_data_type=AwardResponse)
|
|
138
172
|
def query_awards(self: ClientProto, request: AwardQuery, days: int, date: Optional[Date] = None) -> AwardResponse:
|
|
139
173
|
"""Query the MMS server for award results.
|
|
140
174
|
|
|
@@ -115,12 +115,9 @@ class SchemaType(Enum):
|
|
|
115
115
|
OMI = "omi.xsd"
|
|
116
116
|
|
|
117
117
|
|
|
118
|
-
class PayloadBase(BaseXmlModel
|
|
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."""
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Contains objects for MMS reserve requirements."""
|
|
2
|
+
|
|
3
|
+
from typing import List
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from pydantic_extra_types.pendulum_dt import DateTime
|
|
7
|
+
from pydantic_xml import attr
|
|
8
|
+
from pydantic_xml import element
|
|
9
|
+
|
|
10
|
+
from mms_client.types.base import Payload
|
|
11
|
+
from mms_client.types.enums import AreaCode
|
|
12
|
+
from mms_client.types.enums import Direction
|
|
13
|
+
from mms_client.types.fields import power_positive
|
|
14
|
+
from mms_client.types.market import MarketType
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Requirement(Payload):
|
|
18
|
+
"""Represents a reserve requirement."""
|
|
19
|
+
|
|
20
|
+
# The start block of the requirement
|
|
21
|
+
start: DateTime = attr(name="StartTime")
|
|
22
|
+
|
|
23
|
+
# The end block of the requirement
|
|
24
|
+
end: DateTime = attr(name="EndTime")
|
|
25
|
+
|
|
26
|
+
# The direction of the requirement
|
|
27
|
+
direction: Direction = attr(name="Direction")
|
|
28
|
+
|
|
29
|
+
# The primary reserve quantity in kW
|
|
30
|
+
primary_qty_kw: Optional[int] = power_positive("PrimaryReserveQuantityInKw", True)
|
|
31
|
+
|
|
32
|
+
# The first secondary reserve quantity in kW
|
|
33
|
+
secondary_1_qty_kw: Optional[int] = power_positive("Secondary1ReserveQuantityInKw", True)
|
|
34
|
+
|
|
35
|
+
# The second secondary reserve quantity in kW
|
|
36
|
+
secondary_2_qty_kw: Optional[int] = power_positive("Secondary2ReserveQuantityInKw", True)
|
|
37
|
+
|
|
38
|
+
# The first tertiary reserve quantity in kW
|
|
39
|
+
tertiary_1_qty_kw: Optional[int] = power_positive("Tertiary1ReserveQuantityInKw", True)
|
|
40
|
+
|
|
41
|
+
# The second tertiary reserve quantity in kW
|
|
42
|
+
tertiary_2_qty_kw: Optional[int] = power_positive("Tertiary2ReserveQuantityInKw", True)
|
|
43
|
+
|
|
44
|
+
# The minimum reserve of compound primary and secondary 1 in kW
|
|
45
|
+
primary_secondary_1_qty_kw: Optional[int] = power_positive("CompoundPriSec1ReserveQuantityInKw", True)
|
|
46
|
+
|
|
47
|
+
# The minimum reserve of compound primary and secondary 2 in kW
|
|
48
|
+
primary_secondary_2_qty_kw: Optional[int] = power_positive("CompoundPriSec2ReserveQuantityInKw", True)
|
|
49
|
+
|
|
50
|
+
# The minimum reserve of compound primary and tertiary 1 in kW
|
|
51
|
+
primary_tertiary_1_qty_kw: Optional[int] = power_positive("CompoundPriTer1ReserveQuantityInKw", True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ReserveRequirement(Payload):
|
|
55
|
+
"""Represents a set of reserve requirements."""
|
|
56
|
+
|
|
57
|
+
# The area for which the reserve requirement applies
|
|
58
|
+
area: AreaCode = attr(name="Area")
|
|
59
|
+
|
|
60
|
+
# The requirements associated with the area
|
|
61
|
+
requirements: List[Requirement] = element(tag="Requirement", min_length=1)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ReserveRequirementQuery(Payload):
|
|
65
|
+
"""Represents a request to query reserve requirements."""
|
|
66
|
+
|
|
67
|
+
# The market type for which to query reserve requirements
|
|
68
|
+
market_type: MarketType = attr(name="MarketType")
|
|
69
|
+
|
|
70
|
+
# The area for which to query reserve requirements
|
|
71
|
+
area: Optional[AreaCode] = attr(default=None, name="Area")
|
|
@@ -55,7 +55,7 @@ class Attachment(BaseModel):
|
|
|
55
55
|
name: str = Field(alias="name")
|
|
56
56
|
|
|
57
57
|
# The attachment file data
|
|
58
|
-
data:
|
|
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:
|
|
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.
|
|
@@ -38,6 +38,21 @@ class AudienceError(ValueError):
|
|
|
38
38
|
super().__init__(self.message)
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
class MMSServerError(RuntimeError):
|
|
42
|
+
"""Error raised when the MMS server returns an error."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, method: str, message: str):
|
|
45
|
+
"""Initialize the error.
|
|
46
|
+
|
|
47
|
+
Arguments:
|
|
48
|
+
method (str): The method that caused the error.
|
|
49
|
+
message (str): The error message.
|
|
50
|
+
"""
|
|
51
|
+
super().__init__(f"{method}: {message}")
|
|
52
|
+
self.message = message
|
|
53
|
+
self.method = method
|
|
54
|
+
|
|
55
|
+
|
|
41
56
|
class MMSClientError(RuntimeError):
|
|
42
57
|
"""Base class for MMS client errors."""
|
|
43
58
|
|
|
@@ -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
|
|
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
|
|
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
|
|
|
@@ -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=
|
|
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,44 +0,0 @@
|
|
|
1
|
-
"""Contains functionality associated with certificates."""
|
|
2
|
-
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
from typing import Union
|
|
5
|
-
|
|
6
|
-
from requests_pkcs12 import Pkcs12Adapter
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class Certificate:
|
|
10
|
-
"""Describes a certificate composed of a cert file and a key file."""
|
|
11
|
-
|
|
12
|
-
def __init__(self, cert: Union[str, Path, bytes], passphrase: str):
|
|
13
|
-
"""Create a new Certificate.
|
|
14
|
-
|
|
15
|
-
Creates a new certificate from the absolute path to the certificate file and a passpharse.
|
|
16
|
-
|
|
17
|
-
Arguments:
|
|
18
|
-
cert: The certificate file. If a string, it should be the absolute path to the certificate file. If
|
|
19
|
-
bytes, it should be the contents of the certificate file.
|
|
20
|
-
passphrase: The passphrase the certificate is encrypted with
|
|
21
|
-
"""
|
|
22
|
-
# Get the certificate file contents
|
|
23
|
-
if isinstance(cert, (str, Path)):
|
|
24
|
-
with open(cert, "rb") as file:
|
|
25
|
-
self._cert = file.read()
|
|
26
|
-
else:
|
|
27
|
-
self._cert = cert
|
|
28
|
-
|
|
29
|
-
# Save the passphrase
|
|
30
|
-
self._passphrase = passphrase
|
|
31
|
-
|
|
32
|
-
@property
|
|
33
|
-
def certificate(self) -> bytes:
|
|
34
|
-
"""Return the certificate data."""
|
|
35
|
-
return self._cert
|
|
36
|
-
|
|
37
|
-
@property
|
|
38
|
-
def passphrase(self) -> str:
|
|
39
|
-
"""Return the full path to the passphrase."""
|
|
40
|
-
return self._passphrase
|
|
41
|
-
|
|
42
|
-
def to_adapter(self) -> Pkcs12Adapter:
|
|
43
|
-
"""Convert the certificate to a Pkcs12Adapter."""
|
|
44
|
-
return Pkcs12Adapter(pkcs12_data=self._cert, pkcs12_password=self._passphrase)
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
"""Contains objects for cryptographic operations."""
|
|
2
|
-
|
|
3
|
-
from base64 import b64encode
|
|
4
|
-
from hashlib import sha256
|
|
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
|
|
11
|
-
|
|
12
|
-
from mms_client.security.certs import Certificate
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class CryptoWrapper:
|
|
16
|
-
"""Wraps the cryptographic operations necessary for signing and encrypting MMS payload data."""
|
|
17
|
-
|
|
18
|
-
def __init__(self, cert: Certificate):
|
|
19
|
-
"""Create a new CryptoWrapper with the given certificate.
|
|
20
|
-
|
|
21
|
-
Arguments:
|
|
22
|
-
cert (Certificate): The certificate to use for cryptographic operations.
|
|
23
|
-
"""
|
|
24
|
-
# First, save our certificate for later use
|
|
25
|
-
self._cert = cert
|
|
26
|
-
|
|
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
|
-
)
|
|
31
|
-
|
|
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.")
|
|
37
|
-
|
|
38
|
-
# Finally, save our padding and algorithm for later use
|
|
39
|
-
self._padding = padding.PKCS1v15()
|
|
40
|
-
self._algorithm = hashes.SHA256()
|
|
41
|
-
|
|
42
|
-
def sign(self, data: bytes) -> bytes:
|
|
43
|
-
"""Create a signature from the given data using the certificate.
|
|
44
|
-
|
|
45
|
-
Arguments:
|
|
46
|
-
data (bytes): The data to be encrypted.
|
|
47
|
-
|
|
48
|
-
Returns: A base64-encoded string containing the signature.
|
|
49
|
-
"""
|
|
50
|
-
# First, hash the data using SHA256
|
|
51
|
-
hashed = sha256(data)
|
|
52
|
-
|
|
53
|
-
# Next, sign the hash using the private key
|
|
54
|
-
signature = self._private_key.sign(hashed.digest(), self._padding, self._algorithm)
|
|
55
|
-
|
|
56
|
-
# Finally, return the base64-encoded signature
|
|
57
|
-
return b64encode(signature)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|