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,226 @@
|
|
|
1
|
+
"""Media operations utilities for strapi-kit.
|
|
2
|
+
|
|
3
|
+
This module provides shared utility functions for media upload, download,
|
|
4
|
+
and response normalization across sync and async clients.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import IO, Any, Literal
|
|
10
|
+
from urllib.parse import urljoin, urlparse
|
|
11
|
+
|
|
12
|
+
from strapi_kit.models.response.media import MediaFile
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UploadPayload:
|
|
16
|
+
"""Container for upload payload with proper file handle management.
|
|
17
|
+
|
|
18
|
+
This class ensures file handles are properly closed after upload operations,
|
|
19
|
+
preventing resource leaks in batch operations or error scenarios.
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
Use as a context manager to ensure proper cleanup:
|
|
23
|
+
|
|
24
|
+
>>> with build_upload_payload("image.jpg") as payload:
|
|
25
|
+
... files = {"files": payload.files_tuple}
|
|
26
|
+
... data = payload.data
|
|
27
|
+
... # Make HTTP request
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
file_path: Path,
|
|
33
|
+
data: dict[str, Any] | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Initialize upload payload.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
file_path: Path to the file to upload
|
|
39
|
+
data: Optional metadata dictionary
|
|
40
|
+
"""
|
|
41
|
+
self._file_path = file_path
|
|
42
|
+
self._data = data
|
|
43
|
+
self._file_handle: IO[bytes] | None = None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def files_tuple(self) -> tuple[str, IO[bytes], None]:
|
|
47
|
+
"""Get the files tuple for httpx multipart upload.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Tuple of (filename, file_handle, content_type)
|
|
51
|
+
Content type is None to let httpx auto-detect MIME type.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
RuntimeError: If accessed outside of context manager
|
|
55
|
+
"""
|
|
56
|
+
if self._file_handle is None:
|
|
57
|
+
raise RuntimeError("UploadPayload must be used as a context manager")
|
|
58
|
+
return ("file", self._file_handle, None)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def data(self) -> dict[str, Any] | None:
|
|
62
|
+
"""Get the metadata dictionary."""
|
|
63
|
+
return self._data
|
|
64
|
+
|
|
65
|
+
def __enter__(self) -> "UploadPayload":
|
|
66
|
+
"""Open file handle on context entry."""
|
|
67
|
+
self._file_handle = open(self._file_path, "rb")
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
71
|
+
"""Close file handle on context exit."""
|
|
72
|
+
if self._file_handle is not None:
|
|
73
|
+
self._file_handle.close()
|
|
74
|
+
self._file_handle = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_upload_payload(
|
|
78
|
+
file_path: str | Path,
|
|
79
|
+
ref: str | None = None,
|
|
80
|
+
ref_id: str | int | None = None,
|
|
81
|
+
field: str | None = None,
|
|
82
|
+
folder: str | None = None,
|
|
83
|
+
alternative_text: str | None = None,
|
|
84
|
+
caption: str | None = None,
|
|
85
|
+
) -> UploadPayload:
|
|
86
|
+
"""Build multipart form data payload for file upload.
|
|
87
|
+
|
|
88
|
+
Returns an UploadPayload context manager that properly handles file
|
|
89
|
+
lifecycle to prevent resource leaks.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
file_path: Path to file to upload
|
|
93
|
+
ref: Reference model name (e.g., "api::article.article")
|
|
94
|
+
ref_id: Reference document ID (numeric or string)
|
|
95
|
+
field: Field name in reference model
|
|
96
|
+
folder: Folder ID for organization
|
|
97
|
+
alternative_text: Alt text for images
|
|
98
|
+
caption: Caption text
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
UploadPayload context manager with file handle management
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
FileNotFoundError: If file doesn't exist
|
|
105
|
+
|
|
106
|
+
Example:
|
|
107
|
+
>>> with build_upload_payload(
|
|
108
|
+
... "image.jpg",
|
|
109
|
+
... ref="api::article.article",
|
|
110
|
+
... ref_id="123",
|
|
111
|
+
... alternative_text="Hero image"
|
|
112
|
+
... ) as payload:
|
|
113
|
+
... # Use payload.files_tuple and payload.data for upload
|
|
114
|
+
... pass
|
|
115
|
+
"""
|
|
116
|
+
path = Path(file_path)
|
|
117
|
+
if not path.exists():
|
|
118
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
119
|
+
|
|
120
|
+
# Build metadata dict (fileInfo in Strapi API)
|
|
121
|
+
file_info: dict[str, Any] = {}
|
|
122
|
+
if alternative_text is not None:
|
|
123
|
+
file_info["alternativeText"] = alternative_text
|
|
124
|
+
if caption is not None:
|
|
125
|
+
file_info["caption"] = caption
|
|
126
|
+
|
|
127
|
+
# Build form data metadata
|
|
128
|
+
data: dict[str, Any] = {}
|
|
129
|
+
if ref is not None:
|
|
130
|
+
data["ref"] = ref
|
|
131
|
+
if ref_id is not None:
|
|
132
|
+
data["refId"] = str(ref_id)
|
|
133
|
+
if field is not None:
|
|
134
|
+
data["field"] = field
|
|
135
|
+
if folder is not None:
|
|
136
|
+
data["folder"] = folder
|
|
137
|
+
if file_info:
|
|
138
|
+
# httpx multipart requires JSON string for nested objects
|
|
139
|
+
data["fileInfo"] = json.dumps(file_info)
|
|
140
|
+
|
|
141
|
+
return UploadPayload(path, data if data else None)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def normalize_media_response(
|
|
145
|
+
response_data: dict[str, Any],
|
|
146
|
+
api_version: Literal["v4", "v5"],
|
|
147
|
+
) -> MediaFile:
|
|
148
|
+
"""Normalize v4/v5 media response to MediaFile model.
|
|
149
|
+
|
|
150
|
+
Handles both nested attributes (v4) and flattened (v5) response structures.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
response_data: Raw API response data
|
|
154
|
+
api_version: Detected API version ("v4" or "v5")
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Validated MediaFile instance
|
|
158
|
+
|
|
159
|
+
Example:
|
|
160
|
+
>>> # v5 response (flattened)
|
|
161
|
+
>>> v5_data = {
|
|
162
|
+
... "id": 1,
|
|
163
|
+
... "documentId": "abc123",
|
|
164
|
+
... "name": "image.jpg",
|
|
165
|
+
... "url": "/uploads/image.jpg"
|
|
166
|
+
... }
|
|
167
|
+
>>> media = normalize_media_response(v5_data, "v5")
|
|
168
|
+
|
|
169
|
+
>>> # v4 response (nested attributes)
|
|
170
|
+
>>> v4_data = {
|
|
171
|
+
... "id": 1,
|
|
172
|
+
... "attributes": {
|
|
173
|
+
... "name": "image.jpg",
|
|
174
|
+
... "url": "/uploads/image.jpg"
|
|
175
|
+
... }
|
|
176
|
+
... }
|
|
177
|
+
>>> media = normalize_media_response(v4_data, "v4")
|
|
178
|
+
"""
|
|
179
|
+
if api_version == "v4":
|
|
180
|
+
# v4: nested structure with id at top level, rest in attributes
|
|
181
|
+
if "attributes" in response_data:
|
|
182
|
+
# Flatten attributes to top level
|
|
183
|
+
flattened = {"id": response_data["id"], **response_data["attributes"]}
|
|
184
|
+
return MediaFile.model_validate(flattened)
|
|
185
|
+
else:
|
|
186
|
+
# Already flattened or invalid
|
|
187
|
+
return MediaFile.model_validate(response_data)
|
|
188
|
+
else:
|
|
189
|
+
# v5: already flattened with documentId
|
|
190
|
+
return MediaFile.model_validate(response_data)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def build_media_download_url(base_url: str, media_url: str) -> str:
|
|
194
|
+
"""Construct full URL for media download.
|
|
195
|
+
|
|
196
|
+
Handles both relative paths (/uploads/...) and absolute URLs.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
base_url: Strapi instance base URL (e.g., "http://localhost:1337")
|
|
200
|
+
media_url: Media URL from API response (relative or absolute)
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Full absolute URL for download
|
|
204
|
+
|
|
205
|
+
Example:
|
|
206
|
+
>>> build_media_download_url(
|
|
207
|
+
... "http://localhost:1337",
|
|
208
|
+
... "/uploads/image.jpg"
|
|
209
|
+
... )
|
|
210
|
+
'http://localhost:1337/uploads/image.jpg'
|
|
211
|
+
|
|
212
|
+
>>> build_media_download_url(
|
|
213
|
+
... "http://localhost:1337",
|
|
214
|
+
... "https://cdn.example.com/image.jpg"
|
|
215
|
+
... )
|
|
216
|
+
'https://cdn.example.com/image.jpg'
|
|
217
|
+
"""
|
|
218
|
+
# Check if URL is already absolute
|
|
219
|
+
parsed = urlparse(media_url)
|
|
220
|
+
if parsed.scheme: # Has http:// or https://
|
|
221
|
+
return media_url
|
|
222
|
+
|
|
223
|
+
# Relative URL - join with base_url
|
|
224
|
+
# Ensure base_url doesn't have trailing slash for proper joining
|
|
225
|
+
base = base_url.rstrip("/")
|
|
226
|
+
return urljoin(base, media_url)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Streaming pagination utilities for large result sets.
|
|
2
|
+
|
|
3
|
+
This module provides generators that automatically handle pagination,
|
|
4
|
+
allowing memory-efficient iteration over large datasets.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import AsyncGenerator, Generator
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from ..models import StrapiQuery
|
|
11
|
+
from ..models.response.normalized import NormalizedEntity
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from ..client.async_client import AsyncClient
|
|
15
|
+
from ..client.sync_client import SyncClient
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def stream_entities(
|
|
19
|
+
client: "SyncClient",
|
|
20
|
+
endpoint: str,
|
|
21
|
+
query: StrapiQuery | None = None,
|
|
22
|
+
page_size: int = 100,
|
|
23
|
+
) -> Generator[NormalizedEntity, None, None]:
|
|
24
|
+
"""Stream entities from endpoint with automatic pagination.
|
|
25
|
+
|
|
26
|
+
This generator automatically fetches pages as needed, yielding
|
|
27
|
+
entities one at a time without loading the entire dataset into memory.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
client: SyncClient instance
|
|
31
|
+
endpoint: API endpoint (e.g., "articles")
|
|
32
|
+
query: Optional query (filters, sorts, populate, etc.)
|
|
33
|
+
page_size: Items per page (default: 100)
|
|
34
|
+
|
|
35
|
+
Yields:
|
|
36
|
+
NormalizedEntity objects one at a time
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
ValueError: If page_size < 1
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
>>> with SyncClient(config) as client:
|
|
43
|
+
... for article in stream_entities(client, "articles", page_size=50):
|
|
44
|
+
... print(article.attributes["title"])
|
|
45
|
+
... # Process one at a time without loading all into memory
|
|
46
|
+
"""
|
|
47
|
+
if page_size < 1:
|
|
48
|
+
raise ValueError("page_size must be >= 1")
|
|
49
|
+
|
|
50
|
+
current_page = 1
|
|
51
|
+
|
|
52
|
+
# Build base query - create copy to avoid mutating caller's query
|
|
53
|
+
base_query = query.copy() if query is not None else StrapiQuery()
|
|
54
|
+
|
|
55
|
+
while True:
|
|
56
|
+
# Update pagination for current page on a copy
|
|
57
|
+
page_query = base_query.copy().paginate(page=current_page, page_size=page_size)
|
|
58
|
+
|
|
59
|
+
# Fetch page
|
|
60
|
+
response = client.get_many(endpoint, query=page_query)
|
|
61
|
+
|
|
62
|
+
# Yield each entity
|
|
63
|
+
yield from response.data
|
|
64
|
+
|
|
65
|
+
# Safety check: if no data returned, stop to prevent infinite loop
|
|
66
|
+
if not response.data:
|
|
67
|
+
break
|
|
68
|
+
|
|
69
|
+
# Check if more pages exist
|
|
70
|
+
if response.meta and response.meta.pagination:
|
|
71
|
+
total_pages = response.meta.pagination.page_count
|
|
72
|
+
# Handle None or 0 page_count - stop to prevent infinite loop
|
|
73
|
+
if total_pages is None or total_pages == 0 or current_page >= total_pages:
|
|
74
|
+
break
|
|
75
|
+
else:
|
|
76
|
+
# No pagination metadata, assume single page
|
|
77
|
+
break
|
|
78
|
+
|
|
79
|
+
current_page += 1
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def stream_entities_async(
|
|
83
|
+
client: "AsyncClient",
|
|
84
|
+
endpoint: str,
|
|
85
|
+
query: StrapiQuery | None = None,
|
|
86
|
+
page_size: int = 100,
|
|
87
|
+
) -> AsyncGenerator[NormalizedEntity, None]:
|
|
88
|
+
"""Async version of stream_entities.
|
|
89
|
+
|
|
90
|
+
This async generator automatically fetches pages as needed, yielding
|
|
91
|
+
entities one at a time without loading the entire dataset into memory.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
client: AsyncClient instance
|
|
95
|
+
endpoint: API endpoint (e.g., "articles")
|
|
96
|
+
query: Optional query (filters, sorts, populate, etc.)
|
|
97
|
+
page_size: Items per page (default: 100)
|
|
98
|
+
|
|
99
|
+
Yields:
|
|
100
|
+
NormalizedEntity objects one at a time
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
ValueError: If page_size < 1
|
|
104
|
+
|
|
105
|
+
Example:
|
|
106
|
+
>>> async with AsyncClient(config) as client:
|
|
107
|
+
... async for article in stream_entities_async(client, "articles"):
|
|
108
|
+
... print(article.attributes["title"])
|
|
109
|
+
... # Process asynchronously without loading all into memory
|
|
110
|
+
"""
|
|
111
|
+
if page_size < 1:
|
|
112
|
+
raise ValueError("page_size must be >= 1")
|
|
113
|
+
|
|
114
|
+
current_page = 1
|
|
115
|
+
|
|
116
|
+
# Build base query - create copy to avoid mutating caller's query
|
|
117
|
+
base_query = query.copy() if query is not None else StrapiQuery()
|
|
118
|
+
|
|
119
|
+
while True:
|
|
120
|
+
# Update pagination for current page on a copy
|
|
121
|
+
page_query = base_query.copy().paginate(page=current_page, page_size=page_size)
|
|
122
|
+
|
|
123
|
+
# Fetch page
|
|
124
|
+
response = await client.get_many(endpoint, query=page_query)
|
|
125
|
+
|
|
126
|
+
# Yield each entity
|
|
127
|
+
for entity in response.data:
|
|
128
|
+
yield entity
|
|
129
|
+
|
|
130
|
+
# Safety check: if no data returned, stop to prevent infinite loop
|
|
131
|
+
if not response.data:
|
|
132
|
+
break
|
|
133
|
+
|
|
134
|
+
# Check if more pages exist
|
|
135
|
+
if response.meta and response.meta.pagination:
|
|
136
|
+
total_pages = response.meta.pagination.page_count
|
|
137
|
+
# Handle None or 0 page_count - stop to prevent infinite loop
|
|
138
|
+
if total_pages is None or total_pages == 0 or current_page >= total_pages:
|
|
139
|
+
break
|
|
140
|
+
else:
|
|
141
|
+
# No pagination metadata, assume single page
|
|
142
|
+
break
|
|
143
|
+
|
|
144
|
+
current_page += 1
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Response parser with automatic Strapi version detection.
|
|
2
|
+
|
|
3
|
+
This module provides automatic detection and normalization of Strapi v4 and v5
|
|
4
|
+
API responses into a consistent format.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
from ..models.response.normalized import (
|
|
11
|
+
NormalizedCollectionResponse,
|
|
12
|
+
NormalizedEntity,
|
|
13
|
+
NormalizedSingleResponse,
|
|
14
|
+
)
|
|
15
|
+
from ..models.response.v4 import V4CollectionResponse, V4SingleResponse
|
|
16
|
+
from ..models.response.v5 import V5CollectionResponse, V5SingleResponse
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class VersionDetectingParser:
|
|
22
|
+
"""Response parser with automatic v4/v5 version detection.
|
|
23
|
+
|
|
24
|
+
This parser automatically detects whether responses are from Strapi v4
|
|
25
|
+
or v5 based on their structure, then normalizes them to a consistent format.
|
|
26
|
+
|
|
27
|
+
Example:
|
|
28
|
+
```python
|
|
29
|
+
parser = VersionDetectingParser()
|
|
30
|
+
normalized = parser.parse_single(raw_response)
|
|
31
|
+
print(normalized.data.id)
|
|
32
|
+
```
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, default_version: Literal["v4", "v5"] | None = None) -> None:
|
|
36
|
+
"""Initialize the parser.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
default_version: Optional version to use if detection fails
|
|
40
|
+
"""
|
|
41
|
+
self._detected_version: Literal["v4", "v5"] | None = default_version
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def detected_version(self) -> Literal["v4", "v5"] | None:
|
|
45
|
+
"""Get the detected API version.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Detected version or None if not yet detected
|
|
49
|
+
"""
|
|
50
|
+
return self._detected_version
|
|
51
|
+
|
|
52
|
+
def detect_version(self, response_data: dict[str, Any]) -> Literal["v4", "v5"]:
|
|
53
|
+
"""Detect Strapi API version from response structure.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
response_data: Raw JSON response from Strapi
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Detected API version ("v4" or "v5")
|
|
60
|
+
|
|
61
|
+
Note:
|
|
62
|
+
Only caches version when detection is definitive (found attributes
|
|
63
|
+
or documentId). Ambiguous responses return fallback without caching,
|
|
64
|
+
allowing re-detection on subsequent meaningful responses.
|
|
65
|
+
"""
|
|
66
|
+
# If already detected, use cached version
|
|
67
|
+
if self._detected_version:
|
|
68
|
+
return self._detected_version
|
|
69
|
+
|
|
70
|
+
# V4: data.attributes structure
|
|
71
|
+
# V5: flattened data with documentId
|
|
72
|
+
if isinstance(response_data.get("data"), dict):
|
|
73
|
+
data = response_data["data"]
|
|
74
|
+
if "attributes" in data:
|
|
75
|
+
self._detected_version = "v4"
|
|
76
|
+
logger.info("Detected Strapi v4 API format")
|
|
77
|
+
elif "documentId" in data:
|
|
78
|
+
self._detected_version = "v5"
|
|
79
|
+
logger.info("Detected Strapi v5 API format")
|
|
80
|
+
else:
|
|
81
|
+
# Don't cache - return fallback without locking
|
|
82
|
+
logger.warning("Could not detect API version from object data, using v4 fallback")
|
|
83
|
+
return "v4"
|
|
84
|
+
elif isinstance(response_data.get("data"), list) and response_data["data"]:
|
|
85
|
+
# Check first item in list
|
|
86
|
+
first_item = response_data["data"][0]
|
|
87
|
+
if "attributes" in first_item:
|
|
88
|
+
self._detected_version = "v4"
|
|
89
|
+
logger.info("Detected Strapi v4 API format")
|
|
90
|
+
elif "documentId" in first_item:
|
|
91
|
+
self._detected_version = "v5"
|
|
92
|
+
logger.info("Detected Strapi v5 API format")
|
|
93
|
+
else:
|
|
94
|
+
# Don't cache - return fallback without locking
|
|
95
|
+
logger.warning("Could not detect API version from list data, using v4 fallback")
|
|
96
|
+
return "v4"
|
|
97
|
+
else:
|
|
98
|
+
# Empty/no data - don't cache, return fallback
|
|
99
|
+
return "v4"
|
|
100
|
+
|
|
101
|
+
return self._detected_version
|
|
102
|
+
|
|
103
|
+
def parse_single(self, response_data: dict[str, Any]) -> NormalizedSingleResponse:
|
|
104
|
+
"""Parse a single entity response into normalized format.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
response_data: Raw JSON response from Strapi
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Normalized single entity response
|
|
111
|
+
|
|
112
|
+
Examples:
|
|
113
|
+
>>> parser = VersionDetectingParser()
|
|
114
|
+
>>> response = {"data": {"id": 1, "documentId": "abc", ...}}
|
|
115
|
+
>>> normalized = parser.parse_single(response)
|
|
116
|
+
>>> normalized.data.id
|
|
117
|
+
1
|
|
118
|
+
"""
|
|
119
|
+
# Detect API version from response
|
|
120
|
+
api_version = self.detect_version(response_data)
|
|
121
|
+
|
|
122
|
+
if api_version == "v4":
|
|
123
|
+
# Parse as v4 and normalize
|
|
124
|
+
v4_response = V4SingleResponse(**response_data)
|
|
125
|
+
if v4_response.data:
|
|
126
|
+
normalized_entity = NormalizedEntity.from_v4(v4_response.data)
|
|
127
|
+
else:
|
|
128
|
+
normalized_entity = None
|
|
129
|
+
|
|
130
|
+
return NormalizedSingleResponse(data=normalized_entity, meta=v4_response.meta)
|
|
131
|
+
else:
|
|
132
|
+
# Parse as v5 and normalize
|
|
133
|
+
v5_response = V5SingleResponse(**response_data)
|
|
134
|
+
if v5_response.data:
|
|
135
|
+
normalized_entity = NormalizedEntity.from_v5(v5_response.data)
|
|
136
|
+
else:
|
|
137
|
+
normalized_entity = None
|
|
138
|
+
|
|
139
|
+
return NormalizedSingleResponse(data=normalized_entity, meta=v5_response.meta)
|
|
140
|
+
|
|
141
|
+
def parse_collection(self, response_data: dict[str, Any]) -> NormalizedCollectionResponse:
|
|
142
|
+
"""Parse a collection response into normalized format.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
response_data: Raw JSON response from Strapi
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Normalized collection response
|
|
149
|
+
|
|
150
|
+
Examples:
|
|
151
|
+
>>> parser = VersionDetectingParser()
|
|
152
|
+
>>> response = {"data": [{"id": 1, ...}, {"id": 2, ...}]}
|
|
153
|
+
>>> normalized = parser.parse_collection(response)
|
|
154
|
+
>>> len(normalized.data)
|
|
155
|
+
2
|
|
156
|
+
"""
|
|
157
|
+
# Detect API version from response
|
|
158
|
+
api_version = self.detect_version(response_data)
|
|
159
|
+
|
|
160
|
+
if api_version == "v4":
|
|
161
|
+
# Parse as v4 and normalize
|
|
162
|
+
v4_response = V4CollectionResponse(**response_data)
|
|
163
|
+
normalized_entities = [NormalizedEntity.from_v4(entity) for entity in v4_response.data]
|
|
164
|
+
|
|
165
|
+
return NormalizedCollectionResponse(data=normalized_entities, meta=v4_response.meta)
|
|
166
|
+
else:
|
|
167
|
+
# Parse as v5 and normalize
|
|
168
|
+
v5_response = V5CollectionResponse(**response_data)
|
|
169
|
+
normalized_entities = [NormalizedEntity.from_v5(entity) for entity in v5_response.data]
|
|
170
|
+
|
|
171
|
+
return NormalizedCollectionResponse(data=normalized_entities, meta=v5_response.meta)
|