strapi-kit 0.0.1__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.
- strapi_kit/__init__.py +97 -0
- strapi_kit/__version__.py +15 -0
- strapi_kit/_version.py +34 -0
- strapi_kit/auth/__init__.py +7 -0
- strapi_kit/auth/api_token.py +48 -0
- strapi_kit/cache/__init__.py +5 -0
- strapi_kit/cache/schema_cache.py +211 -0
- strapi_kit/client/__init__.py +11 -0
- strapi_kit/client/async_client.py +1032 -0
- strapi_kit/client/base.py +460 -0
- strapi_kit/client/sync_client.py +980 -0
- strapi_kit/config_provider.py +368 -0
- strapi_kit/exceptions/__init__.py +37 -0
- strapi_kit/exceptions/errors.py +205 -0
- strapi_kit/export/__init__.py +10 -0
- strapi_kit/export/exporter.py +384 -0
- strapi_kit/export/importer.py +619 -0
- strapi_kit/export/media_handler.py +322 -0
- strapi_kit/export/relation_resolver.py +172 -0
- strapi_kit/models/__init__.py +104 -0
- strapi_kit/models/bulk.py +69 -0
- strapi_kit/models/config.py +174 -0
- strapi_kit/models/enums.py +97 -0
- strapi_kit/models/export_format.py +166 -0
- strapi_kit/models/import_options.py +142 -0
- strapi_kit/models/request/__init__.py +1 -0
- strapi_kit/models/request/fields.py +65 -0
- strapi_kit/models/request/filters.py +611 -0
- strapi_kit/models/request/pagination.py +168 -0
- strapi_kit/models/request/populate.py +281 -0
- strapi_kit/models/request/query.py +429 -0
- strapi_kit/models/request/sort.py +147 -0
- strapi_kit/models/response/__init__.py +1 -0
- strapi_kit/models/response/base.py +75 -0
- strapi_kit/models/response/component.py +67 -0
- strapi_kit/models/response/media.py +91 -0
- strapi_kit/models/response/meta.py +44 -0
- strapi_kit/models/response/normalized.py +168 -0
- strapi_kit/models/response/relation.py +48 -0
- strapi_kit/models/response/v4.py +70 -0
- strapi_kit/models/response/v5.py +57 -0
- strapi_kit/models/schema.py +93 -0
- strapi_kit/operations/__init__.py +16 -0
- strapi_kit/operations/media.py +226 -0
- strapi_kit/operations/streaming.py +144 -0
- strapi_kit/parsers/__init__.py +5 -0
- strapi_kit/parsers/version_detecting.py +171 -0
- strapi_kit/protocols.py +455 -0
- strapi_kit/utils/__init__.py +15 -0
- strapi_kit/utils/rate_limiter.py +201 -0
- strapi_kit/utils/uid.py +88 -0
- strapi_kit-0.0.1.dist-info/METADATA +1098 -0
- strapi_kit-0.0.1.dist-info/RECORD +55 -0
- strapi_kit-0.0.1.dist-info/WHEEL +4 -0
- strapi_kit-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
"""Base HTTP client for Strapi API communication.
|
|
2
|
+
|
|
3
|
+
This module provides the foundation for all HTTP operations with
|
|
4
|
+
automatic response format detection, error handling, and authentication.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from tenacity import (
|
|
12
|
+
before_sleep_log,
|
|
13
|
+
retry,
|
|
14
|
+
retry_if_exception,
|
|
15
|
+
stop_after_attempt,
|
|
16
|
+
wait_exponential,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from ..auth.api_token import APITokenAuth
|
|
20
|
+
from ..exceptions import (
|
|
21
|
+
AuthenticationError,
|
|
22
|
+
AuthorizationError,
|
|
23
|
+
ConflictError,
|
|
24
|
+
NotFoundError,
|
|
25
|
+
RateLimitError,
|
|
26
|
+
ServerError,
|
|
27
|
+
StrapiError,
|
|
28
|
+
ValidationError,
|
|
29
|
+
)
|
|
30
|
+
from ..exceptions import (
|
|
31
|
+
ConnectionError as StrapiConnectionError,
|
|
32
|
+
)
|
|
33
|
+
from ..models.response.media import MediaFile
|
|
34
|
+
from ..models.response.normalized import (
|
|
35
|
+
NormalizedCollectionResponse,
|
|
36
|
+
NormalizedSingleResponse,
|
|
37
|
+
)
|
|
38
|
+
from ..operations.media import normalize_media_response
|
|
39
|
+
from ..parsers import VersionDetectingParser
|
|
40
|
+
from ..protocols import AuthProvider, ConfigProvider, ResponseParser
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BaseClient:
|
|
46
|
+
"""Base HTTP client for Strapi API operations.
|
|
47
|
+
|
|
48
|
+
This class provides the foundation for both synchronous and asynchronous
|
|
49
|
+
clients with:
|
|
50
|
+
- Authentication via API tokens
|
|
51
|
+
- Automatic Strapi version detection (v4 vs v5)
|
|
52
|
+
- Error handling and exception mapping
|
|
53
|
+
- Request/response logging
|
|
54
|
+
- Connection pooling
|
|
55
|
+
|
|
56
|
+
Not intended to be used directly - use SyncClient or AsyncClient instead.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
config: ConfigProvider,
|
|
62
|
+
auth: AuthProvider | None = None,
|
|
63
|
+
parser: ResponseParser | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Initialize the base client with dependency injection.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
config: Configuration provider (typically StrapiConfig)
|
|
69
|
+
auth: Authentication provider (defaults to APITokenAuth)
|
|
70
|
+
parser: Response parser (defaults to VersionDetectingParser)
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
ValueError: If authentication token is invalid
|
|
74
|
+
"""
|
|
75
|
+
self.config: ConfigProvider = config
|
|
76
|
+
self.base_url = config.get_base_url()
|
|
77
|
+
|
|
78
|
+
# Dependency injection with sensible defaults
|
|
79
|
+
self.auth: AuthProvider = auth or APITokenAuth(config.get_api_token())
|
|
80
|
+
self.parser: ResponseParser = parser or VersionDetectingParser(
|
|
81
|
+
default_version=None if config.api_version == "auto" else config.api_version
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Validate authentication
|
|
85
|
+
if not self.auth.validate_token():
|
|
86
|
+
raise ValueError("API token is required and cannot be empty")
|
|
87
|
+
|
|
88
|
+
# API version detection (for backward compatibility)
|
|
89
|
+
self._api_version: Literal["v4", "v5"] | None = (
|
|
90
|
+
None if config.api_version == "auto" else config.api_version
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
logger.info(
|
|
94
|
+
f"Initialized Strapi client for {self.base_url} (version: {config.api_version})"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def _get_headers(self, extra_headers: dict[str, str] | None = None) -> dict[str, str]:
|
|
98
|
+
"""Build request headers with authentication.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
extra_headers: Additional headers to include
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Complete headers dictionary
|
|
105
|
+
"""
|
|
106
|
+
headers = {
|
|
107
|
+
"Content-Type": "application/json",
|
|
108
|
+
"Accept": "application/json",
|
|
109
|
+
**self.auth.get_headers(),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if extra_headers:
|
|
113
|
+
headers.update(extra_headers)
|
|
114
|
+
|
|
115
|
+
return headers
|
|
116
|
+
|
|
117
|
+
def _build_url(self, endpoint: str) -> str:
|
|
118
|
+
"""Build full URL for an endpoint.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
endpoint: API endpoint path (e.g., "articles" or "/api/articles")
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Complete URL
|
|
125
|
+
"""
|
|
126
|
+
# Remove leading and trailing slashes from endpoint
|
|
127
|
+
endpoint = endpoint.strip("/")
|
|
128
|
+
|
|
129
|
+
# Ensure /api prefix for content endpoints
|
|
130
|
+
if not endpoint.startswith("api/"):
|
|
131
|
+
endpoint = f"api/{endpoint}"
|
|
132
|
+
|
|
133
|
+
return f"{self.base_url}/{endpoint}"
|
|
134
|
+
|
|
135
|
+
def _detect_api_version(self, response_data: dict[str, Any]) -> Literal["v4", "v5"]:
|
|
136
|
+
"""Detect Strapi API version from response structure.
|
|
137
|
+
|
|
138
|
+
Only caches the version when detection is definitive (attributes or documentId found).
|
|
139
|
+
Ambiguous responses return v4 as fallback without caching.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
response_data: Response JSON data
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Detected API version
|
|
146
|
+
"""
|
|
147
|
+
# If already detected or configured, use that
|
|
148
|
+
if self._api_version:
|
|
149
|
+
return self._api_version
|
|
150
|
+
|
|
151
|
+
# V4: data.attributes structure
|
|
152
|
+
# V5: flattened data with documentId
|
|
153
|
+
if isinstance(response_data.get("data"), dict):
|
|
154
|
+
data = response_data["data"]
|
|
155
|
+
if "attributes" in data:
|
|
156
|
+
self._api_version = "v4"
|
|
157
|
+
logger.info("Detected Strapi v4 API format")
|
|
158
|
+
return self._api_version
|
|
159
|
+
elif "documentId" in data:
|
|
160
|
+
self._api_version = "v5"
|
|
161
|
+
logger.info("Detected Strapi v5 API format")
|
|
162
|
+
return self._api_version
|
|
163
|
+
else:
|
|
164
|
+
# Ambiguous - don't cache, return v4 as fallback
|
|
165
|
+
logger.warning("Could not detect API version, using v4 fallback (not cached)")
|
|
166
|
+
return "v4"
|
|
167
|
+
elif isinstance(response_data.get("data"), list) and response_data["data"]:
|
|
168
|
+
# Check first item in list
|
|
169
|
+
first_item = response_data["data"][0]
|
|
170
|
+
if "attributes" in first_item:
|
|
171
|
+
self._api_version = "v4"
|
|
172
|
+
logger.info("Detected Strapi v4 API format")
|
|
173
|
+
return self._api_version
|
|
174
|
+
elif "documentId" in first_item:
|
|
175
|
+
self._api_version = "v5"
|
|
176
|
+
logger.info("Detected Strapi v5 API format")
|
|
177
|
+
return self._api_version
|
|
178
|
+
else:
|
|
179
|
+
# Ambiguous - don't cache, return v4 as fallback
|
|
180
|
+
logger.warning("Could not detect API version, using v4 fallback (not cached)")
|
|
181
|
+
return "v4"
|
|
182
|
+
else:
|
|
183
|
+
# No data field or empty - don't cache, return v4 as fallback
|
|
184
|
+
return "v4"
|
|
185
|
+
|
|
186
|
+
def reset_version_detection(self) -> None:
|
|
187
|
+
"""Reset the cached API version detection.
|
|
188
|
+
|
|
189
|
+
Call this if you need to re-detect the API version, for example
|
|
190
|
+
after changing the Strapi instance or during testing.
|
|
191
|
+
"""
|
|
192
|
+
self._api_version = None
|
|
193
|
+
logger.info("Reset API version detection cache")
|
|
194
|
+
|
|
195
|
+
def _handle_error_response(self, response: httpx.Response) -> None:
|
|
196
|
+
"""Handle HTTP error responses by raising appropriate exceptions.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
response: HTTPX response object
|
|
200
|
+
|
|
201
|
+
Raises:
|
|
202
|
+
Appropriate StrapiError subclass based on status code
|
|
203
|
+
"""
|
|
204
|
+
status_code = response.status_code
|
|
205
|
+
|
|
206
|
+
# Try to extract error details from response
|
|
207
|
+
try:
|
|
208
|
+
error_data = response.json()
|
|
209
|
+
error_message = error_data.get("error", {}).get("message", response.text)
|
|
210
|
+
error_details = error_data.get("error", {}).get("details", {})
|
|
211
|
+
except Exception:
|
|
212
|
+
error_message = response.text or f"HTTP {status_code}"
|
|
213
|
+
error_details = {}
|
|
214
|
+
|
|
215
|
+
# Map status codes to exceptions
|
|
216
|
+
if status_code == 401:
|
|
217
|
+
raise AuthenticationError(
|
|
218
|
+
f"Authentication failed: {error_message}", details=error_details
|
|
219
|
+
)
|
|
220
|
+
elif status_code == 403:
|
|
221
|
+
raise AuthorizationError(
|
|
222
|
+
f"Authorization failed: {error_message}", details=error_details
|
|
223
|
+
)
|
|
224
|
+
elif status_code == 404:
|
|
225
|
+
raise NotFoundError(f"Resource not found: {error_message}", details=error_details)
|
|
226
|
+
elif status_code == 400:
|
|
227
|
+
raise ValidationError(f"Validation error: {error_message}", details=error_details)
|
|
228
|
+
elif status_code == 409:
|
|
229
|
+
raise ConflictError(f"Conflict: {error_message}", details=error_details)
|
|
230
|
+
elif status_code == 429:
|
|
231
|
+
retry_after = response.headers.get("Retry-After")
|
|
232
|
+
# RFC 7231: Retry-After can be numeric seconds or HTTP-date string
|
|
233
|
+
retry_seconds: int | None = None
|
|
234
|
+
if retry_after:
|
|
235
|
+
try:
|
|
236
|
+
retry_seconds = int(retry_after)
|
|
237
|
+
except ValueError:
|
|
238
|
+
# HTTP-date format (e.g., "Wed, 21 Oct 2015 07:28:00 GMT")
|
|
239
|
+
# Fall back to default retry behavior
|
|
240
|
+
retry_seconds = None
|
|
241
|
+
raise RateLimitError(
|
|
242
|
+
f"Rate limit exceeded: {error_message}",
|
|
243
|
+
retry_after=retry_seconds,
|
|
244
|
+
details=error_details,
|
|
245
|
+
)
|
|
246
|
+
elif 500 <= status_code < 600:
|
|
247
|
+
raise ServerError(
|
|
248
|
+
f"Server error: {error_message}",
|
|
249
|
+
status_code=status_code,
|
|
250
|
+
details=error_details,
|
|
251
|
+
)
|
|
252
|
+
else:
|
|
253
|
+
raise StrapiError(
|
|
254
|
+
f"Unexpected error (HTTP {status_code}): {error_message}",
|
|
255
|
+
details=error_details,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def _create_retry_decorator(self) -> Any:
|
|
259
|
+
"""Create a retry decorator based on configuration.
|
|
260
|
+
|
|
261
|
+
The decorator retries on:
|
|
262
|
+
- Server errors (5xx) and connection issues
|
|
263
|
+
- Rate limit errors (429) with retry_after support
|
|
264
|
+
- Configured status codes from retry_on_status
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
Configured tenacity retry decorator
|
|
268
|
+
"""
|
|
269
|
+
retry_config = self.config.retry
|
|
270
|
+
|
|
271
|
+
def should_retry_exception(exception: BaseException) -> bool:
|
|
272
|
+
"""Determine if exception should trigger retry."""
|
|
273
|
+
# Always retry connection issues
|
|
274
|
+
if isinstance(exception, StrapiConnectionError):
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
# Retry RateLimitError with exponential backoff
|
|
278
|
+
if isinstance(exception, RateLimitError):
|
|
279
|
+
return True
|
|
280
|
+
|
|
281
|
+
# Check if exception has status_code matching retry_on_status
|
|
282
|
+
# This includes ServerError if its status code is in retry_on_status
|
|
283
|
+
if hasattr(exception, "status_code"):
|
|
284
|
+
return exception.status_code in retry_config.retry_on_status
|
|
285
|
+
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
def wait_strategy(retry_state): # type: ignore[no-untyped-def]
|
|
289
|
+
"""Custom wait strategy that respects retry_after."""
|
|
290
|
+
exception = retry_state.outcome.exception()
|
|
291
|
+
|
|
292
|
+
# If RateLimitError with retry_after, use that value
|
|
293
|
+
if isinstance(exception, RateLimitError) and exception.retry_after:
|
|
294
|
+
return exception.retry_after
|
|
295
|
+
|
|
296
|
+
# Otherwise use exponential backoff
|
|
297
|
+
return wait_exponential(
|
|
298
|
+
multiplier=retry_config.exponential_base,
|
|
299
|
+
min=retry_config.initial_wait,
|
|
300
|
+
max=retry_config.max_wait,
|
|
301
|
+
)(retry_state)
|
|
302
|
+
|
|
303
|
+
return retry(
|
|
304
|
+
stop=stop_after_attempt(retry_config.max_attempts),
|
|
305
|
+
wait=wait_strategy,
|
|
306
|
+
retry=retry_if_exception(should_retry_exception),
|
|
307
|
+
before_sleep=before_sleep_log(logger, logging.WARNING),
|
|
308
|
+
reraise=True,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
@property
|
|
312
|
+
def api_version(self) -> Literal["v4", "v5"] | None:
|
|
313
|
+
"""Get the detected or configured API version.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
API version or None if not yet detected
|
|
317
|
+
"""
|
|
318
|
+
return self._api_version
|
|
319
|
+
|
|
320
|
+
def _parse_single_response(self, response_data: dict[str, Any]) -> NormalizedSingleResponse:
|
|
321
|
+
"""Parse a single entity response into normalized format.
|
|
322
|
+
|
|
323
|
+
Delegates to the injected parser for actual parsing logic.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
response_data: Raw JSON response from Strapi
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
Normalized single entity response
|
|
330
|
+
|
|
331
|
+
Examples:
|
|
332
|
+
>>> response_data = {"data": {"id": 1, "documentId": "abc", ...}}
|
|
333
|
+
>>> normalized = client._parse_single_response(response_data)
|
|
334
|
+
>>> normalized.data.id
|
|
335
|
+
1
|
|
336
|
+
"""
|
|
337
|
+
# Delegate to injected parser
|
|
338
|
+
return self.parser.parse_single(response_data)
|
|
339
|
+
|
|
340
|
+
def _parse_collection_response(
|
|
341
|
+
self, response_data: dict[str, Any]
|
|
342
|
+
) -> NormalizedCollectionResponse:
|
|
343
|
+
"""Parse a collection response into normalized format.
|
|
344
|
+
|
|
345
|
+
Delegates to the injected parser for actual parsing logic.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
response_data: Raw JSON response from Strapi
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
Normalized collection response
|
|
352
|
+
|
|
353
|
+
Examples:
|
|
354
|
+
>>> response_data = {"data": [{"id": 1, ...}, {"id": 2, ...}]}
|
|
355
|
+
>>> normalized = client._parse_collection_response(response_data)
|
|
356
|
+
>>> len(normalized.data)
|
|
357
|
+
2
|
|
358
|
+
"""
|
|
359
|
+
# Delegate to injected parser
|
|
360
|
+
return self.parser.parse_collection(response_data)
|
|
361
|
+
|
|
362
|
+
def _build_upload_headers(self) -> dict[str, str]:
|
|
363
|
+
"""Build headers for multipart file upload.
|
|
364
|
+
|
|
365
|
+
Omits Content-Type header to let httpx set the multipart boundary automatically.
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
Headers dictionary without Content-Type
|
|
369
|
+
"""
|
|
370
|
+
headers = {
|
|
371
|
+
"Accept": "application/json",
|
|
372
|
+
**self.auth.get_headers(),
|
|
373
|
+
}
|
|
374
|
+
return headers
|
|
375
|
+
|
|
376
|
+
def _parse_media_response(self, response_data: dict[str, Any]) -> MediaFile:
|
|
377
|
+
"""Parse media upload/download response into MediaFile model.
|
|
378
|
+
|
|
379
|
+
Automatically detects v4/v5 format and normalizes the response.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
response_data: Raw JSON response from Strapi media endpoint
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
Validated MediaFile instance
|
|
386
|
+
|
|
387
|
+
Examples:
|
|
388
|
+
>>> # v5 response
|
|
389
|
+
>>> response_data = {
|
|
390
|
+
... "id": 1,
|
|
391
|
+
... "documentId": "abc123",
|
|
392
|
+
... "name": "image.jpg",
|
|
393
|
+
... "url": "/uploads/image.jpg",
|
|
394
|
+
... ...
|
|
395
|
+
... }
|
|
396
|
+
>>> media = client._parse_media_response(response_data)
|
|
397
|
+
>>> media.name
|
|
398
|
+
'image.jpg'
|
|
399
|
+
"""
|
|
400
|
+
api_version = self._detect_api_version({"data": response_data})
|
|
401
|
+
return normalize_media_response(response_data, api_version)
|
|
402
|
+
|
|
403
|
+
def _parse_media_list_response(
|
|
404
|
+
self, response_data: dict[str, Any] | list[dict[str, Any]]
|
|
405
|
+
) -> NormalizedCollectionResponse:
|
|
406
|
+
"""Parse media library list response into normalized collection.
|
|
407
|
+
|
|
408
|
+
Media list responses may be in standard Strapi collection format
|
|
409
|
+
or a raw array (depending on Strapi version/plugin).
|
|
410
|
+
|
|
411
|
+
For v4 responses with nested attributes, this method flattens each
|
|
412
|
+
item before passing to the collection parser to ensure consistent
|
|
413
|
+
handling with single media responses.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
response_data: Raw JSON response from media list endpoint
|
|
417
|
+
(may be dict with "data" key or raw array)
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
Normalized collection response with MediaFile entities
|
|
421
|
+
|
|
422
|
+
Examples:
|
|
423
|
+
>>> # Standard format
|
|
424
|
+
>>> response_data = {
|
|
425
|
+
... "data": [
|
|
426
|
+
... {"id": 1, "name": "image1.jpg", ...},
|
|
427
|
+
... {"id": 2, "name": "image2.jpg", ...}
|
|
428
|
+
... ],
|
|
429
|
+
... "meta": {"pagination": {...}}
|
|
430
|
+
... }
|
|
431
|
+
>>> result = client._parse_media_list_response(response_data)
|
|
432
|
+
>>> len(result.data)
|
|
433
|
+
2
|
|
434
|
+
|
|
435
|
+
>>> # Raw array format (Strapi Upload plugin)
|
|
436
|
+
>>> response_data = [{"id": 1, "name": "image.jpg", ...}]
|
|
437
|
+
>>> result = client._parse_media_list_response(response_data)
|
|
438
|
+
>>> len(result.data)
|
|
439
|
+
1
|
|
440
|
+
"""
|
|
441
|
+
# Handle raw array response (Strapi Upload plugin may return this)
|
|
442
|
+
if isinstance(response_data, list):
|
|
443
|
+
response_data = {"data": response_data, "meta": {}}
|
|
444
|
+
|
|
445
|
+
# For v4, flatten nested attributes to match v5 format before parsing
|
|
446
|
+
if isinstance(response_data.get("data"), list):
|
|
447
|
+
data_items = response_data["data"]
|
|
448
|
+
if data_items and isinstance(data_items[0], dict) and "attributes" in data_items[0]:
|
|
449
|
+
# v4 format - flatten each item
|
|
450
|
+
flattened_items = []
|
|
451
|
+
for item in data_items:
|
|
452
|
+
if "attributes" in item:
|
|
453
|
+
flattened = {"id": item["id"], **item["attributes"]}
|
|
454
|
+
flattened_items.append(flattened)
|
|
455
|
+
else:
|
|
456
|
+
flattened_items.append(item)
|
|
457
|
+
response_data = {"data": flattened_items, "meta": response_data.get("meta", {})}
|
|
458
|
+
|
|
459
|
+
# Media list follows standard collection format
|
|
460
|
+
return self._parse_collection_response(response_data)
|