mtx-api-py 0.4.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.
- mtx_api/__init__.py +25 -0
- mtx_api/client/__init__.py +3 -0
- mtx_api/client/_auth_client.py +230 -0
- mtx_api/client/_base_client.py +50 -0
- mtx_api/client/_jurisdiction_client.py +74 -0
- mtx_api/client/_jurisdiction_elements_client.py +41 -0
- mtx_api/client/_location_client.py +61 -0
- mtx_api/client/_mtx_http_client.py +201 -0
- mtx_api/client/_request_client.py +374 -0
- mtx_api/client/_service_client.py +94 -0
- mtx_api/client/_typology_client.py +64 -0
- mtx_api/client/_user_client.py +135 -0
- mtx_api/client/mtx_client.py +51 -0
- mtx_api/models/__init__.py +0 -0
- mtx_api/models/additional_data.py +334 -0
- mtx_api/models/auth.py +84 -0
- mtx_api/models/base.py +26 -0
- mtx_api/models/common.py +103 -0
- mtx_api/models/errors.py +117 -0
- mtx_api/models/jurisdiction.py +101 -0
- mtx_api/models/medias.py +97 -0
- mtx_api/models/request.py +253 -0
- mtx_api/models/request_comment.py +53 -0
- mtx_api/models/request_media.py +32 -0
- mtx_api/models/service.py +207 -0
- mtx_api/models/typology.py +69 -0
- mtx_api/py.typed +0 -0
- mtx_api/settings.py +11 -0
- mtx_api_py-0.4.0.dist-info/METADATA +323 -0
- mtx_api_py-0.4.0.dist-info/RECORD +32 -0
- mtx_api_py-0.4.0.dist-info/WHEEL +4 -0
- mtx_api_py-0.4.0.dist-info/licenses/LICENSE.txt +21 -0
mtx_api/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from mtx_api.client.mtx_client import MTXClient
|
|
4
|
+
from mtx_api.models.errors import (
|
|
5
|
+
ApiDecodingError,
|
|
6
|
+
ApiError,
|
|
7
|
+
InvalidEmailError,
|
|
8
|
+
MissingBaseUrlError,
|
|
9
|
+
MissingClientIdError,
|
|
10
|
+
MissingJurisdictionCodesError,
|
|
11
|
+
MissingJwtSecretError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"MTXClient",
|
|
16
|
+
"ApiError",
|
|
17
|
+
"ApiDecodingError",
|
|
18
|
+
"InvalidEmailError",
|
|
19
|
+
"MissingBaseUrlError",
|
|
20
|
+
"MissingClientIdError",
|
|
21
|
+
"MissingJwtSecretError",
|
|
22
|
+
"MissingJurisdictionCodesError",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
__version__ = "0.4.0"
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from base64 import b64decode
|
|
3
|
+
|
|
4
|
+
import jwt
|
|
5
|
+
|
|
6
|
+
from mtx_api.client._mtx_http_client import Method, MtxHttpClient
|
|
7
|
+
from mtx_api.models.auth import Authentication
|
|
8
|
+
from mtx_api.models.errors import InvalidEmailError, MissingArgumentError, MissingJwtSecretError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthClient:
|
|
12
|
+
def __init__(self, client: MtxHttpClient):
|
|
13
|
+
self._client: MtxHttpClient = client
|
|
14
|
+
|
|
15
|
+
async def login_anonymous(
|
|
16
|
+
self,
|
|
17
|
+
*,
|
|
18
|
+
device_id: str,
|
|
19
|
+
) -> Authentication:
|
|
20
|
+
"""Authenticates a user anonymously using a unique device identifier.
|
|
21
|
+
|
|
22
|
+
Use this tool to log in a user who does not have a registered account,
|
|
23
|
+
identifying them solely by their device ID. This enables access to
|
|
24
|
+
features that require authentication without user registration.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
device_id: A stable and unique identifier for the device.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Authentication: A model containing access and refresh tokens.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
MissingArgumentError: If `device_id` is empty or None.
|
|
34
|
+
"""
|
|
35
|
+
if not device_id:
|
|
36
|
+
raise MissingArgumentError("device_id")
|
|
37
|
+
|
|
38
|
+
return await self._client.http_request(
|
|
39
|
+
method=Method.POST,
|
|
40
|
+
path="login-anonymous",
|
|
41
|
+
model=Authentication,
|
|
42
|
+
body={
|
|
43
|
+
"client_id": self._client.client_id,
|
|
44
|
+
"device_id": device_id,
|
|
45
|
+
},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
async def login_refresh(
|
|
49
|
+
self,
|
|
50
|
+
*,
|
|
51
|
+
refresh_token: str,
|
|
52
|
+
) -> Authentication:
|
|
53
|
+
"""Refreshes an existing authentication session using a valid refresh token.
|
|
54
|
+
|
|
55
|
+
Use this tool to obtain a new access token when the current one has expired,
|
|
56
|
+
allowing the user's session to continue without requiring re-authentication.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
refresh_token: The token issued during a previous authentication, used
|
|
60
|
+
to refresh the session.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Authentication: A model containing access and refresh tokens.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
MissingArgumentError: If `refresh_token` is empty or None.
|
|
67
|
+
"""
|
|
68
|
+
if not refresh_token:
|
|
69
|
+
raise MissingArgumentError("refresh_token")
|
|
70
|
+
return await self._client.http_request(
|
|
71
|
+
method=Method.POST,
|
|
72
|
+
path="oauth/v2/token",
|
|
73
|
+
model=Authentication,
|
|
74
|
+
body={
|
|
75
|
+
"grant_type": "refresh_token",
|
|
76
|
+
"refresh_token": refresh_token,
|
|
77
|
+
"client_id": self._client.client_id,
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
async def login_jwt(
|
|
82
|
+
self,
|
|
83
|
+
*,
|
|
84
|
+
email: str,
|
|
85
|
+
first_name: str | None = None,
|
|
86
|
+
last_name: str | None = None,
|
|
87
|
+
register: bool | None = None,
|
|
88
|
+
legal_terms_accepted: bool | None = None,
|
|
89
|
+
) -> Authentication:
|
|
90
|
+
"""Authenticates a user using a signed JSON Web Token (JWT).
|
|
91
|
+
|
|
92
|
+
Use this tool for trusted server-side authentication or Single Sign-On (SSO)
|
|
93
|
+
integration. It facilitates logging in or registering a user based on their
|
|
94
|
+
email address.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
email: The user's email address.
|
|
98
|
+
first_name: Optional user's first name.
|
|
99
|
+
last_name: Optional user's last name.
|
|
100
|
+
register: Optional. If True, registers the user if they do not exist.
|
|
101
|
+
legal_terms_accepted: Optional. Whether the user has accepted the legal terms.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Authentication: A model containing access and refresh tokens.
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
InvalidEmailError: If the provided email format is invalid.
|
|
108
|
+
MissingJwtSecretError: If the JWT secret is not configured in the client.
|
|
109
|
+
"""
|
|
110
|
+
if re.fullmatch(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", email) is None:
|
|
111
|
+
raise InvalidEmailError()
|
|
112
|
+
user_data = {
|
|
113
|
+
k: v
|
|
114
|
+
for k, v in {
|
|
115
|
+
"username": email,
|
|
116
|
+
"first_name": first_name,
|
|
117
|
+
"last_name": last_name,
|
|
118
|
+
"register": register,
|
|
119
|
+
"legal_terms_accepted": legal_terms_accepted,
|
|
120
|
+
}.items()
|
|
121
|
+
if v is not None
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
secret = self._client.jwt_secret
|
|
125
|
+
if not secret:
|
|
126
|
+
raise MissingJwtSecretError()
|
|
127
|
+
while len(secret) % 4 != 0:
|
|
128
|
+
secret += "="
|
|
129
|
+
dec = b64decode(secret)
|
|
130
|
+
token = jwt.encode(user_data, dec, algorithm="HS256")
|
|
131
|
+
|
|
132
|
+
return await self._client.http_request(
|
|
133
|
+
method=Method.POST,
|
|
134
|
+
path="login-jwt",
|
|
135
|
+
model=Authentication,
|
|
136
|
+
body={
|
|
137
|
+
"client_id": self._client.client_id,
|
|
138
|
+
"token": token,
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
async def register(
|
|
143
|
+
self,
|
|
144
|
+
*,
|
|
145
|
+
username: str,
|
|
146
|
+
password: str,
|
|
147
|
+
first_name: str,
|
|
148
|
+
last_name: str,
|
|
149
|
+
legal_terms_accepted: bool,
|
|
150
|
+
jurisdiction_code: str | None = None,
|
|
151
|
+
) -> Authentication:
|
|
152
|
+
"""Registers a new user account and immediately authenticates the user.
|
|
153
|
+
|
|
154
|
+
Use this tool to create a new user profile with the provided credentials
|
|
155
|
+
and personal information. This action also logs the user in.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
username: The email address to use as the username.
|
|
159
|
+
password: The password for the new account.
|
|
160
|
+
first_name: The user's first name.
|
|
161
|
+
last_name: The user's last name.
|
|
162
|
+
legal_terms_accepted: Must be True to indicate acceptance of legal terms.
|
|
163
|
+
jurisdiction_code: Optional jurisdiction code for the user.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Authentication: A model containing access and refresh tokens.
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
MissingArgumentError: If any required argument (username, password,
|
|
170
|
+
first_name, last_name) is missing.
|
|
171
|
+
"""
|
|
172
|
+
if not username:
|
|
173
|
+
raise MissingArgumentError("username")
|
|
174
|
+
if not password:
|
|
175
|
+
raise MissingArgumentError("password")
|
|
176
|
+
if not first_name:
|
|
177
|
+
raise MissingArgumentError("first_name")
|
|
178
|
+
if not last_name:
|
|
179
|
+
raise MissingArgumentError("last_name")
|
|
180
|
+
|
|
181
|
+
return await self._client.http_request(
|
|
182
|
+
method=Method.POST,
|
|
183
|
+
path="login",
|
|
184
|
+
model=Authentication,
|
|
185
|
+
body={
|
|
186
|
+
"client_id": self._client.client_id,
|
|
187
|
+
"username": username,
|
|
188
|
+
"password": password,
|
|
189
|
+
"register": "true",
|
|
190
|
+
"first_name": first_name,
|
|
191
|
+
"last_name": last_name,
|
|
192
|
+
"legal_terms_accepted": "true" if legal_terms_accepted else None,
|
|
193
|
+
"jurisdiction_id": jurisdiction_code,
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
async def login(
|
|
198
|
+
self,
|
|
199
|
+
*,
|
|
200
|
+
username: str,
|
|
201
|
+
password: str,
|
|
202
|
+
) -> Authentication:
|
|
203
|
+
"""Authenticates a user using their username (email) and password.
|
|
204
|
+
|
|
205
|
+
Use this tool for standard user login to obtain access credentials.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
username: The user's email address.
|
|
209
|
+
password: The user's password.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Authentication: A model containing access and refresh tokens.
|
|
213
|
+
|
|
214
|
+
Raises:
|
|
215
|
+
MissingArgumentError: If `username` or `password` is missing.
|
|
216
|
+
"""
|
|
217
|
+
if not username:
|
|
218
|
+
raise MissingArgumentError("username")
|
|
219
|
+
if not password:
|
|
220
|
+
raise MissingArgumentError("password")
|
|
221
|
+
return await self._client.http_request(
|
|
222
|
+
method=Method.POST,
|
|
223
|
+
path="login",
|
|
224
|
+
model=Authentication,
|
|
225
|
+
body={
|
|
226
|
+
"client_id": self._client.client_id,
|
|
227
|
+
"username": username,
|
|
228
|
+
"password": password,
|
|
229
|
+
},
|
|
230
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from mtx_api.client._mtx_http_client import Method, MtxHttpClient
|
|
4
|
+
from mtx_api.models.base import EmptyResponse, TextResponse
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BaseClient:
|
|
8
|
+
def __init__(self, client: MtxHttpClient):
|
|
9
|
+
self._client: MtxHttpClient = client
|
|
10
|
+
|
|
11
|
+
async def status(
|
|
12
|
+
self,
|
|
13
|
+
*,
|
|
14
|
+
access_token: str | None = None,
|
|
15
|
+
) -> bool:
|
|
16
|
+
"""Checks the operational status of the platform.
|
|
17
|
+
|
|
18
|
+
Verifies if the platform is running and reachable. Use this tool
|
|
19
|
+
to confirm platform availability before performing other operations.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
access_token: Optional token used for authentication. If not provided, the operation
|
|
23
|
+
will be made without authentication.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
True if the platform is available and the operation succeeds.
|
|
27
|
+
"""
|
|
28
|
+
await self._client.http_request(
|
|
29
|
+
method=Method.GET,
|
|
30
|
+
path="",
|
|
31
|
+
model=EmptyResponse,
|
|
32
|
+
access_token=access_token,
|
|
33
|
+
)
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
async def openapi(self) -> str:
|
|
37
|
+
"""Retrieves the OpenAPI specification of the platform.
|
|
38
|
+
|
|
39
|
+
Fetches the complete OpenAPI documentation in plain text. This provides details on available
|
|
40
|
+
tools, operation structures, and expected responses.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The OpenAPI document content as a string.
|
|
44
|
+
"""
|
|
45
|
+
resp = await self._client.http_request(
|
|
46
|
+
method=Method.GET,
|
|
47
|
+
path="openapi",
|
|
48
|
+
model=TextResponse,
|
|
49
|
+
)
|
|
50
|
+
return resp.text
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from mtx_api.client._mtx_http_client import Method, MtxHttpClient
|
|
4
|
+
from mtx_api.models.errors import MissingJurisdictionCodesError
|
|
5
|
+
from mtx_api.models.jurisdiction import Jurisdiction, JurisdictionList
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class JurisdictionClient:
|
|
9
|
+
def __init__(self, client: MtxHttpClient):
|
|
10
|
+
self._client: MtxHttpClient = client
|
|
11
|
+
|
|
12
|
+
async def list(
|
|
13
|
+
self,
|
|
14
|
+
*,
|
|
15
|
+
access_token: str | None = None,
|
|
16
|
+
lat: float | None = None,
|
|
17
|
+
lng: float | None = None,
|
|
18
|
+
) -> JurisdictionList:
|
|
19
|
+
"""Retrieves a list of jurisdictions from the platform.
|
|
20
|
+
|
|
21
|
+
This tool allows you to list all available jurisdictions or find those closest to a
|
|
22
|
+
specific location if coordinates are provided.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
access_token: Optional token used for authentication. If not provided,
|
|
26
|
+
the operation will be made without authentication.
|
|
27
|
+
lat: Optional latitude coordinate to filter jurisdictions by proximity.
|
|
28
|
+
lng: Optional longitude coordinate to filter jurisdictions by proximity.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
JurisdictionList: A collection of Jurisdiction objects with basic details.
|
|
32
|
+
"""
|
|
33
|
+
return await self._client.http_request(
|
|
34
|
+
method=Method.GET,
|
|
35
|
+
path="jurisdictions",
|
|
36
|
+
model=JurisdictionList,
|
|
37
|
+
access_token=access_token,
|
|
38
|
+
query_params={
|
|
39
|
+
"lat": lat,
|
|
40
|
+
"long": lng,
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
async def detail(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
access_token: str | None = None,
|
|
48
|
+
jurisdiction_code: str,
|
|
49
|
+
) -> Jurisdiction:
|
|
50
|
+
"""Retrieves detailed information for a specific jurisdiction.
|
|
51
|
+
|
|
52
|
+
This tool fetches the complete details of a jurisdiction using its unique code.
|
|
53
|
+
Use this tool when you need more information about a specific jurisdiction than what is
|
|
54
|
+
provided in the list.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
access_token: Optional token used for authentication. If not provided,
|
|
58
|
+
the operation will be made without authentication.
|
|
59
|
+
jurisdiction_code: The unique string code of the jurisdiction to query.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
MissingJurisdictionCodesError: If the provided jurisdiction_code is empty.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Jurisdiction: An object containing comprehensive information about the jurisdiction.
|
|
66
|
+
"""
|
|
67
|
+
if not jurisdiction_code:
|
|
68
|
+
raise MissingJurisdictionCodesError()
|
|
69
|
+
return await self._client.http_request(
|
|
70
|
+
method=Method.GET,
|
|
71
|
+
path=f"jurisdictions/{jurisdiction_code}",
|
|
72
|
+
model=Jurisdiction,
|
|
73
|
+
access_token=access_token,
|
|
74
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from mtx_api.client._mtx_http_client import Method, MtxHttpClient
|
|
2
|
+
from mtx_api.models.errors import MissingJurisdictionCodesError
|
|
3
|
+
from mtx_api.models.jurisdiction import JurisdictionElementList
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class JurisdictionElementsClient:
|
|
7
|
+
def __init__(self, client: MtxHttpClient):
|
|
8
|
+
self._client: MtxHttpClient = client
|
|
9
|
+
|
|
10
|
+
async def list(
|
|
11
|
+
self,
|
|
12
|
+
*,
|
|
13
|
+
access_token: str | None = None,
|
|
14
|
+
jurisdiction_code: str,
|
|
15
|
+
) -> JurisdictionElementList:
|
|
16
|
+
"""Retrieves jurisdiction elements associated with a specific jurisdiction code.
|
|
17
|
+
|
|
18
|
+
Use this tool to find all jurisdiction elements linked to a given jurisdiction code
|
|
19
|
+
from the platform.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
access_token: Optional token used for authentication. If not provided, the operation
|
|
23
|
+
will be made without authentication.
|
|
24
|
+
jurisdiction_code: The unique string code of the jurisdiction to query.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
JurisdictionElementList: An object containing the list of retrieved
|
|
28
|
+
jurisdiction elements.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
MissingJurisdictionCodesError: If the `jurisdiction_code` argument is missing or empty.
|
|
32
|
+
"""
|
|
33
|
+
if not jurisdiction_code:
|
|
34
|
+
raise MissingJurisdictionCodesError()
|
|
35
|
+
|
|
36
|
+
return await self._client.http_request(
|
|
37
|
+
method=Method.GET,
|
|
38
|
+
path=f"jurisdiction/{jurisdiction_code}/jurisdiction-elements",
|
|
39
|
+
model=JurisdictionElementList,
|
|
40
|
+
access_token=access_token,
|
|
41
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from mtx_api.client._mtx_http_client import Method, MtxHttpClient
|
|
2
|
+
from mtx_api.models.additional_data import LocationAdditionalDataList
|
|
3
|
+
from mtx_api.models.errors import MissingJurisdictionElementIdError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LocationClient:
|
|
7
|
+
def __init__(self, client: MtxHttpClient):
|
|
8
|
+
self.client: MtxHttpClient = client
|
|
9
|
+
|
|
10
|
+
async def resolve(
|
|
11
|
+
self,
|
|
12
|
+
*,
|
|
13
|
+
jurisdiction_element_id: str,
|
|
14
|
+
formatted_address: str | None = None,
|
|
15
|
+
lat: float | None = None,
|
|
16
|
+
lng: float | None = None,
|
|
17
|
+
) -> LocationAdditionalDataList:
|
|
18
|
+
"""
|
|
19
|
+
Retrieves latitude and longitude information from an address, or vice versa.
|
|
20
|
+
|
|
21
|
+
This tool fetches location-specific questions configured for a
|
|
22
|
+
jurisdiction element. You can identify the location by providing either a formatted
|
|
23
|
+
address, geographic coordinates (lat, lng), or both. Regardless of which parameters
|
|
24
|
+
you provide, the tool always returns the complete location information including
|
|
25
|
+
formatted address, geographic coordinates, and the list of additional data questions.
|
|
26
|
+
|
|
27
|
+
Use this tool when you need to know what additional information should be collected
|
|
28
|
+
for a specific location.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
jurisdiction_element_id: The unique identifier string of the jurisdiction element
|
|
32
|
+
where the location is being queried.
|
|
33
|
+
formatted_address: Optional complete address string in human-readable format
|
|
34
|
+
to identify the location.
|
|
35
|
+
lat: Optional latitude coordinate as a decimal number to identify the location.
|
|
36
|
+
Must be provided together with lng.
|
|
37
|
+
lng: Optional longitude coordinate as a decimal number to identify the location.
|
|
38
|
+
Must be provided together with lat.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
LocationAdditionalDataList: A collection of location entries. Each entry always
|
|
42
|
+
contains the formatted address, geographic coordinates (latitude and longitude),
|
|
43
|
+
and a list of additional data questions configured for that location, regardless
|
|
44
|
+
of which input parameters were used to identify the location.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
MissingJurisdictionElementIdError: If jurisdiction_element_id is empty or not provided.
|
|
48
|
+
"""
|
|
49
|
+
if not jurisdiction_element_id:
|
|
50
|
+
raise MissingJurisdictionElementIdError()
|
|
51
|
+
return await self.client.http_request(
|
|
52
|
+
method=Method.GET,
|
|
53
|
+
path="location-additional-data",
|
|
54
|
+
model=LocationAdditionalDataList,
|
|
55
|
+
query_params={
|
|
56
|
+
"jurisdiction_element_id": jurisdiction_element_id,
|
|
57
|
+
"formatted_address": formatted_address,
|
|
58
|
+
"lat": lat,
|
|
59
|
+
"lng": lng,
|
|
60
|
+
},
|
|
61
|
+
)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import TypeVar
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from pydantic import BaseModel, ValidationError
|
|
11
|
+
|
|
12
|
+
from mtx_api.models.base import EmptyResponse, TextResponse
|
|
13
|
+
from mtx_api.models.errors import (
|
|
14
|
+
ApiDecodingError,
|
|
15
|
+
ApiError,
|
|
16
|
+
MissingBaseUrlError,
|
|
17
|
+
MissingClientIdError,
|
|
18
|
+
)
|
|
19
|
+
from mtx_api.settings import MTXSettings
|
|
20
|
+
|
|
21
|
+
M = TypeVar("M", bound=BaseModel)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Method(Enum):
|
|
25
|
+
PUT = "PUT"
|
|
26
|
+
GET = "GET"
|
|
27
|
+
POST = "POST"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MtxHttpClient:
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
35
|
+
base_url: str | None = None,
|
|
36
|
+
client_id: str | None = None,
|
|
37
|
+
jwt_secret: str | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
settings = MTXSettings()
|
|
40
|
+
|
|
41
|
+
base_url_value = base_url or settings.base_url
|
|
42
|
+
if not base_url_value:
|
|
43
|
+
raise MissingBaseUrlError()
|
|
44
|
+
self._base_url: str = base_url_value.rstrip("/")
|
|
45
|
+
|
|
46
|
+
client_id_value = client_id or settings.client_id
|
|
47
|
+
if not client_id_value:
|
|
48
|
+
raise MissingClientIdError()
|
|
49
|
+
self._client_id: str = client_id_value
|
|
50
|
+
|
|
51
|
+
self._jwt_secret: str | None = jwt_secret or settings.jwt_secret
|
|
52
|
+
|
|
53
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
54
|
+
self.logger = logging.getLogger("mtx_logs")
|
|
55
|
+
|
|
56
|
+
self._client = httpx.AsyncClient(
|
|
57
|
+
base_url=self._base_url,
|
|
58
|
+
timeout=30.0,
|
|
59
|
+
transport=transport,
|
|
60
|
+
follow_redirects=True,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
async def __aenter__(self) -> MtxHttpClient:
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
async def __aexit__(
|
|
67
|
+
self,
|
|
68
|
+
exc_type: type[BaseException] | None,
|
|
69
|
+
exc: BaseException | None,
|
|
70
|
+
tb: object | None,
|
|
71
|
+
) -> None:
|
|
72
|
+
await self._client.aclose()
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def client_id(self) -> str:
|
|
76
|
+
return self._client_id
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def base_url(self) -> str:
|
|
80
|
+
return self._base_url
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def jwt_secret(self) -> str | None:
|
|
84
|
+
return self._jwt_secret
|
|
85
|
+
|
|
86
|
+
async def http_request(
|
|
87
|
+
self,
|
|
88
|
+
*,
|
|
89
|
+
method: Method,
|
|
90
|
+
path: str,
|
|
91
|
+
model: type[M],
|
|
92
|
+
access_token: str | None = None,
|
|
93
|
+
headers: Mapping[str, str] | None = None,
|
|
94
|
+
body: Mapping[str, object] | None = None,
|
|
95
|
+
query_params: Mapping[str, object] | None = None,
|
|
96
|
+
files: Mapping[str, tuple[str, bytes]] | None = None,
|
|
97
|
+
) -> M:
|
|
98
|
+
if not path.startswith("/"):
|
|
99
|
+
path = f"/{path}"
|
|
100
|
+
try:
|
|
101
|
+
auth_header = {"Authorization": f"Bearer {access_token}"} if access_token else {}
|
|
102
|
+
headers = {
|
|
103
|
+
**auth_header,
|
|
104
|
+
**(headers or {}),
|
|
105
|
+
"X-CLIENT-ID": self._client_id,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
is_multipart = files is not None
|
|
109
|
+
|
|
110
|
+
if not is_multipart:
|
|
111
|
+
headers["Content-type"] = "application/json"
|
|
112
|
+
|
|
113
|
+
def normalize(value: object) -> str | None:
|
|
114
|
+
if value is None:
|
|
115
|
+
return None
|
|
116
|
+
if isinstance(value, bool):
|
|
117
|
+
return "true" if value else "false"
|
|
118
|
+
if isinstance(value, list):
|
|
119
|
+
return ",".join(map(str, value)) if value else None
|
|
120
|
+
return str(value)
|
|
121
|
+
|
|
122
|
+
params = {k: normalize(v) for k, v in query_params.items()} if query_params else None
|
|
123
|
+
|
|
124
|
+
body = {k: v for k, v in body.items() if v is not None} if body else None
|
|
125
|
+
|
|
126
|
+
req = self._client.build_request(
|
|
127
|
+
method=method.value,
|
|
128
|
+
url=path,
|
|
129
|
+
headers=headers,
|
|
130
|
+
json=body if body and not is_multipart else None,
|
|
131
|
+
data=body if body and is_multipart else None,
|
|
132
|
+
params={k: v for k, v in params.items() if v is not None} if params else None,
|
|
133
|
+
files=files,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if is_multipart:
|
|
137
|
+
req.extensions["mtx_file_names"] = files
|
|
138
|
+
req.extensions["mtx_form_fields"] = body
|
|
139
|
+
|
|
140
|
+
self.log_as_curl(req)
|
|
141
|
+
resp = await self._client.send(req)
|
|
142
|
+
resp.raise_for_status()
|
|
143
|
+
self.log_response(resp)
|
|
144
|
+
try:
|
|
145
|
+
if model is EmptyResponse:
|
|
146
|
+
return model()
|
|
147
|
+
elif model is TextResponse:
|
|
148
|
+
return model(text=resp.text)
|
|
149
|
+
else:
|
|
150
|
+
return model.model_validate_json(resp.text)
|
|
151
|
+
except (ValidationError, ValueError) as de:
|
|
152
|
+
raise ApiDecodingError(str(de))
|
|
153
|
+
except httpx.HTTPStatusError as e:
|
|
154
|
+
resp = e.response
|
|
155
|
+
api_error = ApiError(body=resp.text, status_code=resp.status_code)
|
|
156
|
+
self.log_error(api_error)
|
|
157
|
+
raise api_error from e
|
|
158
|
+
|
|
159
|
+
def log_as_curl(
|
|
160
|
+
self,
|
|
161
|
+
request: httpx.Request,
|
|
162
|
+
) -> None:
|
|
163
|
+
header_parts = " ".join([f'-H "{k}: {v}"' for k, v in request.headers.items()])
|
|
164
|
+
content_type = request.headers.get("content-type", "").lower()
|
|
165
|
+
|
|
166
|
+
def replace_quotes(s: str) -> str:
|
|
167
|
+
return s.replace("'", "'\"'\"'")
|
|
168
|
+
|
|
169
|
+
data_part = ""
|
|
170
|
+
if content_type.startswith("multipart/form-data"):
|
|
171
|
+
extensions = request.extensions
|
|
172
|
+
parts = []
|
|
173
|
+
|
|
174
|
+
form_meta = extensions.get("mtx_form_fields") or {}
|
|
175
|
+
parts.extend([f"{k}={v}" for k, v in form_meta.items()])
|
|
176
|
+
|
|
177
|
+
files_meta = extensions.get("mtx_file_names") or {}
|
|
178
|
+
parts.extend([f"{k}=@{v[0]}" for k, v in files_meta.items()])
|
|
179
|
+
|
|
180
|
+
data_part = " ".join([f"-F '{replace_quotes(p)}'" for p in parts])
|
|
181
|
+
elif request.content:
|
|
182
|
+
data = request.content.decode()
|
|
183
|
+
data_part = f" -d '{replace_quotes(data)}'"
|
|
184
|
+
|
|
185
|
+
self.logger.info(f"HTTP {request.method} to {request.url.path}")
|
|
186
|
+
self.logger.debug(f"curl -X {request.method} {str(request.url)} {header_parts} {data_part}")
|
|
187
|
+
|
|
188
|
+
def log_response(self, resp: httpx.Response) -> None:
|
|
189
|
+
try:
|
|
190
|
+
json_obj = json.loads(resp.text)
|
|
191
|
+
formatted_json = json.dumps(json_obj, indent=4)
|
|
192
|
+
self.logger.debug(f"Response JSON: {resp.status_code} {formatted_json}")
|
|
193
|
+
except json.JSONDecodeError:
|
|
194
|
+
self.logger.debug(f"Response Text: {resp.status_code} {resp.text}")
|
|
195
|
+
|
|
196
|
+
def log_error(self, e: ApiError) -> None:
|
|
197
|
+
self.logger.error(f"{type(e).__name__}: {e}")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def array_query_param(key: str, value: Sequence[object] | None) -> dict[str, object]:
|
|
201
|
+
return {} if value is None else {f"{key}[{i}]": v for i, v in enumerate(value)}
|