shopware-api-client 1.0.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.
- shopware_api_client/__init__.py +0 -0
- shopware_api_client/base.py +464 -0
- shopware_api_client/client.py +103 -0
- shopware_api_client/config.py +39 -0
- shopware_api_client/endpoints/__init__.py +0 -0
- shopware_api_client/endpoints/admin/__init__.py +149 -0
- shopware_api_client/endpoints/admin/core/__init__.py +0 -0
- shopware_api_client/endpoints/admin/core/category.py +168 -0
- shopware_api_client/endpoints/admin/core/cms_block.py +98 -0
- shopware_api_client/endpoints/admin/core/cms_page.py +67 -0
- shopware_api_client/endpoints/admin/core/cms_section.py +87 -0
- shopware_api_client/endpoints/admin/core/cms_slot.py +63 -0
- shopware_api_client/endpoints/admin/core/country.py +123 -0
- shopware_api_client/endpoints/admin/core/country_state.py +57 -0
- shopware_api_client/endpoints/admin/core/currency.py +89 -0
- shopware_api_client/endpoints/admin/core/currency_country_rounding.py +57 -0
- shopware_api_client/endpoints/admin/core/customer.py +218 -0
- shopware_api_client/endpoints/admin/core/customer_address.py +85 -0
- shopware_api_client/endpoints/admin/core/customer_group.py +74 -0
- shopware_api_client/endpoints/admin/core/customer_recovery.py +46 -0
- shopware_api_client/endpoints/admin/core/customer_wishlist.py +53 -0
- shopware_api_client/endpoints/admin/core/customer_wishlist_product.py +56 -0
- shopware_api_client/endpoints/admin/core/delivery_time.py +50 -0
- shopware_api_client/endpoints/admin/core/document.py +80 -0
- shopware_api_client/endpoints/admin/core/document_base_config.py +73 -0
- shopware_api_client/endpoints/admin/core/document_base_config_sales_channel.py +64 -0
- shopware_api_client/endpoints/admin/core/document_type.py +55 -0
- shopware_api_client/endpoints/admin/core/landing_page.py +75 -0
- shopware_api_client/endpoints/admin/core/language.py +79 -0
- shopware_api_client/endpoints/admin/core/locale.py +49 -0
- shopware_api_client/endpoints/admin/core/main_category.py +63 -0
- shopware_api_client/endpoints/admin/core/media.py +156 -0
- shopware_api_client/endpoints/admin/core/media_thumbnail.py +50 -0
- shopware_api_client/endpoints/admin/core/order.py +185 -0
- shopware_api_client/endpoints/admin/core/order_address.py +95 -0
- shopware_api_client/endpoints/admin/core/order_customer.py +82 -0
- shopware_api_client/endpoints/admin/core/order_delivery.py +94 -0
- shopware_api_client/endpoints/admin/core/order_delivery_position.py +77 -0
- shopware_api_client/endpoints/admin/core/order_line_item.py +125 -0
- shopware_api_client/endpoints/admin/core/order_line_item_download.py +64 -0
- shopware_api_client/endpoints/admin/core/order_transaction.py +64 -0
- shopware_api_client/endpoints/admin/core/order_transaction_capture.py +73 -0
- shopware_api_client/endpoints/admin/core/order_transaction_capture_refund.py +72 -0
- shopware_api_client/endpoints/admin/core/order_transaction_capture_refund_position.py +80 -0
- shopware_api_client/endpoints/admin/core/payment_method.py +123 -0
- shopware_api_client/endpoints/admin/core/product.py +326 -0
- shopware_api_client/endpoints/admin/core/product_configurator_setting.py +68 -0
- shopware_api_client/endpoints/admin/core/product_cross_selling.py +73 -0
- shopware_api_client/endpoints/admin/core/product_cross_selling_assigned_products.py +60 -0
- shopware_api_client/endpoints/admin/core/product_download.py +59 -0
- shopware_api_client/endpoints/admin/core/product_export.py +114 -0
- shopware_api_client/endpoints/admin/core/product_feature_set.py +45 -0
- shopware_api_client/endpoints/admin/core/product_manufacturer.py +56 -0
- shopware_api_client/endpoints/admin/core/product_media.py +60 -0
- shopware_api_client/endpoints/admin/core/product_price.py +65 -0
- shopware_api_client/endpoints/admin/core/product_review.py +78 -0
- shopware_api_client/endpoints/admin/core/product_search_keyword.py +59 -0
- shopware_api_client/endpoints/admin/core/product_stream.py +63 -0
- shopware_api_client/endpoints/admin/core/product_visibility.py +55 -0
- shopware_api_client/endpoints/admin/core/promotion.py +114 -0
- shopware_api_client/endpoints/admin/core/promotion_discount.py +72 -0
- shopware_api_client/endpoints/admin/core/promotion_discount_prices.py +52 -0
- shopware_api_client/endpoints/admin/core/property_group.py +60 -0
- shopware_api_client/endpoints/admin/core/property_group_option.py +63 -0
- shopware_api_client/endpoints/admin/core/rule.py +68 -0
- shopware_api_client/endpoints/admin/core/sales_channel.py +246 -0
- shopware_api_client/endpoints/admin/core/sales_channel_domain.py +74 -0
- shopware_api_client/endpoints/admin/core/salutation.py +62 -0
- shopware_api_client/endpoints/admin/core/seo_url.py +79 -0
- shopware_api_client/endpoints/admin/core/shipping_method.py +88 -0
- shopware_api_client/endpoints/admin/core/state_machine.py +61 -0
- shopware_api_client/endpoints/admin/core/state_machine_history.py +76 -0
- shopware_api_client/endpoints/admin/core/state_machine_state.py +85 -0
- shopware_api_client/endpoints/admin/core/state_machine_transition.py +61 -0
- shopware_api_client/endpoints/admin/core/tag.py +54 -0
- shopware_api_client/endpoints/admin/core/tax.py +49 -0
- shopware_api_client/endpoints/admin/core/tax_rule.py +56 -0
- shopware_api_client/endpoints/admin/core/tax_rule_type.py +50 -0
- shopware_api_client/endpoints/admin/core/unit.py +49 -0
- shopware_api_client/endpoints/admin/core/user.py +81 -0
- shopware_api_client/endpoints/base_fields.py +115 -0
- shopware_api_client/endpoints/relations.py +39 -0
- shopware_api_client/endpoints/store/__init__.py +3 -0
- shopware_api_client/endpoints/store/core/__init__.py +0 -0
- shopware_api_client/endpoints/store/core/address.py +89 -0
- shopware_api_client/exceptions.py +30 -0
- shopware_api_client/logging.py +7 -0
- shopware_api_client-1.0.0.dist-info/LICENSE +21 -0
- shopware_api_client-1.0.0.dist-info/METADATA +28 -0
- shopware_api_client-1.0.0.dist-info/RECORD +91 -0
- shopware_api_client-1.0.0.dist-info/WHEEL +4 -0
|
File without changes
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, AsyncGenerator, Generic, Self, Type, TypeVar, overload
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from .endpoints.base_fields import IdField
|
|
8
|
+
from .exceptions import SWAPIError, SWFilterException, SWNoClientProvided
|
|
9
|
+
from .logging import logger
|
|
10
|
+
|
|
11
|
+
EndpointClass = TypeVar("EndpointClass", bound="EndpointBase[Any]")
|
|
12
|
+
ModelClass = TypeVar("ModelClass", bound="ApiModelBase[Any]")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfigBase:
|
|
16
|
+
def __init__(self, url: str):
|
|
17
|
+
self.url = url.rstrip("/")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ClientBase:
|
|
21
|
+
api_url: str
|
|
22
|
+
raw: bool
|
|
23
|
+
|
|
24
|
+
def __init__(self, config: ConfigBase, raw: bool = False):
|
|
25
|
+
self.api_url = config.url
|
|
26
|
+
self.raw = raw
|
|
27
|
+
|
|
28
|
+
async def __aenter__(self) -> "Self":
|
|
29
|
+
self._get_client()
|
|
30
|
+
return self
|
|
31
|
+
|
|
32
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
33
|
+
await self._get_client().aclose()
|
|
34
|
+
|
|
35
|
+
async def log_request(self, request: httpx.Request) -> None:
|
|
36
|
+
logger.debug(f"Request: {request.method} {request.url} - {request.content!r} <headers: {request.headers}>")
|
|
37
|
+
|
|
38
|
+
async def log_response(self, response: httpx.Response) -> None:
|
|
39
|
+
await response.aread()
|
|
40
|
+
logger.debug(
|
|
41
|
+
f"Response: {response.status_code} - {response.status_code} - {response.content!r} "
|
|
42
|
+
f"<headers: {response.headers}>"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def _get_client(self) -> httpx.AsyncClient:
|
|
46
|
+
raise NotImplementedError()
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def _get_headers() -> dict[str, str]:
|
|
50
|
+
return {"Content-Type": "application/json", "Accept": "application/vnd.api+json"}
|
|
51
|
+
|
|
52
|
+
async def _make_request(self, method: str, relative_url: str, **kwargs: Any) -> httpx.Response:
|
|
53
|
+
if relative_url.startswith("http://") or relative_url.startswith("https://"):
|
|
54
|
+
url = relative_url
|
|
55
|
+
else:
|
|
56
|
+
url = f"{self.api_url}{relative_url}"
|
|
57
|
+
client = self._get_client()
|
|
58
|
+
|
|
59
|
+
headers = self._get_headers()
|
|
60
|
+
headers.update(kwargs.pop("headers", {}))
|
|
61
|
+
|
|
62
|
+
response = await client.request(method, url, headers=headers, **kwargs)
|
|
63
|
+
|
|
64
|
+
if response.status_code >= 400:
|
|
65
|
+
try:
|
|
66
|
+
raise SWAPIError(response.json().get("errors"))
|
|
67
|
+
except json.JSONDecodeError:
|
|
68
|
+
raise SWAPIError(f"Status: {response.status_code}: {response.text}")
|
|
69
|
+
|
|
70
|
+
return response
|
|
71
|
+
|
|
72
|
+
async def get(self, relative_url: str, **kwargs: Any) -> httpx.Response:
|
|
73
|
+
return await self._make_request(method="GET", relative_url=relative_url, **kwargs)
|
|
74
|
+
|
|
75
|
+
async def post(self, relative_url: str, **kwargs: Any) -> httpx.Response:
|
|
76
|
+
# we need to set a response type, otherwise we don't get one
|
|
77
|
+
relative_url += "?_response=basic"
|
|
78
|
+
return await self._make_request(method="POST", relative_url=relative_url, **kwargs)
|
|
79
|
+
|
|
80
|
+
async def patch(self, relative_url: str, **kwargs: Any) -> httpx.Response:
|
|
81
|
+
# we need to set a reponse type, otherwise we don't get one
|
|
82
|
+
relative_url += "?_response=basic"
|
|
83
|
+
return await self._make_request(method="PATCH", relative_url=relative_url, **kwargs)
|
|
84
|
+
|
|
85
|
+
async def delete(self, relative_url: str, **kwargs: Any) -> httpx.Response:
|
|
86
|
+
return await self._make_request(method="DELETE", relative_url=relative_url, **kwargs)
|
|
87
|
+
|
|
88
|
+
async def close(self) -> None:
|
|
89
|
+
await self._get_client().aclose()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ApiModelBase(BaseModel, Generic[EndpointClass]):
|
|
93
|
+
_identifier: str
|
|
94
|
+
|
|
95
|
+
id: IdField | None
|
|
96
|
+
|
|
97
|
+
def __init__(self, client: ClientBase | None = None, **kwargs: dict[str, Any]) -> None:
|
|
98
|
+
super().__init__(**kwargs)
|
|
99
|
+
self._client = client
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def using(cls: type[Self], client: ClientBase) -> EndpointClass:
|
|
103
|
+
# we want a fresh endpoint
|
|
104
|
+
endpoint: EndpointClass = getattr(client, cls._identifier.get_default()).__class__(client) # type: ignore
|
|
105
|
+
return endpoint
|
|
106
|
+
|
|
107
|
+
def _get_client(self) -> ClientBase:
|
|
108
|
+
if self._client is None:
|
|
109
|
+
raise SWNoClientProvided("Model has no api client set. Use `using` to set a client.")
|
|
110
|
+
return self._client
|
|
111
|
+
|
|
112
|
+
def _get_endpoint(self) -> EndpointClass:
|
|
113
|
+
# we want a fresh endpoint
|
|
114
|
+
endpoint: EndpointClass = getattr(self._get_client(), self._identifier).__class__(self._get_client()) # type: ignore
|
|
115
|
+
return endpoint
|
|
116
|
+
|
|
117
|
+
async def save(self) -> Self | dict:
|
|
118
|
+
endpoint = self._get_endpoint()
|
|
119
|
+
|
|
120
|
+
if self.id is None:
|
|
121
|
+
result = await endpoint.create(obj=self)
|
|
122
|
+
else:
|
|
123
|
+
result = await endpoint.update(pk=self.id, obj=self)
|
|
124
|
+
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
async def delete(self) -> bool:
|
|
128
|
+
endpoint = self._get_endpoint()
|
|
129
|
+
|
|
130
|
+
# without id we can't delete
|
|
131
|
+
if self.id is None:
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
return await endpoint.delete(pk=self.id)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class EndpointBase(Generic[ModelClass]):
|
|
138
|
+
name: str
|
|
139
|
+
path: str
|
|
140
|
+
model_class: Type[ModelClass]
|
|
141
|
+
raw: bool
|
|
142
|
+
|
|
143
|
+
def __init__(self, client: ClientBase):
|
|
144
|
+
self.client = client
|
|
145
|
+
self.raw = client.raw
|
|
146
|
+
self._filter: list[dict[str, Any]] = []
|
|
147
|
+
self._limit: int | None = None
|
|
148
|
+
self._sort: list[dict[str, Any]] = []
|
|
149
|
+
|
|
150
|
+
def _is_search_query(self) -> bool:
|
|
151
|
+
return len(self._filter) > 0 or len(self._sort) > 0
|
|
152
|
+
|
|
153
|
+
def _get_data_dict(self) -> dict[str, Any]:
|
|
154
|
+
data: dict[str, Any] = {}
|
|
155
|
+
|
|
156
|
+
if len(self._filter) > 0:
|
|
157
|
+
data["filter"] = self._filter
|
|
158
|
+
|
|
159
|
+
if len(self._sort) > 0:
|
|
160
|
+
data["sort"] = self._sort
|
|
161
|
+
|
|
162
|
+
if self._limit is not None:
|
|
163
|
+
data["limit"] = self._limit
|
|
164
|
+
|
|
165
|
+
return data
|
|
166
|
+
|
|
167
|
+
def _reset_endpoint(self) -> None:
|
|
168
|
+
self._filter = []
|
|
169
|
+
self._limit = None
|
|
170
|
+
self._sort = []
|
|
171
|
+
|
|
172
|
+
@overload
|
|
173
|
+
def _parse_response(self, data: list[dict[str, Any]]) -> list[ModelClass]:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
@overload
|
|
177
|
+
def _parse_response(self, data: dict[str, Any]) -> ModelClass:
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
def _parse_response(self, data: list[dict[str, Any]] | dict[str, Any]) -> list[ModelClass] | ModelClass:
|
|
181
|
+
single = False
|
|
182
|
+
|
|
183
|
+
if isinstance(data, dict):
|
|
184
|
+
single = True
|
|
185
|
+
data = [data]
|
|
186
|
+
|
|
187
|
+
result_list: list[ModelClass] = []
|
|
188
|
+
|
|
189
|
+
for entry in data:
|
|
190
|
+
api_type = entry.get("type", None)
|
|
191
|
+
|
|
192
|
+
if api_type is None:
|
|
193
|
+
model_class = self.model_class
|
|
194
|
+
else:
|
|
195
|
+
model_class = getattr(self.client, api_type).model_class
|
|
196
|
+
|
|
197
|
+
if "attributes" in entry:
|
|
198
|
+
obj = model_class(client=self.client, id=entry["id"], **entry["attributes"])
|
|
199
|
+
else:
|
|
200
|
+
obj = model_class(client=self.client, **entry)
|
|
201
|
+
|
|
202
|
+
result_list.append(obj)
|
|
203
|
+
|
|
204
|
+
if single:
|
|
205
|
+
return result_list[0]
|
|
206
|
+
|
|
207
|
+
return result_list
|
|
208
|
+
|
|
209
|
+
def _parse_data(self, response_dict: dict[str, Any]) -> list[dict[str, Any]]:
|
|
210
|
+
if "data" in response_dict:
|
|
211
|
+
key = "data"
|
|
212
|
+
elif "elements" in response_dict:
|
|
213
|
+
key = "elements"
|
|
214
|
+
else:
|
|
215
|
+
key = None
|
|
216
|
+
|
|
217
|
+
data: list[dict[str, Any]] | dict[str, Any] = response_dict[key] if key else response_dict
|
|
218
|
+
|
|
219
|
+
if isinstance(data, dict):
|
|
220
|
+
return [data]
|
|
221
|
+
|
|
222
|
+
return data
|
|
223
|
+
|
|
224
|
+
def _prase_data_single(self, reponse_dict: dict[str, Any]) -> dict[str, Any]:
|
|
225
|
+
return self._parse_data(reponse_dict)[0]
|
|
226
|
+
|
|
227
|
+
async def all(self) -> list[ModelClass] | list[dict[str, Any]]:
|
|
228
|
+
data = self._get_data_dict()
|
|
229
|
+
|
|
230
|
+
if self._is_search_query():
|
|
231
|
+
result = await self.client.post(f"/search{self.path}", json=data)
|
|
232
|
+
else:
|
|
233
|
+
result = await self.client.get(f"{self.path}", params=data)
|
|
234
|
+
|
|
235
|
+
result_data: list[dict[str, Any]] = self._parse_data(result.json())
|
|
236
|
+
|
|
237
|
+
self._reset_endpoint()
|
|
238
|
+
|
|
239
|
+
if self.raw:
|
|
240
|
+
return result_data
|
|
241
|
+
|
|
242
|
+
return self._parse_response(result_data)
|
|
243
|
+
|
|
244
|
+
async def get(self, pk: str) -> ModelClass | dict[str, Any]:
|
|
245
|
+
result = await self.client.get(f"{self.path}/{pk}")
|
|
246
|
+
result_data: dict[str, Any] = self._prase_data_single(result.json())
|
|
247
|
+
|
|
248
|
+
if self.raw:
|
|
249
|
+
return result_data
|
|
250
|
+
|
|
251
|
+
return self._parse_response(result_data)
|
|
252
|
+
|
|
253
|
+
async def update(self, pk: str, obj: ModelClass | dict[str, Any]) -> ModelClass | dict[str, Any]:
|
|
254
|
+
if isinstance(obj, ApiModelBase):
|
|
255
|
+
data = obj.model_dump_json(by_alias=True)
|
|
256
|
+
else:
|
|
257
|
+
data = json.dumps(obj)
|
|
258
|
+
|
|
259
|
+
result = await self.client.patch(f"{self.path}/{pk}", data=data)
|
|
260
|
+
result_data: dict[str, Any] = self._prase_data_single(result.json())
|
|
261
|
+
|
|
262
|
+
if self.raw:
|
|
263
|
+
return result_data
|
|
264
|
+
|
|
265
|
+
return self._parse_response(result_data)
|
|
266
|
+
|
|
267
|
+
async def first(self) -> ModelClass | dict[str, Any] | None:
|
|
268
|
+
self._limit = 1
|
|
269
|
+
result = await self.all()
|
|
270
|
+
|
|
271
|
+
self._reset_endpoint()
|
|
272
|
+
|
|
273
|
+
# return None instead of an KeyError, if result is empty
|
|
274
|
+
if len(result) == 0:
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
return result[0]
|
|
278
|
+
|
|
279
|
+
async def create(self, obj: ModelClass | dict[str, Any]) -> ModelClass | dict[str, Any]:
|
|
280
|
+
if isinstance(obj, ApiModelBase):
|
|
281
|
+
data = obj.model_dump_json(by_alias=True)
|
|
282
|
+
else:
|
|
283
|
+
data = json.dumps(obj)
|
|
284
|
+
|
|
285
|
+
result = await self.client.post(f"{self.path}", data=data)
|
|
286
|
+
result_data: dict[str, Any] = self._prase_data_single(result.json())
|
|
287
|
+
|
|
288
|
+
if self.raw:
|
|
289
|
+
return result_data
|
|
290
|
+
|
|
291
|
+
return self._parse_response(result_data)
|
|
292
|
+
|
|
293
|
+
async def delete(self, pk: str) -> bool:
|
|
294
|
+
response = await self.client.delete(f"{self.path}/{pk}")
|
|
295
|
+
|
|
296
|
+
if response.status_code == 204:
|
|
297
|
+
return True
|
|
298
|
+
|
|
299
|
+
return False
|
|
300
|
+
|
|
301
|
+
async def get_related(self, parent: ModelClass, relation: str) -> list[ModelClass] | list[dict[str, Any]]:
|
|
302
|
+
parent_endpoint = parent._get_endpoint()
|
|
303
|
+
result = await self.client.get(f"{parent_endpoint.path}/{parent.id}/{relation}")
|
|
304
|
+
result_data: list[dict[str, Any]] = self._parse_data(result.json())
|
|
305
|
+
|
|
306
|
+
if self.raw:
|
|
307
|
+
return result_data
|
|
308
|
+
|
|
309
|
+
return self._parse_response(result_data)
|
|
310
|
+
|
|
311
|
+
def filter(self, **kwargs: str) -> Self:
|
|
312
|
+
for key, value in kwargs.items():
|
|
313
|
+
filter_term = ""
|
|
314
|
+
filter_type = "equals"
|
|
315
|
+
|
|
316
|
+
field_parts = key.split("__")
|
|
317
|
+
|
|
318
|
+
if len(field_parts) > 1:
|
|
319
|
+
filter_term = field_parts[-1]
|
|
320
|
+
|
|
321
|
+
match filter_term:
|
|
322
|
+
case "in":
|
|
323
|
+
filter_type = "equalsAny"
|
|
324
|
+
case "contains":
|
|
325
|
+
filter_type = "contains"
|
|
326
|
+
case "gt":
|
|
327
|
+
filter_type = "range"
|
|
328
|
+
case "gte":
|
|
329
|
+
filter_type = "range"
|
|
330
|
+
case "lt":
|
|
331
|
+
filter_type = "range"
|
|
332
|
+
case "lte":
|
|
333
|
+
filter_type = "range"
|
|
334
|
+
case "range":
|
|
335
|
+
filter_type = "range"
|
|
336
|
+
case "startswith":
|
|
337
|
+
filter_type = "prefix"
|
|
338
|
+
case "endswith":
|
|
339
|
+
filter_type = "suffix"
|
|
340
|
+
case _:
|
|
341
|
+
filter_term = ""
|
|
342
|
+
|
|
343
|
+
if field_parts[0] not in self.model_class.model_fields:
|
|
344
|
+
raise SWFilterException(
|
|
345
|
+
f"Unknown Field: {field_parts[0]}. Available fields: {self.model_class.model_fields.keys()}"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
if filter_term != "":
|
|
349
|
+
field_parts = field_parts[:-1]
|
|
350
|
+
|
|
351
|
+
# Todo: Maybe add filter over related attributes
|
|
352
|
+
if len(field_parts) >= 2:
|
|
353
|
+
field = "%s.%s" % (
|
|
354
|
+
self.model_class.model_fields[field_parts[0]].alias or field_parts[0],
|
|
355
|
+
".".join(field_parts[1:]),
|
|
356
|
+
)
|
|
357
|
+
else:
|
|
358
|
+
field = self.model_class.model_fields[field_parts[0]].alias or field_parts[0]
|
|
359
|
+
|
|
360
|
+
parameters = {}
|
|
361
|
+
|
|
362
|
+
# range has additional parameters
|
|
363
|
+
if filter_type == "range":
|
|
364
|
+
if filter_term == "range":
|
|
365
|
+
parameters = {"gte": value[0], "lte": value[1]}
|
|
366
|
+
else:
|
|
367
|
+
parameters = {filter_term: value}
|
|
368
|
+
|
|
369
|
+
self._filter.append({"type": filter_type, "field": field, "value": value, "parameters": parameters})
|
|
370
|
+
|
|
371
|
+
return self
|
|
372
|
+
|
|
373
|
+
async def bulk_upsert(
|
|
374
|
+
self, objs: list[ModelClass] | list[dict[str, Any]], **request_kwargs: dict[str, Any]
|
|
375
|
+
) -> dict[str, Any] | None:
|
|
376
|
+
obj_list: list[dict] = []
|
|
377
|
+
|
|
378
|
+
for obj in objs:
|
|
379
|
+
if isinstance(obj, ApiModelBase):
|
|
380
|
+
obj_list.append(obj.model_dump(by_alias=True, mode="json"))
|
|
381
|
+
else:
|
|
382
|
+
obj_list.append(obj)
|
|
383
|
+
|
|
384
|
+
data = {f"write-{self.name}": {"entity": self.name, "action": "upsert", "payload": obj_list}}
|
|
385
|
+
|
|
386
|
+
response = await self.client.post("/_action/sync", json=data, timeout=600, **request_kwargs)
|
|
387
|
+
result: dict[str, Any] = response.json()
|
|
388
|
+
|
|
389
|
+
return result
|
|
390
|
+
|
|
391
|
+
async def bulk_delete(
|
|
392
|
+
self, objs: list[ModelClass] | list[dict[str, Any]], **request_kwargs: dict[str, Any]
|
|
393
|
+
) -> dict[str, Any]:
|
|
394
|
+
obj_list: list[dict] = []
|
|
395
|
+
|
|
396
|
+
for obj in objs:
|
|
397
|
+
if isinstance(obj, ApiModelBase):
|
|
398
|
+
obj_list.append(obj.model_dump(by_alias=True, mode="json"))
|
|
399
|
+
else:
|
|
400
|
+
obj_list.append(obj)
|
|
401
|
+
|
|
402
|
+
data = {f"delete-{self.name}": {"entity": self.name, "action": "delete", "payload": obj_list}}
|
|
403
|
+
|
|
404
|
+
response = await self.client.post("/_action/sync", json=data, timeout=600, **request_kwargs)
|
|
405
|
+
result: dict[str, Any] = response.json()
|
|
406
|
+
|
|
407
|
+
return result
|
|
408
|
+
|
|
409
|
+
def limit(self, count: int | None) -> "Self":
|
|
410
|
+
self._limit = count
|
|
411
|
+
return self
|
|
412
|
+
|
|
413
|
+
def order_by(self, fields: str | tuple[str]) -> "Self":
|
|
414
|
+
if isinstance(fields, str):
|
|
415
|
+
fields = (fields,)
|
|
416
|
+
|
|
417
|
+
for field in fields:
|
|
418
|
+
if field.startswith("-"):
|
|
419
|
+
field = field[1:]
|
|
420
|
+
order = "DESC"
|
|
421
|
+
else:
|
|
422
|
+
order = "ASC"
|
|
423
|
+
|
|
424
|
+
if field not in self.model_class.model_fields:
|
|
425
|
+
raise SWFilterException(
|
|
426
|
+
f"Unknown Field: {field}. Available fields: " f"{self.model_class.model_fields.keys()}"
|
|
427
|
+
)
|
|
428
|
+
else:
|
|
429
|
+
field = self.model_class.model_fields[field].alias or field
|
|
430
|
+
|
|
431
|
+
self._sort.append({"field": field, "order": order})
|
|
432
|
+
|
|
433
|
+
return self
|
|
434
|
+
|
|
435
|
+
async def iter(self, batch_size: int = 100) -> AsyncGenerator[ModelClass | dict[str, Any], None]:
|
|
436
|
+
self._limit = batch_size
|
|
437
|
+
data = self._get_data_dict()
|
|
438
|
+
page = 1
|
|
439
|
+
|
|
440
|
+
if self._is_search_query():
|
|
441
|
+
url = f"/search{self.path}"
|
|
442
|
+
else:
|
|
443
|
+
url = self.path
|
|
444
|
+
|
|
445
|
+
while True:
|
|
446
|
+
data["page"] = page
|
|
447
|
+
if self._is_search_query():
|
|
448
|
+
result = await self.client.post(url, json=data)
|
|
449
|
+
else:
|
|
450
|
+
result = await self.client.get(url, params=data)
|
|
451
|
+
|
|
452
|
+
result_dict: dict[str, Any] = result.json()
|
|
453
|
+
result_data: list[dict[str, Any]] = self._parse_data(result_dict)
|
|
454
|
+
|
|
455
|
+
for entry in result_data:
|
|
456
|
+
if self.raw:
|
|
457
|
+
yield entry
|
|
458
|
+
else:
|
|
459
|
+
yield self._parse_response(entry)
|
|
460
|
+
|
|
461
|
+
if "next" in result_dict.get("links", {}) and len(result_data) > 0:
|
|
462
|
+
page += 1
|
|
463
|
+
else:
|
|
464
|
+
break
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from httpx_auth import OAuth2ClientCredentials, OAuth2ResourceOwnerPasswordCredentials
|
|
3
|
+
|
|
4
|
+
from .base import ApiModelBase, ClientBase, EndpointBase
|
|
5
|
+
from .config import AdminConfig, StoreConfig
|
|
6
|
+
from .exceptions import SWAPIConfigException
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AdminClient(ClientBase):
|
|
10
|
+
def __init__(self, config: AdminConfig, raw: bool = False):
|
|
11
|
+
super().__init__(config, raw=raw)
|
|
12
|
+
self.config = config
|
|
13
|
+
self.api_url = f"{config.url}/api"
|
|
14
|
+
self._client: httpx.AsyncClient | None = None
|
|
15
|
+
registry.add_admin_endpoints(self)
|
|
16
|
+
|
|
17
|
+
def _get_client(self) -> httpx.AsyncClient:
|
|
18
|
+
if self._client is None:
|
|
19
|
+
self._client = httpx.AsyncClient(
|
|
20
|
+
event_hooks={"request": [self.log_request], "response": [self.log_response]}
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
auth_url = f"{self.api_url}/oauth/token"
|
|
24
|
+
|
|
25
|
+
if self.config.grant_type == "client_credentials":
|
|
26
|
+
if self.config.client_id is not None and self.config.client_secret is not None:
|
|
27
|
+
self._client.auth = OAuth2ClientCredentials(
|
|
28
|
+
token_url=auth_url, client_id=self.config.client_id, client_secret=self.config.client_secret
|
|
29
|
+
)
|
|
30
|
+
else:
|
|
31
|
+
raise SWAPIConfigException("Missing Client Credentials.")
|
|
32
|
+
elif self.config.grant_type == "password":
|
|
33
|
+
if self.config.username is not None and self.config.password is not None:
|
|
34
|
+
self._client.auth = OAuth2ResourceOwnerPasswordCredentials(
|
|
35
|
+
token_url=auth_url, username=self.config.username, password=self.config.password
|
|
36
|
+
)
|
|
37
|
+
else:
|
|
38
|
+
raise SWAPIConfigException("Missing Client Credentials.")
|
|
39
|
+
else:
|
|
40
|
+
raise SWAPIConfigException("Invalid grant_type for AdminClient.")
|
|
41
|
+
|
|
42
|
+
return self._client
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class StoreClient(ClientBase):
|
|
46
|
+
def __init__(self, config: StoreConfig, raw: bool = False):
|
|
47
|
+
super().__init__(config, raw=raw)
|
|
48
|
+
self.config = config
|
|
49
|
+
self.api_url = f"{config.url}/store-api"
|
|
50
|
+
self._client: httpx.AsyncClient | None = None
|
|
51
|
+
registry.add_store_endpoints(self)
|
|
52
|
+
|
|
53
|
+
def _get_client(self) -> httpx.AsyncClient:
|
|
54
|
+
if self._client is None:
|
|
55
|
+
self._client = httpx.AsyncClient(
|
|
56
|
+
event_hooks={"request": [self.log_request], "response": [self.log_response]},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
headers = {
|
|
60
|
+
"sw-access-key": self.config.access_key,
|
|
61
|
+
"Accept": "application/json",
|
|
62
|
+
"Content-Type": "application/json",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if self.config.context_token is not None:
|
|
66
|
+
headers["sw-context-token"] = self.config.context_token
|
|
67
|
+
|
|
68
|
+
self._client.headers = httpx.Headers(headers)
|
|
69
|
+
|
|
70
|
+
return self._client
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class EndpointRegistry:
|
|
74
|
+
def __init__(self) -> None:
|
|
75
|
+
self.admin_endpoints: dict[str, type[EndpointBase]] = {}
|
|
76
|
+
self.store_endpoints: dict[str, type[EndpointBase]] = {}
|
|
77
|
+
self.admin_models: dict[str, type[ApiModelBase]] = {}
|
|
78
|
+
self.store_models: dict[str, type[ApiModelBase]] = {}
|
|
79
|
+
|
|
80
|
+
def register_admin(self, endpoint_class: type[EndpointBase]) -> None:
|
|
81
|
+
self.admin_endpoints[endpoint_class.name] = endpoint_class
|
|
82
|
+
self.admin_models[endpoint_class.model_class.__name__] = endpoint_class.model_class
|
|
83
|
+
|
|
84
|
+
def register_store(self, endpoint_class: type[EndpointBase]) -> None:
|
|
85
|
+
self.store_endpoints[endpoint_class.name] = endpoint_class
|
|
86
|
+
self.store_models[endpoint_class.model_class.__name__] = endpoint_class.model_class
|
|
87
|
+
|
|
88
|
+
def add_admin_endpoints(self, client: AdminClient) -> None:
|
|
89
|
+
for name, cls in self.admin_endpoints.items():
|
|
90
|
+
setattr(client, name, cls(client))
|
|
91
|
+
|
|
92
|
+
def get_admin_model(self, name: str) -> type[ApiModelBase]:
|
|
93
|
+
return self.admin_models[name]
|
|
94
|
+
|
|
95
|
+
def add_store_endpoints(self, client: StoreClient) -> None:
|
|
96
|
+
for name, cls in self.store_endpoints.items():
|
|
97
|
+
setattr(client, name, cls(client))
|
|
98
|
+
|
|
99
|
+
def get_store_model(self, name: str) -> type[ApiModelBase]:
|
|
100
|
+
return self.store_models[name]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
registry = EndpointRegistry()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from .base import ConfigBase
|
|
2
|
+
from .exceptions import SWAPIConfigException
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AdminConfig(ConfigBase):
|
|
6
|
+
def __init__(
|
|
7
|
+
self,
|
|
8
|
+
url: str,
|
|
9
|
+
username: str | None = None,
|
|
10
|
+
password: str | None = None,
|
|
11
|
+
client_id: str | None = None,
|
|
12
|
+
client_secret: str | None = None,
|
|
13
|
+
grant_type: str = "client_credentials",
|
|
14
|
+
) -> None:
|
|
15
|
+
match grant_type:
|
|
16
|
+
case "client_credentials":
|
|
17
|
+
if client_id is None or client_secret is None:
|
|
18
|
+
raise SWAPIConfigException(
|
|
19
|
+
"'client_id' and 'client_secret' must be set for grant_type 'client_credentials'"
|
|
20
|
+
)
|
|
21
|
+
case "password":
|
|
22
|
+
if username is None or password is None:
|
|
23
|
+
raise SWAPIConfigException("'username' and 'password' must be set for grant_type 'password'")
|
|
24
|
+
case _:
|
|
25
|
+
raise SWAPIConfigException("Invalid 'grant_type'. Must be one of: 'client_credentials', 'password'")
|
|
26
|
+
|
|
27
|
+
super().__init__(url=url)
|
|
28
|
+
self.username = username
|
|
29
|
+
self.password = password
|
|
30
|
+
self.client_id = client_id
|
|
31
|
+
self.client_secret = client_secret
|
|
32
|
+
self.grant_type = grant_type
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class StoreConfig(ConfigBase):
|
|
36
|
+
def __init__(self, url: str, access_key: str, context_token: str | None = None):
|
|
37
|
+
super().__init__(url=url)
|
|
38
|
+
self.access_key = access_key
|
|
39
|
+
self.context_token = context_token
|
|
File without changes
|