mcp-eregistrations-bpa 0.8.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of mcp-eregistrations-bpa might be problematic. Click here for more details.
- mcp_eregistrations_bpa/__init__.py +121 -0
- mcp_eregistrations_bpa/__main__.py +6 -0
- mcp_eregistrations_bpa/arazzo/__init__.py +21 -0
- mcp_eregistrations_bpa/arazzo/expression.py +379 -0
- mcp_eregistrations_bpa/audit/__init__.py +56 -0
- mcp_eregistrations_bpa/audit/context.py +66 -0
- mcp_eregistrations_bpa/audit/logger.py +236 -0
- mcp_eregistrations_bpa/audit/models.py +131 -0
- mcp_eregistrations_bpa/auth/__init__.py +64 -0
- mcp_eregistrations_bpa/auth/callback.py +391 -0
- mcp_eregistrations_bpa/auth/cas.py +409 -0
- mcp_eregistrations_bpa/auth/oidc.py +252 -0
- mcp_eregistrations_bpa/auth/permissions.py +162 -0
- mcp_eregistrations_bpa/auth/token_manager.py +348 -0
- mcp_eregistrations_bpa/bpa_client/__init__.py +84 -0
- mcp_eregistrations_bpa/bpa_client/client.py +740 -0
- mcp_eregistrations_bpa/bpa_client/endpoints.py +193 -0
- mcp_eregistrations_bpa/bpa_client/errors.py +276 -0
- mcp_eregistrations_bpa/bpa_client/models.py +203 -0
- mcp_eregistrations_bpa/config.py +349 -0
- mcp_eregistrations_bpa/db/__init__.py +21 -0
- mcp_eregistrations_bpa/db/connection.py +64 -0
- mcp_eregistrations_bpa/db/migrations.py +168 -0
- mcp_eregistrations_bpa/exceptions.py +39 -0
- mcp_eregistrations_bpa/py.typed +0 -0
- mcp_eregistrations_bpa/rollback/__init__.py +19 -0
- mcp_eregistrations_bpa/rollback/manager.py +616 -0
- mcp_eregistrations_bpa/server.py +152 -0
- mcp_eregistrations_bpa/tools/__init__.py +372 -0
- mcp_eregistrations_bpa/tools/actions.py +155 -0
- mcp_eregistrations_bpa/tools/analysis.py +352 -0
- mcp_eregistrations_bpa/tools/audit.py +399 -0
- mcp_eregistrations_bpa/tools/behaviours.py +1042 -0
- mcp_eregistrations_bpa/tools/bots.py +627 -0
- mcp_eregistrations_bpa/tools/classifications.py +575 -0
- mcp_eregistrations_bpa/tools/costs.py +765 -0
- mcp_eregistrations_bpa/tools/debug_strategies.py +351 -0
- mcp_eregistrations_bpa/tools/debugger.py +1230 -0
- mcp_eregistrations_bpa/tools/determinants.py +2235 -0
- mcp_eregistrations_bpa/tools/document_requirements.py +670 -0
- mcp_eregistrations_bpa/tools/export.py +899 -0
- mcp_eregistrations_bpa/tools/fields.py +162 -0
- mcp_eregistrations_bpa/tools/form_errors.py +36 -0
- mcp_eregistrations_bpa/tools/formio_helpers.py +971 -0
- mcp_eregistrations_bpa/tools/forms.py +1269 -0
- mcp_eregistrations_bpa/tools/jsonlogic_builder.py +466 -0
- mcp_eregistrations_bpa/tools/large_response.py +163 -0
- mcp_eregistrations_bpa/tools/messages.py +523 -0
- mcp_eregistrations_bpa/tools/notifications.py +241 -0
- mcp_eregistrations_bpa/tools/registration_institutions.py +680 -0
- mcp_eregistrations_bpa/tools/registrations.py +897 -0
- mcp_eregistrations_bpa/tools/role_status.py +447 -0
- mcp_eregistrations_bpa/tools/role_units.py +400 -0
- mcp_eregistrations_bpa/tools/roles.py +1236 -0
- mcp_eregistrations_bpa/tools/rollback.py +335 -0
- mcp_eregistrations_bpa/tools/services.py +674 -0
- mcp_eregistrations_bpa/tools/workflows.py +2487 -0
- mcp_eregistrations_bpa/tools/yaml_transformer.py +991 -0
- mcp_eregistrations_bpa/workflows/__init__.py +28 -0
- mcp_eregistrations_bpa/workflows/loader.py +440 -0
- mcp_eregistrations_bpa/workflows/models.py +336 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/METADATA +965 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/RECORD +66 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/WHEEL +4 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/entry_points.txt +2 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/licenses/LICENSE +86 -0
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
"""BPA API async HTTP client with retry logic.
|
|
2
|
+
|
|
3
|
+
This module provides the main BPAClient class for interacting with the
|
|
4
|
+
BPA REST API. It features:
|
|
5
|
+
|
|
6
|
+
- Async HTTP operations using httpx
|
|
7
|
+
- Exponential backoff retry for transient errors (429, 502, 503, 504)
|
|
8
|
+
- No retry for client errors (400, 401, 403, 404)
|
|
9
|
+
- Token-based authorization via auth module
|
|
10
|
+
- AI-friendly error translation
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
from mcp_eregistrations_bpa.bpa_client import BPAClient
|
|
14
|
+
|
|
15
|
+
async with BPAClient() as client:
|
|
16
|
+
services = await client.get("/service")
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import logging
|
|
23
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from mcp_eregistrations_bpa.bpa_client.errors import (
|
|
28
|
+
BPAClientError,
|
|
29
|
+
BPAConnectionError,
|
|
30
|
+
BPATimeoutError,
|
|
31
|
+
translate_http_error,
|
|
32
|
+
)
|
|
33
|
+
from mcp_eregistrations_bpa.config import load_config
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from types import TracebackType
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"BPAClient",
|
|
40
|
+
"MAX_RETRIES",
|
|
41
|
+
"BASE_DELAY",
|
|
42
|
+
"MAX_DELAY",
|
|
43
|
+
"DEFAULT_TIMEOUT",
|
|
44
|
+
"RETRYABLE_STATUS_CODES",
|
|
45
|
+
"NON_RETRYABLE_STATUS_CODES",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
# Retry configuration
|
|
51
|
+
MAX_RETRIES = 3
|
|
52
|
+
BASE_DELAY = 1.0 # seconds
|
|
53
|
+
MAX_DELAY = 10.0 # seconds
|
|
54
|
+
|
|
55
|
+
# Timeout configuration (NFR15: 5 second default)
|
|
56
|
+
DEFAULT_TIMEOUT = 5.0 # seconds
|
|
57
|
+
|
|
58
|
+
# Status codes that should trigger retry
|
|
59
|
+
RETRYABLE_STATUS_CODES = frozenset({429, 502, 503, 504})
|
|
60
|
+
|
|
61
|
+
# Status codes that should NOT be retried (client errors)
|
|
62
|
+
NON_RETRYABLE_STATUS_CODES = frozenset({400, 401, 403, 404})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def calculate_backoff_delay(attempt: int) -> float:
|
|
66
|
+
"""Calculate exponential backoff delay with jitter.
|
|
67
|
+
|
|
68
|
+
Uses exponential backoff: delay = min(BASE_DELAY * 2^attempt, MAX_DELAY)
|
|
69
|
+
Adds small jitter to prevent thundering herd.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
attempt: The retry attempt number (0-indexed).
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
The delay in seconds before next retry.
|
|
76
|
+
"""
|
|
77
|
+
import random
|
|
78
|
+
|
|
79
|
+
delay = min(BASE_DELAY * (2**attempt), MAX_DELAY)
|
|
80
|
+
# Add up to 10% jitter
|
|
81
|
+
jitter = delay * 0.1 * random.random()
|
|
82
|
+
return float(delay + jitter)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class BPAClient:
|
|
86
|
+
"""Async HTTP client for BPA API with retry logic.
|
|
87
|
+
|
|
88
|
+
Features:
|
|
89
|
+
- Token-based authorization header injection
|
|
90
|
+
- Exponential backoff retry for transient errors
|
|
91
|
+
- AI-friendly error translation
|
|
92
|
+
- Async context manager support
|
|
93
|
+
|
|
94
|
+
Attributes:
|
|
95
|
+
base_url: The BPA instance base URL.
|
|
96
|
+
timeout: Request timeout in seconds.
|
|
97
|
+
max_retries: Maximum retry attempts for transient errors.
|
|
98
|
+
|
|
99
|
+
Example:
|
|
100
|
+
async with BPAClient() as client:
|
|
101
|
+
# GET request
|
|
102
|
+
services = await client.get("/service")
|
|
103
|
+
|
|
104
|
+
# GET with path parameters
|
|
105
|
+
service = await client.get("/service/{id}", path_params={"id": 123})
|
|
106
|
+
|
|
107
|
+
# POST with body
|
|
108
|
+
result = await client.post("/service", json={"name": "New Service"})
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
*,
|
|
114
|
+
base_url: str | None = None,
|
|
115
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
116
|
+
max_retries: int = MAX_RETRIES,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Initialize BPA client.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
base_url: BPA instance base URL. If None, uses config.
|
|
122
|
+
timeout: Request timeout in seconds.
|
|
123
|
+
max_retries: Maximum retry attempts for transient errors.
|
|
124
|
+
"""
|
|
125
|
+
if base_url is None:
|
|
126
|
+
config = load_config()
|
|
127
|
+
base_url = str(config.bpa_instance_url)
|
|
128
|
+
|
|
129
|
+
# Ensure base URL ends with API path (v2016.06, not /v3)
|
|
130
|
+
if not base_url.endswith("/bparest/bpa/v2016/06"):
|
|
131
|
+
base_url = base_url.rstrip("/") + "/bparest/bpa/v2016/06"
|
|
132
|
+
|
|
133
|
+
self.base_url = base_url
|
|
134
|
+
self.timeout = timeout
|
|
135
|
+
self.max_retries = max_retries
|
|
136
|
+
self._client: httpx.AsyncClient | None = None
|
|
137
|
+
|
|
138
|
+
async def __aenter__(self) -> BPAClient:
|
|
139
|
+
"""Enter async context manager."""
|
|
140
|
+
self._client = httpx.AsyncClient(
|
|
141
|
+
base_url=self.base_url,
|
|
142
|
+
timeout=httpx.Timeout(self.timeout),
|
|
143
|
+
)
|
|
144
|
+
return self
|
|
145
|
+
|
|
146
|
+
async def __aexit__(
|
|
147
|
+
self,
|
|
148
|
+
exc_type: type[BaseException] | None,
|
|
149
|
+
exc_val: BaseException | None,
|
|
150
|
+
exc_tb: TracebackType | None,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Exit async context manager."""
|
|
153
|
+
if self._client:
|
|
154
|
+
await self._client.aclose()
|
|
155
|
+
self._client = None
|
|
156
|
+
|
|
157
|
+
def _get_client(self) -> httpx.AsyncClient:
|
|
158
|
+
"""Get the HTTP client, ensuring it's initialized.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
The httpx async client.
|
|
162
|
+
|
|
163
|
+
Raises:
|
|
164
|
+
RuntimeError: If client is not initialized (use async with).
|
|
165
|
+
"""
|
|
166
|
+
if self._client is None:
|
|
167
|
+
msg = "BPAClient must be used as an async context manager"
|
|
168
|
+
raise RuntimeError(msg)
|
|
169
|
+
return self._client
|
|
170
|
+
|
|
171
|
+
async def _get_auth_header(self) -> dict[str, str]:
|
|
172
|
+
"""Get authorization header with current access token.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Headers dict with Authorization header.
|
|
176
|
+
|
|
177
|
+
Raises:
|
|
178
|
+
ToolError: If not authenticated or token expired.
|
|
179
|
+
"""
|
|
180
|
+
from mcp_eregistrations_bpa.auth.permissions import ensure_authenticated
|
|
181
|
+
|
|
182
|
+
token = await ensure_authenticated()
|
|
183
|
+
return {"Authorization": f"Bearer {token}"}
|
|
184
|
+
|
|
185
|
+
def _format_url(self, endpoint: str, path_params: dict[str, Any] | None) -> str:
|
|
186
|
+
"""Format URL endpoint with path parameters.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
endpoint: URL endpoint template (e.g., "/service/{id}").
|
|
190
|
+
path_params: Dictionary of path parameters.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Formatted URL path.
|
|
194
|
+
"""
|
|
195
|
+
if path_params:
|
|
196
|
+
return endpoint.format(**path_params)
|
|
197
|
+
return endpoint
|
|
198
|
+
|
|
199
|
+
async def _request_with_retry(
|
|
200
|
+
self,
|
|
201
|
+
method: str,
|
|
202
|
+
endpoint: str,
|
|
203
|
+
*,
|
|
204
|
+
path_params: dict[str, Any] | None = None,
|
|
205
|
+
params: dict[str, Any] | None = None,
|
|
206
|
+
json: dict[str, Any] | str | None = None,
|
|
207
|
+
content: str | bytes | None = None,
|
|
208
|
+
resource_type: str | None = None,
|
|
209
|
+
resource_id: str | int | None = None,
|
|
210
|
+
) -> httpx.Response:
|
|
211
|
+
"""Execute HTTP request with retry logic.
|
|
212
|
+
|
|
213
|
+
Retries on transient errors (429, 502, 503, 504) with exponential backoff.
|
|
214
|
+
Does NOT retry on client errors (400, 401, 403, 404).
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
method: HTTP method (GET, POST, PUT, DELETE).
|
|
218
|
+
endpoint: URL endpoint template.
|
|
219
|
+
path_params: Path parameters for URL formatting.
|
|
220
|
+
params: Query parameters.
|
|
221
|
+
json: JSON body for POST/PUT.
|
|
222
|
+
resource_type: Resource type for error context.
|
|
223
|
+
resource_id: Resource ID for error context.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
httpx Response object.
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
BPAClientError: On non-retryable errors or after max retries.
|
|
230
|
+
"""
|
|
231
|
+
client = self._get_client()
|
|
232
|
+
url = self._format_url(endpoint, path_params)
|
|
233
|
+
headers = await self._get_auth_header()
|
|
234
|
+
|
|
235
|
+
last_error: Exception | None = None
|
|
236
|
+
|
|
237
|
+
for attempt in range(self.max_retries + 1):
|
|
238
|
+
try:
|
|
239
|
+
# Log request details (DEBUG level for verbose output)
|
|
240
|
+
logger.debug(
|
|
241
|
+
"BPA API Request: %s %s%s",
|
|
242
|
+
method,
|
|
243
|
+
self.base_url,
|
|
244
|
+
url,
|
|
245
|
+
)
|
|
246
|
+
if params:
|
|
247
|
+
logger.debug(" Query params: %s", params)
|
|
248
|
+
if json:
|
|
249
|
+
logger.debug(" Request body (json): %s", json)
|
|
250
|
+
if content:
|
|
251
|
+
logger.debug(" Request body (content): %s", content)
|
|
252
|
+
|
|
253
|
+
response = await client.request(
|
|
254
|
+
method,
|
|
255
|
+
url,
|
|
256
|
+
headers=headers,
|
|
257
|
+
params=params,
|
|
258
|
+
json=json,
|
|
259
|
+
content=content,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Log response (INFO for success, DEBUG for body)
|
|
263
|
+
logger.info(
|
|
264
|
+
"BPA API Response: %s %s → %d %s",
|
|
265
|
+
method,
|
|
266
|
+
url,
|
|
267
|
+
response.status_code,
|
|
268
|
+
response.reason_phrase,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# Check for HTTP errors
|
|
272
|
+
if response.status_code >= 400:
|
|
273
|
+
# Don't retry client errors
|
|
274
|
+
if response.status_code in NON_RETRYABLE_STATUS_CODES:
|
|
275
|
+
error = httpx.HTTPStatusError(
|
|
276
|
+
f"HTTP {response.status_code}",
|
|
277
|
+
request=response.request,
|
|
278
|
+
response=response,
|
|
279
|
+
)
|
|
280
|
+
raise translate_http_error(
|
|
281
|
+
error,
|
|
282
|
+
resource_type=resource_type,
|
|
283
|
+
resource_id=resource_id,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# Check if retryable
|
|
287
|
+
if response.status_code in RETRYABLE_STATUS_CODES:
|
|
288
|
+
if attempt < self.max_retries:
|
|
289
|
+
delay = calculate_backoff_delay(attempt)
|
|
290
|
+
logger.warning(
|
|
291
|
+
"Retryable error %d on %s %s (attempt %d/%d), "
|
|
292
|
+
"retrying in %.2fs",
|
|
293
|
+
response.status_code,
|
|
294
|
+
method,
|
|
295
|
+
url,
|
|
296
|
+
attempt + 1,
|
|
297
|
+
self.max_retries + 1,
|
|
298
|
+
delay,
|
|
299
|
+
)
|
|
300
|
+
await asyncio.sleep(delay)
|
|
301
|
+
continue
|
|
302
|
+
|
|
303
|
+
# Non-retryable or out of retries
|
|
304
|
+
error = httpx.HTTPStatusError(
|
|
305
|
+
f"HTTP {response.status_code}",
|
|
306
|
+
request=response.request,
|
|
307
|
+
response=response,
|
|
308
|
+
)
|
|
309
|
+
raise translate_http_error(
|
|
310
|
+
error,
|
|
311
|
+
resource_type=resource_type,
|
|
312
|
+
resource_id=resource_id,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
return response
|
|
316
|
+
|
|
317
|
+
except httpx.ConnectError as e:
|
|
318
|
+
last_error = e
|
|
319
|
+
if attempt < self.max_retries:
|
|
320
|
+
delay = calculate_backoff_delay(attempt)
|
|
321
|
+
logger.warning(
|
|
322
|
+
"Connection error on %s %s (attempt %d/%d), "
|
|
323
|
+
"retrying in %.2fs: %s",
|
|
324
|
+
method,
|
|
325
|
+
url,
|
|
326
|
+
attempt + 1,
|
|
327
|
+
self.max_retries + 1,
|
|
328
|
+
delay,
|
|
329
|
+
e,
|
|
330
|
+
)
|
|
331
|
+
await asyncio.sleep(delay)
|
|
332
|
+
continue
|
|
333
|
+
raise BPAConnectionError(
|
|
334
|
+
f"Failed to connect to BPA API after {self.max_retries + 1} "
|
|
335
|
+
f"attempts: {e}"
|
|
336
|
+
) from e
|
|
337
|
+
|
|
338
|
+
except httpx.TimeoutException as e:
|
|
339
|
+
last_error = e
|
|
340
|
+
if attempt < self.max_retries:
|
|
341
|
+
delay = calculate_backoff_delay(attempt)
|
|
342
|
+
logger.warning(
|
|
343
|
+
"Timeout on %s %s (attempt %d/%d), retrying in %.2fs",
|
|
344
|
+
method,
|
|
345
|
+
url,
|
|
346
|
+
attempt + 1,
|
|
347
|
+
self.max_retries + 1,
|
|
348
|
+
delay,
|
|
349
|
+
)
|
|
350
|
+
await asyncio.sleep(delay)
|
|
351
|
+
continue
|
|
352
|
+
raise BPATimeoutError(
|
|
353
|
+
f"BPA API request timed out after {self.max_retries + 1} attempts"
|
|
354
|
+
) from e
|
|
355
|
+
|
|
356
|
+
except BPAClientError:
|
|
357
|
+
# Re-raise BPA errors without wrapping
|
|
358
|
+
raise
|
|
359
|
+
|
|
360
|
+
# Should not reach here, but just in case
|
|
361
|
+
if last_error:
|
|
362
|
+
raise BPAClientError(f"Request failed: {last_error}")
|
|
363
|
+
raise BPAClientError("Request failed for unknown reason")
|
|
364
|
+
|
|
365
|
+
async def get(
|
|
366
|
+
self,
|
|
367
|
+
endpoint: str,
|
|
368
|
+
*,
|
|
369
|
+
path_params: dict[str, Any] | None = None,
|
|
370
|
+
params: dict[str, Any] | None = None,
|
|
371
|
+
resource_type: str | None = None,
|
|
372
|
+
resource_id: str | int | None = None,
|
|
373
|
+
) -> dict[str, Any]:
|
|
374
|
+
"""Execute GET request.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
endpoint: URL endpoint template.
|
|
378
|
+
path_params: Path parameters for URL formatting.
|
|
379
|
+
params: Query parameters.
|
|
380
|
+
resource_type: Resource type for error context.
|
|
381
|
+
resource_id: Resource ID for error context.
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
JSON response as dictionary.
|
|
385
|
+
|
|
386
|
+
Raises:
|
|
387
|
+
BPAClientError: On API errors.
|
|
388
|
+
"""
|
|
389
|
+
response = await self._request_with_retry(
|
|
390
|
+
"GET",
|
|
391
|
+
endpoint,
|
|
392
|
+
path_params=path_params,
|
|
393
|
+
params=params,
|
|
394
|
+
resource_type=resource_type,
|
|
395
|
+
resource_id=resource_id,
|
|
396
|
+
)
|
|
397
|
+
# Handle empty response body
|
|
398
|
+
# (API returns 200 with no content for some missing resources)
|
|
399
|
+
if not response.content:
|
|
400
|
+
from mcp_eregistrations_bpa.bpa_client.errors import BPANotFoundError
|
|
401
|
+
|
|
402
|
+
raise BPANotFoundError(
|
|
403
|
+
"Resource not found (empty response)",
|
|
404
|
+
status_code=200,
|
|
405
|
+
)
|
|
406
|
+
return cast(dict[str, Any], response.json())
|
|
407
|
+
|
|
408
|
+
async def get_list(
|
|
409
|
+
self,
|
|
410
|
+
endpoint: str,
|
|
411
|
+
*,
|
|
412
|
+
path_params: dict[str, Any] | None = None,
|
|
413
|
+
params: dict[str, Any] | None = None,
|
|
414
|
+
resource_type: str | None = None,
|
|
415
|
+
) -> list[dict[str, Any]]:
|
|
416
|
+
"""Execute GET request expecting a list response.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
endpoint: URL endpoint template.
|
|
420
|
+
path_params: Path parameters for URL formatting.
|
|
421
|
+
params: Query parameters.
|
|
422
|
+
resource_type: Resource type for error context.
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
JSON response as list of dictionaries.
|
|
426
|
+
|
|
427
|
+
Raises:
|
|
428
|
+
BPAClientError: On API errors.
|
|
429
|
+
"""
|
|
430
|
+
response = await self._request_with_retry(
|
|
431
|
+
"GET",
|
|
432
|
+
endpoint,
|
|
433
|
+
path_params=path_params,
|
|
434
|
+
params=params,
|
|
435
|
+
resource_type=resource_type,
|
|
436
|
+
)
|
|
437
|
+
# Handle empty response body - return empty list
|
|
438
|
+
if not response.content:
|
|
439
|
+
return []
|
|
440
|
+
result = response.json()
|
|
441
|
+
if isinstance(result, list):
|
|
442
|
+
return cast(list[dict[str, Any]], result)
|
|
443
|
+
# Some APIs wrap lists in an object
|
|
444
|
+
if isinstance(result, dict) and "items" in result:
|
|
445
|
+
return cast(list[dict[str, Any]], result["items"])
|
|
446
|
+
return [cast(dict[str, Any], result)] if result else []
|
|
447
|
+
|
|
448
|
+
async def post(
|
|
449
|
+
self,
|
|
450
|
+
endpoint: str,
|
|
451
|
+
*,
|
|
452
|
+
path_params: dict[str, Any] | None = None,
|
|
453
|
+
params: dict[str, Any] | None = None,
|
|
454
|
+
json: dict[str, Any] | str | None = None,
|
|
455
|
+
content: str | bytes | None = None,
|
|
456
|
+
resource_type: str | None = None,
|
|
457
|
+
) -> dict[str, Any]:
|
|
458
|
+
"""Execute POST request.
|
|
459
|
+
|
|
460
|
+
Args:
|
|
461
|
+
endpoint: URL endpoint template.
|
|
462
|
+
path_params: Path parameters for URL formatting.
|
|
463
|
+
params: Query parameters.
|
|
464
|
+
json: JSON body (will be serialized).
|
|
465
|
+
content: Raw body content (not serialized).
|
|
466
|
+
resource_type: Resource type for error context.
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
JSON response as dictionary.
|
|
470
|
+
|
|
471
|
+
Raises:
|
|
472
|
+
BPAClientError: On API errors.
|
|
473
|
+
"""
|
|
474
|
+
response = await self._request_with_retry(
|
|
475
|
+
"POST",
|
|
476
|
+
endpoint,
|
|
477
|
+
path_params=path_params,
|
|
478
|
+
params=params,
|
|
479
|
+
json=json,
|
|
480
|
+
content=content,
|
|
481
|
+
resource_type=resource_type,
|
|
482
|
+
)
|
|
483
|
+
# Handle empty response body
|
|
484
|
+
if not response.content:
|
|
485
|
+
return {}
|
|
486
|
+
return cast(dict[str, Any], response.json())
|
|
487
|
+
|
|
488
|
+
async def put(
|
|
489
|
+
self,
|
|
490
|
+
endpoint: str,
|
|
491
|
+
*,
|
|
492
|
+
path_params: dict[str, Any] | None = None,
|
|
493
|
+
params: dict[str, Any] | None = None,
|
|
494
|
+
json: dict[str, Any] | None = None,
|
|
495
|
+
resource_type: str | None = None,
|
|
496
|
+
resource_id: str | int | None = None,
|
|
497
|
+
) -> dict[str, Any]:
|
|
498
|
+
"""Execute PUT request.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
endpoint: URL endpoint template.
|
|
502
|
+
path_params: Path parameters for URL formatting.
|
|
503
|
+
params: Query parameters.
|
|
504
|
+
json: JSON body.
|
|
505
|
+
resource_type: Resource type for error context.
|
|
506
|
+
resource_id: Resource ID for error context.
|
|
507
|
+
|
|
508
|
+
Returns:
|
|
509
|
+
JSON response as dictionary.
|
|
510
|
+
|
|
511
|
+
Raises:
|
|
512
|
+
BPAClientError: On API errors.
|
|
513
|
+
"""
|
|
514
|
+
response = await self._request_with_retry(
|
|
515
|
+
"PUT",
|
|
516
|
+
endpoint,
|
|
517
|
+
path_params=path_params,
|
|
518
|
+
params=params,
|
|
519
|
+
json=json,
|
|
520
|
+
resource_type=resource_type,
|
|
521
|
+
resource_id=resource_id,
|
|
522
|
+
)
|
|
523
|
+
# Handle empty response body
|
|
524
|
+
if not response.content:
|
|
525
|
+
return {}
|
|
526
|
+
return cast(dict[str, Any], response.json())
|
|
527
|
+
|
|
528
|
+
async def delete(
|
|
529
|
+
self,
|
|
530
|
+
endpoint: str,
|
|
531
|
+
*,
|
|
532
|
+
path_params: dict[str, Any] | None = None,
|
|
533
|
+
params: dict[str, Any] | None = None,
|
|
534
|
+
resource_type: str | None = None,
|
|
535
|
+
resource_id: str | int | None = None,
|
|
536
|
+
) -> dict[str, Any] | None:
|
|
537
|
+
"""Execute DELETE request.
|
|
538
|
+
|
|
539
|
+
Args:
|
|
540
|
+
endpoint: URL endpoint template.
|
|
541
|
+
path_params: Path parameters for URL formatting.
|
|
542
|
+
params: Query parameters.
|
|
543
|
+
resource_type: Resource type for error context.
|
|
544
|
+
resource_id: Resource ID for error context.
|
|
545
|
+
|
|
546
|
+
Returns:
|
|
547
|
+
JSON response as dictionary, or None if no content.
|
|
548
|
+
|
|
549
|
+
Raises:
|
|
550
|
+
BPAClientError: On API errors.
|
|
551
|
+
"""
|
|
552
|
+
response = await self._request_with_retry(
|
|
553
|
+
"DELETE",
|
|
554
|
+
endpoint,
|
|
555
|
+
path_params=path_params,
|
|
556
|
+
params=params,
|
|
557
|
+
resource_type=resource_type,
|
|
558
|
+
resource_id=resource_id,
|
|
559
|
+
)
|
|
560
|
+
# Handle empty response (204 No Content or empty body)
|
|
561
|
+
if response.status_code == 204 or not response.content:
|
|
562
|
+
return None
|
|
563
|
+
return cast(dict[str, Any], response.json())
|
|
564
|
+
|
|
565
|
+
async def download_service(
|
|
566
|
+
self,
|
|
567
|
+
service_id: str,
|
|
568
|
+
*,
|
|
569
|
+
options: dict[str, Any] | None = None,
|
|
570
|
+
timeout: float | None = None,
|
|
571
|
+
) -> tuple[dict[str, Any], int]:
|
|
572
|
+
"""Download complete service definition.
|
|
573
|
+
|
|
574
|
+
This endpoint can return large payloads (5-15MB) and may take
|
|
575
|
+
longer than the default timeout. Uses retry logic for transient errors.
|
|
576
|
+
|
|
577
|
+
Args:
|
|
578
|
+
service_id: The BPA service UUID.
|
|
579
|
+
options: Export selection options. Defaults to all-inclusive.
|
|
580
|
+
timeout: Custom timeout in seconds (default: 120s for exports).
|
|
581
|
+
|
|
582
|
+
Returns:
|
|
583
|
+
Tuple of (export_data, size_bytes) where:
|
|
584
|
+
- export_data: The complete service definition JSON
|
|
585
|
+
- size_bytes: Size of the response in bytes
|
|
586
|
+
|
|
587
|
+
Raises:
|
|
588
|
+
BPAClientError: On API errors.
|
|
589
|
+
"""
|
|
590
|
+
# Default options: include all components
|
|
591
|
+
default_options = {
|
|
592
|
+
"serviceSelected": True,
|
|
593
|
+
"costsSelected": True,
|
|
594
|
+
"requirementsSelected": True,
|
|
595
|
+
"resultsSelected": True,
|
|
596
|
+
"activityConditionsSelected": True,
|
|
597
|
+
"registrationLawsSelected": True,
|
|
598
|
+
"serviceLocationsSelected": True,
|
|
599
|
+
"serviceTutorialsSelected": True,
|
|
600
|
+
"serviceTranslationsSelected": True,
|
|
601
|
+
"guideFormSelected": True,
|
|
602
|
+
"applicantFormSelected": True,
|
|
603
|
+
"sendFileFormSelected": True,
|
|
604
|
+
"paymentFormSelected": True,
|
|
605
|
+
"catalogsSelected": True,
|
|
606
|
+
"rolesSelected": True,
|
|
607
|
+
"registrationsSelected": True,
|
|
608
|
+
"determinantsSelected": True,
|
|
609
|
+
"printDocumentsSelected": True,
|
|
610
|
+
"botsSelected": True,
|
|
611
|
+
"copyService": False,
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
# Merge with provided options
|
|
615
|
+
export_options = {**default_options, **(options or {})}
|
|
616
|
+
|
|
617
|
+
# Use longer timeout for exports (default 120s)
|
|
618
|
+
export_timeout = timeout or 120.0
|
|
619
|
+
|
|
620
|
+
client = self._get_client()
|
|
621
|
+
url = f"/download_service/{service_id}"
|
|
622
|
+
headers = await self._get_auth_header()
|
|
623
|
+
|
|
624
|
+
logger.info("BPA Export Request: POST %s%s", self.base_url, url)
|
|
625
|
+
|
|
626
|
+
last_error: Exception | None = None
|
|
627
|
+
|
|
628
|
+
for attempt in range(self.max_retries + 1):
|
|
629
|
+
try:
|
|
630
|
+
response = await client.post(
|
|
631
|
+
url,
|
|
632
|
+
headers=headers,
|
|
633
|
+
json=export_options,
|
|
634
|
+
timeout=httpx.Timeout(export_timeout),
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
if response.status_code >= 400:
|
|
638
|
+
# Don't retry client errors
|
|
639
|
+
if response.status_code in NON_RETRYABLE_STATUS_CODES:
|
|
640
|
+
error = httpx.HTTPStatusError(
|
|
641
|
+
f"HTTP {response.status_code}",
|
|
642
|
+
request=response.request,
|
|
643
|
+
response=response,
|
|
644
|
+
)
|
|
645
|
+
raise translate_http_error(
|
|
646
|
+
error,
|
|
647
|
+
resource_type="service",
|
|
648
|
+
resource_id=service_id,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
# Check if retryable
|
|
652
|
+
if response.status_code in RETRYABLE_STATUS_CODES:
|
|
653
|
+
if attempt < self.max_retries:
|
|
654
|
+
delay = calculate_backoff_delay(attempt)
|
|
655
|
+
logger.warning(
|
|
656
|
+
"Retryable error %d on export (attempt %d/%d), "
|
|
657
|
+
"retrying in %.2fs",
|
|
658
|
+
response.status_code,
|
|
659
|
+
attempt + 1,
|
|
660
|
+
self.max_retries + 1,
|
|
661
|
+
delay,
|
|
662
|
+
)
|
|
663
|
+
await asyncio.sleep(delay)
|
|
664
|
+
continue
|
|
665
|
+
|
|
666
|
+
# Non-retryable or out of retries
|
|
667
|
+
error = httpx.HTTPStatusError(
|
|
668
|
+
f"HTTP {response.status_code}",
|
|
669
|
+
request=response.request,
|
|
670
|
+
response=response,
|
|
671
|
+
)
|
|
672
|
+
raise translate_http_error(
|
|
673
|
+
error,
|
|
674
|
+
resource_type="service",
|
|
675
|
+
resource_id=service_id,
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
# Get response size
|
|
679
|
+
size_bytes = len(response.content)
|
|
680
|
+
logger.info(
|
|
681
|
+
"BPA Export Response: POST %s → %d %s (%.2f MB)",
|
|
682
|
+
url,
|
|
683
|
+
response.status_code,
|
|
684
|
+
response.reason_phrase,
|
|
685
|
+
size_bytes / (1024 * 1024),
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
# Parse JSON with error handling
|
|
689
|
+
try:
|
|
690
|
+
export_data = response.json()
|
|
691
|
+
except ValueError as e:
|
|
692
|
+
raise BPAClientError(
|
|
693
|
+
f"Failed to parse export response as JSON: {e}"
|
|
694
|
+
) from e
|
|
695
|
+
|
|
696
|
+
return cast(dict[str, Any], export_data), size_bytes
|
|
697
|
+
|
|
698
|
+
except httpx.ConnectError as e:
|
|
699
|
+
last_error = e
|
|
700
|
+
if attempt < self.max_retries:
|
|
701
|
+
delay = calculate_backoff_delay(attempt)
|
|
702
|
+
logger.warning(
|
|
703
|
+
"Connection error on export (attempt %d/%d), "
|
|
704
|
+
"retrying in %.2fs: %s",
|
|
705
|
+
attempt + 1,
|
|
706
|
+
self.max_retries + 1,
|
|
707
|
+
delay,
|
|
708
|
+
e,
|
|
709
|
+
)
|
|
710
|
+
await asyncio.sleep(delay)
|
|
711
|
+
continue
|
|
712
|
+
raise BPAConnectionError(
|
|
713
|
+
f"Failed to connect to BPA API after {self.max_retries + 1} "
|
|
714
|
+
f"attempts: {e}"
|
|
715
|
+
) from e
|
|
716
|
+
|
|
717
|
+
except httpx.TimeoutException as e:
|
|
718
|
+
last_error = e
|
|
719
|
+
if attempt < self.max_retries:
|
|
720
|
+
delay = calculate_backoff_delay(attempt)
|
|
721
|
+
logger.warning(
|
|
722
|
+
"Timeout on export (attempt %d/%d), retrying in %.2fs",
|
|
723
|
+
attempt + 1,
|
|
724
|
+
self.max_retries + 1,
|
|
725
|
+
delay,
|
|
726
|
+
)
|
|
727
|
+
await asyncio.sleep(delay)
|
|
728
|
+
continue
|
|
729
|
+
raise BPATimeoutError(
|
|
730
|
+
f"Export request timed out after {self.max_retries + 1} attempts"
|
|
731
|
+
) from e
|
|
732
|
+
|
|
733
|
+
except BPAClientError:
|
|
734
|
+
# Re-raise BPA errors without wrapping
|
|
735
|
+
raise
|
|
736
|
+
|
|
737
|
+
# Should not reach here, but just in case
|
|
738
|
+
if last_error:
|
|
739
|
+
raise BPAClientError(f"Export failed: {last_error}")
|
|
740
|
+
raise BPAClientError("Export failed for unknown reason")
|