miso-client 0.2.0__py3-none-any.whl → 0.5.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.
Potentially problematic release.
This version of miso-client might be problematic. Click here for more details.
- miso_client/__init__.py +59 -3
- miso_client/errors.py +22 -1
- miso_client/models/__init__.py +4 -0
- miso_client/models/error_response.py +50 -0
- miso_client/models/filter.py +140 -0
- miso_client/models/pagination.py +66 -0
- miso_client/models/sort.py +25 -0
- miso_client/services/logger.py +7 -6
- miso_client/utils/data_masker.py +77 -5
- miso_client/utils/error_utils.py +104 -0
- miso_client/utils/filter.py +256 -0
- miso_client/utils/http_client.py +517 -212
- miso_client/utils/internal_http_client.py +471 -0
- miso_client/utils/pagination.py +157 -0
- miso_client/utils/sensitive_fields_loader.py +116 -0
- miso_client/utils/sort.py +116 -0
- {miso_client-0.2.0.dist-info → miso_client-0.5.0.dist-info}/METADATA +348 -3
- miso_client-0.5.0.dist-info/RECORD +33 -0
- miso_client-0.2.0.dist-info/RECORD +0 -23
- {miso_client-0.2.0.dist-info → miso_client-0.5.0.dist-info}/WHEEL +0 -0
- {miso_client-0.2.0.dist-info → miso_client-0.5.0.dist-info}/licenses/LICENSE +0 -0
- {miso_client-0.2.0.dist-info → miso_client-0.5.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Internal HTTP client utility for controller communication.
|
|
3
|
+
|
|
4
|
+
This module provides the internal HTTP client implementation with automatic client
|
|
5
|
+
token management. This class is not meant to be used directly - use the public
|
|
6
|
+
HttpClient class instead which adds ISO 27001 compliant audit and debug logging.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
from typing import Any, Dict, Literal, Optional
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from ..errors import AuthenticationError, ConnectionError, MisoClientError
|
|
16
|
+
from ..models.config import ClientTokenResponse, MisoClientConfig
|
|
17
|
+
from ..models.error_response import ErrorResponse
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InternalHttpClient:
|
|
21
|
+
"""
|
|
22
|
+
Internal HTTP client for Miso Controller communication with automatic client token management.
|
|
23
|
+
|
|
24
|
+
This class contains the core HTTP functionality without logging.
|
|
25
|
+
It is wrapped by the public HttpClient class which adds audit and debug logging.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, config: MisoClientConfig):
|
|
29
|
+
"""
|
|
30
|
+
Initialize internal HTTP client with configuration.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
config: MisoClient configuration
|
|
34
|
+
"""
|
|
35
|
+
self.config = config
|
|
36
|
+
self.client: Optional[httpx.AsyncClient] = None
|
|
37
|
+
self.client_token: Optional[str] = None
|
|
38
|
+
self.token_expires_at: Optional[datetime] = None
|
|
39
|
+
self.token_refresh_lock = asyncio.Lock()
|
|
40
|
+
|
|
41
|
+
async def _initialize_client(self):
|
|
42
|
+
"""Initialize HTTP client if not already initialized."""
|
|
43
|
+
if self.client is None:
|
|
44
|
+
self.client = httpx.AsyncClient(
|
|
45
|
+
base_url=self.config.controller_url,
|
|
46
|
+
timeout=30.0,
|
|
47
|
+
headers={
|
|
48
|
+
"Content-Type": "application/json",
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
async def _get_client_token(self) -> str:
|
|
53
|
+
"""
|
|
54
|
+
Get client token, fetching if needed.
|
|
55
|
+
|
|
56
|
+
Proactively refreshes if token will expire within 60 seconds.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Client token string
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
AuthenticationError: If token fetch fails
|
|
63
|
+
"""
|
|
64
|
+
await self._initialize_client()
|
|
65
|
+
|
|
66
|
+
now = datetime.now()
|
|
67
|
+
|
|
68
|
+
# If token exists and not expired (with 60s buffer for proactive refresh), return it
|
|
69
|
+
if (
|
|
70
|
+
self.client_token
|
|
71
|
+
and self.token_expires_at
|
|
72
|
+
and self.token_expires_at > now + timedelta(seconds=60)
|
|
73
|
+
):
|
|
74
|
+
assert self.client_token is not None
|
|
75
|
+
return self.client_token
|
|
76
|
+
|
|
77
|
+
# Acquire lock to prevent concurrent token fetches
|
|
78
|
+
async with self.token_refresh_lock:
|
|
79
|
+
# Double-check after acquiring lock
|
|
80
|
+
if (
|
|
81
|
+
self.client_token
|
|
82
|
+
and self.token_expires_at
|
|
83
|
+
and self.token_expires_at > now + timedelta(seconds=60)
|
|
84
|
+
):
|
|
85
|
+
assert self.client_token is not None
|
|
86
|
+
return self.client_token
|
|
87
|
+
|
|
88
|
+
# Fetch new token
|
|
89
|
+
await self._fetch_client_token()
|
|
90
|
+
assert self.client_token is not None
|
|
91
|
+
return self.client_token
|
|
92
|
+
|
|
93
|
+
async def _fetch_client_token(self) -> None:
|
|
94
|
+
"""
|
|
95
|
+
Fetch client token from controller.
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
AuthenticationError: If token fetch fails
|
|
99
|
+
"""
|
|
100
|
+
await self._initialize_client()
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
# Use a temporary client to avoid interceptor recursion
|
|
104
|
+
temp_client = httpx.AsyncClient(
|
|
105
|
+
base_url=self.config.controller_url,
|
|
106
|
+
timeout=30.0,
|
|
107
|
+
headers={
|
|
108
|
+
"Content-Type": "application/json",
|
|
109
|
+
"x-client-id": self.config.client_id,
|
|
110
|
+
"x-client-secret": self.config.client_secret,
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
response = await temp_client.post("/api/auth/token")
|
|
115
|
+
await temp_client.aclose()
|
|
116
|
+
|
|
117
|
+
if response.status_code != 200:
|
|
118
|
+
raise AuthenticationError(
|
|
119
|
+
f"Failed to get client token: HTTP {response.status_code}",
|
|
120
|
+
status_code=response.status_code,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
data = response.json()
|
|
124
|
+
token_response = ClientTokenResponse(**data)
|
|
125
|
+
|
|
126
|
+
if not token_response.success or not token_response.token:
|
|
127
|
+
raise AuthenticationError("Failed to get client token: Invalid response")
|
|
128
|
+
|
|
129
|
+
self.client_token = token_response.token
|
|
130
|
+
# Set expiration with 30 second buffer before actual expiration
|
|
131
|
+
expires_in = max(0, token_response.expiresIn - 30)
|
|
132
|
+
self.token_expires_at = datetime.now() + timedelta(seconds=expires_in)
|
|
133
|
+
|
|
134
|
+
except httpx.HTTPError as e:
|
|
135
|
+
raise ConnectionError(f"Failed to get client token: {str(e)}")
|
|
136
|
+
except Exception as e:
|
|
137
|
+
if isinstance(e, (AuthenticationError, ConnectionError)):
|
|
138
|
+
raise
|
|
139
|
+
raise AuthenticationError(f"Failed to get client token: {str(e)}")
|
|
140
|
+
|
|
141
|
+
async def _ensure_client_token(self):
|
|
142
|
+
"""Ensure client token is set in headers."""
|
|
143
|
+
token = await self._get_client_token()
|
|
144
|
+
if self.client:
|
|
145
|
+
self.client.headers["x-client-token"] = token
|
|
146
|
+
|
|
147
|
+
def _parse_error_response(self, response: httpx.Response, url: str) -> Optional[ErrorResponse]:
|
|
148
|
+
"""
|
|
149
|
+
Parse structured error response from HTTP response.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
response: HTTP response object
|
|
153
|
+
url: Request URL (used for instance URI if not in response)
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
ErrorResponse if response matches structure, None otherwise
|
|
157
|
+
"""
|
|
158
|
+
if not response.headers.get("content-type", "").startswith("application/json"):
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
response_data = response.json()
|
|
163
|
+
# Check if response matches ErrorResponse structure
|
|
164
|
+
if (
|
|
165
|
+
isinstance(response_data, dict)
|
|
166
|
+
and "errors" in response_data
|
|
167
|
+
and "type" in response_data
|
|
168
|
+
and "title" in response_data
|
|
169
|
+
and "statusCode" in response_data
|
|
170
|
+
):
|
|
171
|
+
# Set instance from URL if not provided
|
|
172
|
+
if "instance" not in response_data or not response_data["instance"]:
|
|
173
|
+
response_data["instance"] = url
|
|
174
|
+
return ErrorResponse(**response_data)
|
|
175
|
+
except (ValueError, TypeError, KeyError):
|
|
176
|
+
# JSON parsing failed or structure doesn't match
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
async def close(self):
|
|
182
|
+
"""Close the HTTP client."""
|
|
183
|
+
if self.client:
|
|
184
|
+
await self.client.aclose()
|
|
185
|
+
self.client = None
|
|
186
|
+
|
|
187
|
+
async def __aenter__(self):
|
|
188
|
+
"""Async context manager entry."""
|
|
189
|
+
return self
|
|
190
|
+
|
|
191
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
192
|
+
"""Async context manager exit."""
|
|
193
|
+
await self.close()
|
|
194
|
+
|
|
195
|
+
async def get(self, url: str, **kwargs) -> Any:
|
|
196
|
+
"""
|
|
197
|
+
Make GET request.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
url: Request URL
|
|
201
|
+
**kwargs: Additional httpx request parameters
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Response data (JSON parsed)
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
MisoClientError: If request fails
|
|
208
|
+
"""
|
|
209
|
+
await self._initialize_client()
|
|
210
|
+
await self._ensure_client_token()
|
|
211
|
+
try:
|
|
212
|
+
assert self.client is not None
|
|
213
|
+
response = await self.client.get(url, **kwargs)
|
|
214
|
+
|
|
215
|
+
# Handle 401 - clear token to force refresh
|
|
216
|
+
if response.status_code == 401:
|
|
217
|
+
self.client_token = None
|
|
218
|
+
self.token_expires_at = None
|
|
219
|
+
|
|
220
|
+
response.raise_for_status()
|
|
221
|
+
return response.json()
|
|
222
|
+
except httpx.HTTPStatusError as e:
|
|
223
|
+
# Try to parse structured error response
|
|
224
|
+
error_response = self._parse_error_response(e.response, url)
|
|
225
|
+
error_body = {}
|
|
226
|
+
if (
|
|
227
|
+
e.response.headers.get("content-type", "").startswith("application/json")
|
|
228
|
+
and not error_response
|
|
229
|
+
):
|
|
230
|
+
try:
|
|
231
|
+
error_body = e.response.json()
|
|
232
|
+
except (ValueError, TypeError):
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
raise MisoClientError(
|
|
236
|
+
f"HTTP {e.response.status_code}: {e.response.text}",
|
|
237
|
+
status_code=e.response.status_code,
|
|
238
|
+
error_body=error_body,
|
|
239
|
+
error_response=error_response,
|
|
240
|
+
)
|
|
241
|
+
except httpx.RequestError as e:
|
|
242
|
+
raise ConnectionError(f"Request failed: {str(e)}")
|
|
243
|
+
|
|
244
|
+
async def post(self, url: str, data: Optional[Dict[str, Any]] = None, **kwargs) -> Any:
|
|
245
|
+
"""
|
|
246
|
+
Make POST request.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
url: Request URL
|
|
250
|
+
data: Request data (will be JSON encoded)
|
|
251
|
+
**kwargs: Additional httpx request parameters
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
Response data (JSON parsed)
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
MisoClientError: If request fails
|
|
258
|
+
"""
|
|
259
|
+
await self._initialize_client()
|
|
260
|
+
await self._ensure_client_token()
|
|
261
|
+
try:
|
|
262
|
+
assert self.client is not None
|
|
263
|
+
response = await self.client.post(url, json=data, **kwargs)
|
|
264
|
+
|
|
265
|
+
if response.status_code == 401:
|
|
266
|
+
self.client_token = None
|
|
267
|
+
self.token_expires_at = None
|
|
268
|
+
|
|
269
|
+
response.raise_for_status()
|
|
270
|
+
return response.json()
|
|
271
|
+
except httpx.HTTPStatusError as e:
|
|
272
|
+
# Try to parse structured error response
|
|
273
|
+
error_response = self._parse_error_response(e.response, url)
|
|
274
|
+
error_body = {}
|
|
275
|
+
if (
|
|
276
|
+
e.response.headers.get("content-type", "").startswith("application/json")
|
|
277
|
+
and not error_response
|
|
278
|
+
):
|
|
279
|
+
try:
|
|
280
|
+
error_body = e.response.json()
|
|
281
|
+
except (ValueError, TypeError):
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
raise MisoClientError(
|
|
285
|
+
f"HTTP {e.response.status_code}: {e.response.text}",
|
|
286
|
+
status_code=e.response.status_code,
|
|
287
|
+
error_body=error_body,
|
|
288
|
+
error_response=error_response,
|
|
289
|
+
)
|
|
290
|
+
except httpx.RequestError as e:
|
|
291
|
+
raise ConnectionError(f"Request failed: {str(e)}")
|
|
292
|
+
|
|
293
|
+
async def put(self, url: str, data: Optional[Dict[str, Any]] = None, **kwargs) -> Any:
|
|
294
|
+
"""
|
|
295
|
+
Make PUT request.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
url: Request URL
|
|
299
|
+
data: Request data (will be JSON encoded)
|
|
300
|
+
**kwargs: Additional httpx request parameters
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Response data (JSON parsed)
|
|
304
|
+
|
|
305
|
+
Raises:
|
|
306
|
+
MisoClientError: If request fails
|
|
307
|
+
"""
|
|
308
|
+
await self._initialize_client()
|
|
309
|
+
await self._ensure_client_token()
|
|
310
|
+
try:
|
|
311
|
+
assert self.client is not None
|
|
312
|
+
response = await self.client.put(url, json=data, **kwargs)
|
|
313
|
+
|
|
314
|
+
if response.status_code == 401:
|
|
315
|
+
self.client_token = None
|
|
316
|
+
self.token_expires_at = None
|
|
317
|
+
|
|
318
|
+
response.raise_for_status()
|
|
319
|
+
return response.json()
|
|
320
|
+
except httpx.HTTPStatusError as e:
|
|
321
|
+
# Try to parse structured error response
|
|
322
|
+
error_response = self._parse_error_response(e.response, url)
|
|
323
|
+
error_body = {}
|
|
324
|
+
if (
|
|
325
|
+
e.response.headers.get("content-type", "").startswith("application/json")
|
|
326
|
+
and not error_response
|
|
327
|
+
):
|
|
328
|
+
try:
|
|
329
|
+
error_body = e.response.json()
|
|
330
|
+
except (ValueError, TypeError):
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
raise MisoClientError(
|
|
334
|
+
f"HTTP {e.response.status_code}: {e.response.text}",
|
|
335
|
+
status_code=e.response.status_code,
|
|
336
|
+
error_body=error_body,
|
|
337
|
+
error_response=error_response,
|
|
338
|
+
)
|
|
339
|
+
except httpx.RequestError as e:
|
|
340
|
+
raise ConnectionError(f"Request failed: {str(e)}")
|
|
341
|
+
|
|
342
|
+
async def delete(self, url: str, **kwargs) -> Any:
|
|
343
|
+
"""
|
|
344
|
+
Make DELETE request.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
url: Request URL
|
|
348
|
+
**kwargs: Additional httpx request parameters
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
Response data (JSON parsed)
|
|
352
|
+
|
|
353
|
+
Raises:
|
|
354
|
+
MisoClientError: If request fails
|
|
355
|
+
"""
|
|
356
|
+
await self._initialize_client()
|
|
357
|
+
await self._ensure_client_token()
|
|
358
|
+
try:
|
|
359
|
+
assert self.client is not None
|
|
360
|
+
response = await self.client.delete(url, **kwargs)
|
|
361
|
+
|
|
362
|
+
if response.status_code == 401:
|
|
363
|
+
self.client_token = None
|
|
364
|
+
self.token_expires_at = None
|
|
365
|
+
|
|
366
|
+
response.raise_for_status()
|
|
367
|
+
return response.json()
|
|
368
|
+
except httpx.HTTPStatusError as e:
|
|
369
|
+
# Try to parse structured error response
|
|
370
|
+
error_response = self._parse_error_response(e.response, url)
|
|
371
|
+
error_body = {}
|
|
372
|
+
if (
|
|
373
|
+
e.response.headers.get("content-type", "").startswith("application/json")
|
|
374
|
+
and not error_response
|
|
375
|
+
):
|
|
376
|
+
try:
|
|
377
|
+
error_body = e.response.json()
|
|
378
|
+
except (ValueError, TypeError):
|
|
379
|
+
pass
|
|
380
|
+
|
|
381
|
+
raise MisoClientError(
|
|
382
|
+
f"HTTP {e.response.status_code}: {e.response.text}",
|
|
383
|
+
status_code=e.response.status_code,
|
|
384
|
+
error_body=error_body,
|
|
385
|
+
error_response=error_response,
|
|
386
|
+
)
|
|
387
|
+
except httpx.RequestError as e:
|
|
388
|
+
raise ConnectionError(f"Request failed: {str(e)}")
|
|
389
|
+
|
|
390
|
+
async def request(
|
|
391
|
+
self,
|
|
392
|
+
method: Literal["GET", "POST", "PUT", "DELETE"],
|
|
393
|
+
url: str,
|
|
394
|
+
data: Optional[Dict[str, Any]] = None,
|
|
395
|
+
**kwargs,
|
|
396
|
+
) -> Any:
|
|
397
|
+
"""
|
|
398
|
+
Generic request method.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
method: HTTP method
|
|
402
|
+
url: Request URL
|
|
403
|
+
data: Request data (for POST/PUT)
|
|
404
|
+
**kwargs: Additional httpx request parameters
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
Response data (JSON parsed)
|
|
408
|
+
|
|
409
|
+
Raises:
|
|
410
|
+
MisoClientError: If request fails
|
|
411
|
+
"""
|
|
412
|
+
method_upper = method.upper()
|
|
413
|
+
if method_upper == "GET":
|
|
414
|
+
return await self.get(url, **kwargs)
|
|
415
|
+
elif method_upper == "POST":
|
|
416
|
+
return await self.post(url, data, **kwargs)
|
|
417
|
+
elif method_upper == "PUT":
|
|
418
|
+
return await self.put(url, data, **kwargs)
|
|
419
|
+
elif method_upper == "DELETE":
|
|
420
|
+
return await self.delete(url, **kwargs)
|
|
421
|
+
else:
|
|
422
|
+
raise ValueError(f"Unsupported HTTP method: {method}")
|
|
423
|
+
|
|
424
|
+
async def authenticated_request(
|
|
425
|
+
self,
|
|
426
|
+
method: Literal["GET", "POST", "PUT", "DELETE"],
|
|
427
|
+
url: str,
|
|
428
|
+
token: str,
|
|
429
|
+
data: Optional[Dict[str, Any]] = None,
|
|
430
|
+
**kwargs,
|
|
431
|
+
) -> Any:
|
|
432
|
+
"""
|
|
433
|
+
Make authenticated request with Bearer token.
|
|
434
|
+
|
|
435
|
+
IMPORTANT: Client token is sent as x-client-token header (via _ensure_client_token)
|
|
436
|
+
User token is sent as Authorization: Bearer header (this method parameter)
|
|
437
|
+
These are two separate tokens for different purposes.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
method: HTTP method
|
|
441
|
+
url: Request URL
|
|
442
|
+
token: User authentication token (sent as Bearer token)
|
|
443
|
+
data: Request data (for POST/PUT)
|
|
444
|
+
**kwargs: Additional httpx request parameters
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
Response data (JSON parsed)
|
|
448
|
+
|
|
449
|
+
Raises:
|
|
450
|
+
MisoClientError: If request fails
|
|
451
|
+
"""
|
|
452
|
+
await self._ensure_client_token()
|
|
453
|
+
|
|
454
|
+
# Add Bearer token for user authentication
|
|
455
|
+
# x-client-token is automatically added by _ensure_client_token
|
|
456
|
+
headers = kwargs.get("headers", {})
|
|
457
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
458
|
+
kwargs["headers"] = headers
|
|
459
|
+
|
|
460
|
+
return await self.request(method, url, data, **kwargs)
|
|
461
|
+
|
|
462
|
+
async def get_environment_token(self) -> str:
|
|
463
|
+
"""
|
|
464
|
+
Get environment token using client credentials.
|
|
465
|
+
|
|
466
|
+
This is called automatically by HttpClient but can be called manually.
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
Client token string
|
|
470
|
+
"""
|
|
471
|
+
return await self._get_client_token()
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pagination utilities for MisoClient SDK.
|
|
3
|
+
|
|
4
|
+
This module provides reusable pagination utilities for parsing pagination parameters,
|
|
5
|
+
creating meta objects, and working with paginated responses.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import List, Tuple, TypeVar
|
|
9
|
+
|
|
10
|
+
from ..models.pagination import Meta, PaginatedListResponse
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_pagination_params(params: dict) -> Tuple[int, int]:
|
|
16
|
+
"""
|
|
17
|
+
Parse query parameters to pagination values.
|
|
18
|
+
|
|
19
|
+
Parses `page` and `page_size` query parameters into `current_page` and `page_size`.
|
|
20
|
+
Both are 1-based (page starts at 1).
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
params: Dictionary with query parameters (e.g., {'page': '1', 'page_size': '25'})
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Tuple of (current_page, page_size) as integers
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
>>> parse_pagination_params({'page': '1', 'page_size': '25'})
|
|
30
|
+
(1, 25)
|
|
31
|
+
>>> parse_pagination_params({'page': '2'})
|
|
32
|
+
(2, 25) # Default page_size is 25
|
|
33
|
+
"""
|
|
34
|
+
# Default values
|
|
35
|
+
default_page = 1
|
|
36
|
+
default_page_size = 25
|
|
37
|
+
|
|
38
|
+
# Parse page (must be >= 1)
|
|
39
|
+
page_str = params.get("page") or params.get("current_page")
|
|
40
|
+
if page_str is None:
|
|
41
|
+
current_page = default_page
|
|
42
|
+
else:
|
|
43
|
+
try:
|
|
44
|
+
current_page = int(page_str)
|
|
45
|
+
if current_page < 1:
|
|
46
|
+
current_page = default_page
|
|
47
|
+
except (ValueError, TypeError):
|
|
48
|
+
current_page = default_page
|
|
49
|
+
|
|
50
|
+
# Parse page_size (must be >= 1)
|
|
51
|
+
page_size_str = params.get("page_size") or params.get("pageSize")
|
|
52
|
+
if page_size_str is None:
|
|
53
|
+
page_size = default_page_size
|
|
54
|
+
else:
|
|
55
|
+
try:
|
|
56
|
+
page_size = int(page_size_str)
|
|
57
|
+
if page_size < 1:
|
|
58
|
+
page_size = default_page_size
|
|
59
|
+
except (ValueError, TypeError):
|
|
60
|
+
page_size = default_page_size
|
|
61
|
+
|
|
62
|
+
return (current_page, page_size)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def create_meta_object(total_items: int, current_page: int, page_size: int, type: str) -> Meta:
|
|
66
|
+
"""
|
|
67
|
+
Construct Meta object from pagination parameters.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
total_items: Total number of items across all pages
|
|
71
|
+
current_page: Current page number (1-based)
|
|
72
|
+
page_size: Number of items per page
|
|
73
|
+
type: Resource type identifier (e.g., 'item', 'user', 'group')
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Meta object with pagination metadata
|
|
77
|
+
|
|
78
|
+
Examples:
|
|
79
|
+
>>> meta = create_meta_object(120, 1, 25, 'item')
|
|
80
|
+
>>> meta.total_items
|
|
81
|
+
120
|
|
82
|
+
>>> meta.current_page
|
|
83
|
+
1
|
|
84
|
+
"""
|
|
85
|
+
return Meta(
|
|
86
|
+
total_items=total_items,
|
|
87
|
+
current_page=current_page,
|
|
88
|
+
page_size=page_size,
|
|
89
|
+
type=type,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def apply_pagination_to_array(items: List[T], current_page: int, page_size: int) -> List[T]:
|
|
94
|
+
"""
|
|
95
|
+
Apply pagination to array (for testing/mocks).
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
items: Array of items to paginate
|
|
99
|
+
current_page: Current page number (1-based)
|
|
100
|
+
page_size: Number of items per page
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Paginated subset of items for the specified page
|
|
104
|
+
|
|
105
|
+
Examples:
|
|
106
|
+
>>> items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
|
107
|
+
>>> apply_pagination_to_array(items, 1, 3)
|
|
108
|
+
[1, 2, 3]
|
|
109
|
+
>>> apply_pagination_to_array(items, 2, 3)
|
|
110
|
+
[4, 5, 6]
|
|
111
|
+
"""
|
|
112
|
+
if not items:
|
|
113
|
+
return []
|
|
114
|
+
|
|
115
|
+
if current_page < 1:
|
|
116
|
+
current_page = 1
|
|
117
|
+
if page_size < 1:
|
|
118
|
+
page_size = 25
|
|
119
|
+
|
|
120
|
+
# Calculate start and end indices
|
|
121
|
+
start_index = (current_page - 1) * page_size
|
|
122
|
+
end_index = start_index + page_size
|
|
123
|
+
|
|
124
|
+
# Return paginated subset
|
|
125
|
+
return items[start_index:end_index]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def create_paginated_list_response(
|
|
129
|
+
items: List[T],
|
|
130
|
+
total_items: int,
|
|
131
|
+
current_page: int,
|
|
132
|
+
page_size: int,
|
|
133
|
+
type: str,
|
|
134
|
+
) -> PaginatedListResponse[T]:
|
|
135
|
+
"""
|
|
136
|
+
Wrap array + meta into standard paginated response.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
items: Array of items for current page
|
|
140
|
+
total_items: Total number of items across all pages
|
|
141
|
+
current_page: Current page number (1-based)
|
|
142
|
+
page_size: Number of items per page
|
|
143
|
+
type: Resource type identifier (e.g., 'item', 'user', 'group')
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
PaginatedListResponse with meta and data
|
|
147
|
+
|
|
148
|
+
Examples:
|
|
149
|
+
>>> items = [{'id': 1}, {'id': 2}]
|
|
150
|
+
>>> response = create_paginated_list_response(items, 10, 1, 2, 'item')
|
|
151
|
+
>>> response.meta.total_items
|
|
152
|
+
10
|
|
153
|
+
>>> len(response.data)
|
|
154
|
+
2
|
|
155
|
+
"""
|
|
156
|
+
meta = create_meta_object(total_items, current_page, page_size, type)
|
|
157
|
+
return PaginatedListResponse(meta=meta, data=items)
|