feat: Add Booklooker client configuration and exception handling
- Introduced BooklookerConfig class for runtime configuration management. - Created custom exceptions for API errors, including authentication and validation errors. - Generated API contracts from OpenAPI specification, including endpoints and security schemes. - Implemented models for articles, orders, and webhooks to facilitate data handling. - Developed a webhook helper for processing and enriching webhook events. - Added tests for configuration defaults, token expiration, and webhook enrichment.
This commit is contained in:
commit
1d8ee1bba6
21 changed files with 3009 additions and 0 deletions
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.venv/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
36
README.md
Normal file
36
README.md
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# booklooker-client
|
||||||
|
|
||||||
|
A Python client for the Booklooker REST API designed for middleware and connector-hub scenarios.
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- Generated API contract derived from [openapi.yaml](openapi.yaml)
|
||||||
|
- Sync and async client entrypoints
|
||||||
|
- Pydantic models for normalized responses
|
||||||
|
- Token auto-refresh handling
|
||||||
|
- Webhook helper toolbox for Push-API style events
|
||||||
|
- FastAPI example receiver and enrichment workflow
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .[dev,webhooks]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```python
|
||||||
|
from booklooker_client import BooklookerConfig, SyncBooklookerClient
|
||||||
|
|
||||||
|
config = BooklookerConfig(api_key="YOUR_API_KEY")
|
||||||
|
client = SyncBooklookerClient(config)
|
||||||
|
|
||||||
|
token = client.authenticate()
|
||||||
|
articles = client.get_article_list()
|
||||||
|
print(token.token)
|
||||||
|
print(articles.items)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Booklooker wraps nearly all responses into a generic envelope with `status` and `returnValue`. This package normalizes those responses into typed Pydantic models so downstream middleware can work with stable structures.
|
||||||
14
examples/sync_usage.py
Normal file
14
examples/sync_usage.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from booklooker_client import BooklookerConfig, SyncBooklookerClient
|
||||||
|
|
||||||
|
|
||||||
|
api_key = os.environ.get("BOOKLOOKER_API_KEY", "REPLACE_ME")
|
||||||
|
config = BooklookerConfig(api_key=api_key)
|
||||||
|
|
||||||
|
with SyncBooklookerClient(config) as client:
|
||||||
|
token = client.authenticate()
|
||||||
|
print("Token acquired:", token.token)
|
||||||
|
print("Available endpoints:", client.available_endpoints)
|
||||||
19
examples/webhook_fastapi.py
Normal file
19
examples/webhook_fastapi.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
|
||||||
|
from booklooker_client import BooklookerConfig, BooklookerWebhookHelper, SyncBooklookerClient
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Booklooker webhook receiver")
|
||||||
|
helper = BooklookerWebhookHelper()
|
||||||
|
client = SyncBooklookerClient(BooklookerConfig(api_key=os.environ.get("BOOKLOOKER_API_KEY", "REPLACE_ME")))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/webhooks/booklooker")
|
||||||
|
async def receive_booklooker_webhook(request: Request) -> dict:
|
||||||
|
payload = await request.json()
|
||||||
|
event = helper.enrich_with_client(payload, client)
|
||||||
|
return {"accepted": True, "event": event.model_dump(mode="json")}
|
||||||
1620
openapi.yaml
Normal file
1620
openapi.yaml
Normal file
File diff suppressed because it is too large
Load diff
38
pyproject.toml
Normal file
38
pyproject.toml
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "booklooker-client"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Python client for the Booklooker REST API with Pydantic models and webhook helpers"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
authors = [
|
||||||
|
{ name = "GitHub Copilot" }
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"httpx>=0.27.0",
|
||||||
|
"pydantic>=2.8.2",
|
||||||
|
"PyYAML>=6.0.2"
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
webhooks = [
|
||||||
|
"fastapi>=0.115.0",
|
||||||
|
"uvicorn>=0.30.6"
|
||||||
|
]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.3.3",
|
||||||
|
"pytest-asyncio>=0.24.0"
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.package-dir]
|
||||||
|
"" = "src"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "-q"
|
||||||
11
src/booklooker_client/__init__.py
Normal file
11
src/booklooker_client/__init__.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
from .client import AsyncBooklookerClient, SyncBooklookerClient
|
||||||
|
from .config import BooklookerConfig
|
||||||
|
from .webhooks import BooklookerWebhookHelper, InMemoryIdempotencyStore
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AsyncBooklookerClient",
|
||||||
|
"SyncBooklookerClient",
|
||||||
|
"BooklookerConfig",
|
||||||
|
"BooklookerWebhookHelper",
|
||||||
|
"InMemoryIdempotencyStore",
|
||||||
|
]
|
||||||
510
src/booklooker_client/client.py
Normal file
510
src/booklooker_client/client.py
Normal file
|
|
@ -0,0 +1,510 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .config import BooklookerConfig
|
||||||
|
from .exceptions import raise_for_error_code
|
||||||
|
from .generated.contracts import ENDPOINTS
|
||||||
|
from .models.article import (
|
||||||
|
ArticleDeleteResult,
|
||||||
|
ArticleField,
|
||||||
|
ArticleList,
|
||||||
|
ArticleListItem,
|
||||||
|
ArticleStatus,
|
||||||
|
ArticleStatusResult,
|
||||||
|
MediaType,
|
||||||
|
)
|
||||||
|
from .models.common import (
|
||||||
|
ApiEnvelope,
|
||||||
|
AuthToken,
|
||||||
|
FileImportErrorRecord,
|
||||||
|
FileStatusResult,
|
||||||
|
GenericResult,
|
||||||
|
ImportQueueStatus,
|
||||||
|
SearchResult,
|
||||||
|
UploadReceipt,
|
||||||
|
)
|
||||||
|
from .models.order import MessageType, OrderBatch, OrderRecord, OrderStatus
|
||||||
|
|
||||||
|
|
||||||
|
def _now_utc() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_enum_value(value: Any) -> Any:
|
||||||
|
return getattr(value, "value", value)
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_file(path: Path, chunk_size: int = 65536) -> Iterable[bytes]:
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
while True:
|
||||||
|
chunk = handle.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_article_list(raw: Any, field: ArticleField) -> ArticleList:
|
||||||
|
if raw in (None, ""):
|
||||||
|
return ArticleList(items=[], field=field, raw=raw)
|
||||||
|
|
||||||
|
items: list[ArticleListItem] = []
|
||||||
|
for line in str(raw).splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = [part.strip() for part in line.split("\t")]
|
||||||
|
item = ArticleListItem(value=parts[0])
|
||||||
|
if len(parts) > 1:
|
||||||
|
try:
|
||||||
|
item.price = Decimal(parts[1].replace(",", "."))
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
pass
|
||||||
|
if len(parts) > 2:
|
||||||
|
try:
|
||||||
|
item.stock = int(parts[2])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
items.append(item)
|
||||||
|
|
||||||
|
return ArticleList(items=items, field=field, raw=raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_file_status(filename: str, raw: Any, show_errors: bool) -> FileStatusResult:
|
||||||
|
if show_errors and isinstance(raw, list):
|
||||||
|
return FileStatusResult(
|
||||||
|
filename=filename,
|
||||||
|
state="UPLOAD_DONE",
|
||||||
|
errors=[FileImportErrorRecord.model_validate(item) for item in raw],
|
||||||
|
raw=raw,
|
||||||
|
)
|
||||||
|
|
||||||
|
return FileStatusResult(filename=filename, state=None if raw is None else str(raw), raw=raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_orders(raw: Any) -> OrderBatch:
|
||||||
|
if raw in (None, ""):
|
||||||
|
return OrderBatch(orders=[], raw=raw)
|
||||||
|
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
if isinstance(raw.get("orders"), list):
|
||||||
|
records = raw["orders"]
|
||||||
|
else:
|
||||||
|
records = [raw]
|
||||||
|
elif isinstance(raw, list):
|
||||||
|
records = raw
|
||||||
|
else:
|
||||||
|
records = [{"raw": raw}]
|
||||||
|
|
||||||
|
parsed = [OrderRecord.model_validate(item) if isinstance(item, dict) else OrderRecord() for item in records]
|
||||||
|
return OrderBatch(orders=parsed, raw=raw)
|
||||||
|
|
||||||
|
|
||||||
|
class _SyncClientBase:
|
||||||
|
def __init__(self, config: BooklookerConfig) -> None:
|
||||||
|
self.config = config
|
||||||
|
self._token: AuthToken | None = None
|
||||||
|
self._http = httpx.Client(
|
||||||
|
base_url=self.config.base_url,
|
||||||
|
timeout=self.config.timeout,
|
||||||
|
headers={"User-Agent": self.config.user_agent},
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._http.close()
|
||||||
|
|
||||||
|
def __enter__(self) -> "_SyncClientBase":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_: Any) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available_endpoints(self) -> list[str]:
|
||||||
|
return list(ENDPOINTS.keys())
|
||||||
|
|
||||||
|
def authenticate(self) -> AuthToken:
|
||||||
|
raw = self._request("POST", "/authenticate", params={"apiKey": self.config.api_key}, requires_auth=False)
|
||||||
|
self._token = AuthToken(token=str(raw), expires_after_seconds=self.config.token_idle_timeout_seconds)
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
def _get_token(self) -> AuthToken:
|
||||||
|
if self._token is None or self._token.expired:
|
||||||
|
return self.authenticate()
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
requires_auth: bool = True,
|
||||||
|
retry_on_expired_token: bool = True,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
content: Any = None,
|
||||||
|
) -> Any:
|
||||||
|
request_params = dict(params or {})
|
||||||
|
if requires_auth:
|
||||||
|
token = self._get_token()
|
||||||
|
request_params.setdefault("token", token.token)
|
||||||
|
|
||||||
|
response = self._http.request(method, path, params=request_params, headers=headers, content=content)
|
||||||
|
response.raise_for_status()
|
||||||
|
envelope = ApiEnvelope.model_validate(response.json())
|
||||||
|
|
||||||
|
if envelope.status == "OK":
|
||||||
|
if requires_auth and self._token is not None:
|
||||||
|
self._token.acquired_at = _now_utc()
|
||||||
|
return envelope.returnValue
|
||||||
|
|
||||||
|
code = str(envelope.returnValue)
|
||||||
|
if code == "TOKEN_EXPIRED" and requires_auth and retry_on_expired_token and self.config.auto_refresh_token:
|
||||||
|
self._token = None
|
||||||
|
self.authenticate()
|
||||||
|
return self._request(
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
params=params,
|
||||||
|
requires_auth=requires_auth,
|
||||||
|
retry_on_expired_token=False,
|
||||||
|
headers=headers,
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
|
||||||
|
raise_for_error_code(code)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncBooklookerClient(_SyncClientBase):
|
||||||
|
def get_article_list(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
field: ArticleField = ArticleField.ORDER_NO,
|
||||||
|
show_price: bool = False,
|
||||||
|
show_stock: bool = False,
|
||||||
|
media_type: MediaType | int | None = None,
|
||||||
|
) -> ArticleList:
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"field": field.value,
|
||||||
|
"showPrice": int(show_price),
|
||||||
|
"showStock": int(show_stock),
|
||||||
|
}
|
||||||
|
if media_type is not None:
|
||||||
|
params["mediaType"] = _resolve_enum_value(media_type)
|
||||||
|
raw = self._request("GET", "/article_list", params=params)
|
||||||
|
return _parse_article_list(raw, field)
|
||||||
|
|
||||||
|
def delete_article(self, order_no: str) -> ArticleDeleteResult:
|
||||||
|
raw = self._request("DELETE", "/article", params={"orderNo": order_no})
|
||||||
|
return ArticleDeleteResult(order_no=order_no, result=str(raw))
|
||||||
|
|
||||||
|
def get_article_status(self, order_no: str) -> ArticleStatusResult:
|
||||||
|
raw = self._request("GET", "/article_status", params={"orderNo": order_no})
|
||||||
|
return ArticleStatusResult(order_no=order_no, status=ArticleStatus(str(raw)))
|
||||||
|
|
||||||
|
def search(self, **params: Any) -> SearchResult:
|
||||||
|
raw = self._request("GET", "/search", params=params or None)
|
||||||
|
return SearchResult(raw=raw)
|
||||||
|
|
||||||
|
def import_file(
|
||||||
|
self,
|
||||||
|
file_path: str | Path,
|
||||||
|
*,
|
||||||
|
data_type: int,
|
||||||
|
file_type: str = "article",
|
||||||
|
media_type: MediaType | int = MediaType.BOOKS,
|
||||||
|
format_id: int | None = None,
|
||||||
|
encoding: str | None = None,
|
||||||
|
) -> UploadReceipt:
|
||||||
|
path = Path(file_path)
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"fileType": file_type,
|
||||||
|
"dataType": data_type,
|
||||||
|
"mediaType": _resolve_enum_value(media_type),
|
||||||
|
}
|
||||||
|
if format_id is not None:
|
||||||
|
params["formatID"] = format_id
|
||||||
|
if encoding is not None:
|
||||||
|
params["encoding"] = encoding
|
||||||
|
|
||||||
|
raw = self._request(
|
||||||
|
"POST",
|
||||||
|
"/file_import",
|
||||||
|
params=params,
|
||||||
|
headers={"Content-Type": "application/octet-stream"},
|
||||||
|
content=_iter_file(path),
|
||||||
|
)
|
||||||
|
return UploadReceipt(filename=path.name, result=str(raw), raw=raw)
|
||||||
|
|
||||||
|
def get_file_status(self, filename: str, *, show_errors: bool = False) -> FileStatusResult:
|
||||||
|
raw = self._request("GET", "/file_status", params={"filename": filename, "showErrors": int(show_errors)})
|
||||||
|
return _parse_file_status(filename, raw, show_errors)
|
||||||
|
|
||||||
|
def get_import_status(self) -> ImportQueueStatus:
|
||||||
|
raw = self._request("GET", "/import_status")
|
||||||
|
try:
|
||||||
|
pending = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pending = 0
|
||||||
|
return ImportQueueStatus(pending_files=pending, raw=raw)
|
||||||
|
|
||||||
|
def delete_image(self, order_no: str, position: int | None = None) -> GenericResult:
|
||||||
|
params: dict[str, Any] = {"orderNo": order_no}
|
||||||
|
if position is not None:
|
||||||
|
params["position"] = position
|
||||||
|
raw = self._request("DELETE", "/image", params=params)
|
||||||
|
return GenericResult(success=str(raw) == "SUCCESS", raw=raw)
|
||||||
|
|
||||||
|
def get_orders(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
order_id: int | None = None,
|
||||||
|
date: str | None = None,
|
||||||
|
date_from: str | None = None,
|
||||||
|
date_to: str | None = None,
|
||||||
|
) -> OrderBatch:
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
if order_id is not None:
|
||||||
|
params["orderId"] = order_id
|
||||||
|
if date:
|
||||||
|
params["date"] = date
|
||||||
|
if date_from:
|
||||||
|
params["dateFrom"] = date_from
|
||||||
|
if date_to:
|
||||||
|
params["dateTo"] = date_to
|
||||||
|
raw = self._request("GET", "/order", params=params)
|
||||||
|
return _parse_orders(raw)
|
||||||
|
|
||||||
|
def cancel_order(self, order_id: int) -> GenericResult:
|
||||||
|
raw = self._request("PUT", "/order_cancel", params={"orderId": order_id})
|
||||||
|
return GenericResult(success=str(raw) == "SUCCESS", raw=raw)
|
||||||
|
|
||||||
|
def cancel_order_item(self, order_item_id: int, media_type: MediaType | int) -> GenericResult:
|
||||||
|
raw = self._request(
|
||||||
|
"PUT",
|
||||||
|
"/order_item_cancel",
|
||||||
|
params={"orderItemId": order_item_id, "mediaType": _resolve_enum_value(media_type)},
|
||||||
|
)
|
||||||
|
return GenericResult(success=str(raw) == "SUCCESS", raw=raw)
|
||||||
|
|
||||||
|
def send_order_message(
|
||||||
|
self,
|
||||||
|
order_id: int,
|
||||||
|
message_type: MessageType | str,
|
||||||
|
additional_text: str | None = None,
|
||||||
|
) -> GenericResult:
|
||||||
|
params: dict[str, Any] = {"orderId": order_id, "messageType": _resolve_enum_value(message_type)}
|
||||||
|
if additional_text:
|
||||||
|
params["additionalText"] = additional_text
|
||||||
|
raw = self._request("PUT", "/order_message", params=params)
|
||||||
|
return GenericResult(success=True, raw=raw)
|
||||||
|
|
||||||
|
def update_order_status(self, order_id: int, status: OrderStatus | str) -> GenericResult:
|
||||||
|
raw = self._request("PUT", "/order_status", params={"orderId": order_id, "status": _resolve_enum_value(status)})
|
||||||
|
return GenericResult(success=str(raw) == "SUCCESS", raw=raw)
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncBooklookerClient:
|
||||||
|
def __init__(self, config: BooklookerConfig) -> None:
|
||||||
|
self.config = config
|
||||||
|
self._token: AuthToken | None = None
|
||||||
|
self._http = httpx.AsyncClient(
|
||||||
|
base_url=self.config.base_url,
|
||||||
|
timeout=self.config.timeout,
|
||||||
|
headers={"User-Agent": self.config.user_agent},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
await self._http.aclose()
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "AsyncBooklookerClient":
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_: Any) -> None:
|
||||||
|
await self.aclose()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available_endpoints(self) -> list[str]:
|
||||||
|
return list(ENDPOINTS.keys())
|
||||||
|
|
||||||
|
async def authenticate(self) -> AuthToken:
|
||||||
|
raw = await self._request("POST", "/authenticate", params={"apiKey": self.config.api_key}, requires_auth=False)
|
||||||
|
self._token = AuthToken(token=str(raw), expires_after_seconds=self.config.token_idle_timeout_seconds)
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
async def _get_token(self) -> AuthToken:
|
||||||
|
if self._token is None or self._token.expired:
|
||||||
|
return await self.authenticate()
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
requires_auth: bool = True,
|
||||||
|
retry_on_expired_token: bool = True,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
content: Any = None,
|
||||||
|
) -> Any:
|
||||||
|
request_params = dict(params or {})
|
||||||
|
if requires_auth:
|
||||||
|
token = await self._get_token()
|
||||||
|
request_params.setdefault("token", token.token)
|
||||||
|
|
||||||
|
response = await self._http.request(method, path, params=request_params, headers=headers, content=content)
|
||||||
|
response.raise_for_status()
|
||||||
|
envelope = ApiEnvelope.model_validate(response.json())
|
||||||
|
|
||||||
|
if envelope.status == "OK":
|
||||||
|
if requires_auth and self._token is not None:
|
||||||
|
self._token.acquired_at = _now_utc()
|
||||||
|
return envelope.returnValue
|
||||||
|
|
||||||
|
code = str(envelope.returnValue)
|
||||||
|
if code == "TOKEN_EXPIRED" and requires_auth and retry_on_expired_token and self.config.auto_refresh_token:
|
||||||
|
self._token = None
|
||||||
|
await self.authenticate()
|
||||||
|
return await self._request(
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
params=params,
|
||||||
|
requires_auth=requires_auth,
|
||||||
|
retry_on_expired_token=False,
|
||||||
|
headers=headers,
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
|
||||||
|
raise_for_error_code(code)
|
||||||
|
|
||||||
|
async def get_article_list(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
field: ArticleField = ArticleField.ORDER_NO,
|
||||||
|
show_price: bool = False,
|
||||||
|
show_stock: bool = False,
|
||||||
|
media_type: MediaType | int | None = None,
|
||||||
|
) -> ArticleList:
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"field": field.value,
|
||||||
|
"showPrice": int(show_price),
|
||||||
|
"showStock": int(show_stock),
|
||||||
|
}
|
||||||
|
if media_type is not None:
|
||||||
|
params["mediaType"] = _resolve_enum_value(media_type)
|
||||||
|
raw = await self._request("GET", "/article_list", params=params)
|
||||||
|
return _parse_article_list(raw, field)
|
||||||
|
|
||||||
|
async def delete_article(self, order_no: str) -> ArticleDeleteResult:
|
||||||
|
raw = await self._request("DELETE", "/article", params={"orderNo": order_no})
|
||||||
|
return ArticleDeleteResult(order_no=order_no, result=str(raw))
|
||||||
|
|
||||||
|
async def get_article_status(self, order_no: str) -> ArticleStatusResult:
|
||||||
|
raw = await self._request("GET", "/article_status", params={"orderNo": order_no})
|
||||||
|
return ArticleStatusResult(order_no=order_no, status=ArticleStatus(str(raw)))
|
||||||
|
|
||||||
|
async def search(self, **params: Any) -> SearchResult:
|
||||||
|
raw = await self._request("GET", "/search", params=params or None)
|
||||||
|
return SearchResult(raw=raw)
|
||||||
|
|
||||||
|
async def import_file(
|
||||||
|
self,
|
||||||
|
file_path: str | Path,
|
||||||
|
*,
|
||||||
|
data_type: int,
|
||||||
|
file_type: str = "article",
|
||||||
|
media_type: MediaType | int = MediaType.BOOKS,
|
||||||
|
format_id: int | None = None,
|
||||||
|
encoding: str | None = None,
|
||||||
|
) -> UploadReceipt:
|
||||||
|
path = Path(file_path)
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"fileType": file_type,
|
||||||
|
"dataType": data_type,
|
||||||
|
"mediaType": _resolve_enum_value(media_type),
|
||||||
|
}
|
||||||
|
if format_id is not None:
|
||||||
|
params["formatID"] = format_id
|
||||||
|
if encoding is not None:
|
||||||
|
params["encoding"] = encoding
|
||||||
|
|
||||||
|
raw = await self._request(
|
||||||
|
"POST",
|
||||||
|
"/file_import",
|
||||||
|
params=params,
|
||||||
|
headers={"Content-Type": "application/octet-stream"},
|
||||||
|
content=_iter_file(path),
|
||||||
|
)
|
||||||
|
return UploadReceipt(filename=path.name, result=str(raw), raw=raw)
|
||||||
|
|
||||||
|
async def get_file_status(self, filename: str, *, show_errors: bool = False) -> FileStatusResult:
|
||||||
|
raw = await self._request("GET", "/file_status", params={"filename": filename, "showErrors": int(show_errors)})
|
||||||
|
return _parse_file_status(filename, raw, show_errors)
|
||||||
|
|
||||||
|
async def get_import_status(self) -> ImportQueueStatus:
|
||||||
|
raw = await self._request("GET", "/import_status")
|
||||||
|
try:
|
||||||
|
pending = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pending = 0
|
||||||
|
return ImportQueueStatus(pending_files=pending, raw=raw)
|
||||||
|
|
||||||
|
async def delete_image(self, order_no: str, position: int | None = None) -> GenericResult:
|
||||||
|
params: dict[str, Any] = {"orderNo": order_no}
|
||||||
|
if position is not None:
|
||||||
|
params["position"] = position
|
||||||
|
raw = await self._request("DELETE", "/image", params=params)
|
||||||
|
return GenericResult(success=str(raw) == "SUCCESS", raw=raw)
|
||||||
|
|
||||||
|
async def get_orders(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
order_id: int | None = None,
|
||||||
|
date: str | None = None,
|
||||||
|
date_from: str | None = None,
|
||||||
|
date_to: str | None = None,
|
||||||
|
) -> OrderBatch:
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
if order_id is not None:
|
||||||
|
params["orderId"] = order_id
|
||||||
|
if date:
|
||||||
|
params["date"] = date
|
||||||
|
if date_from:
|
||||||
|
params["dateFrom"] = date_from
|
||||||
|
if date_to:
|
||||||
|
params["dateTo"] = date_to
|
||||||
|
raw = await self._request("GET", "/order", params=params)
|
||||||
|
return _parse_orders(raw)
|
||||||
|
|
||||||
|
async def cancel_order(self, order_id: int) -> GenericResult:
|
||||||
|
raw = await self._request("PUT", "/order_cancel", params={"orderId": order_id})
|
||||||
|
return GenericResult(success=str(raw) == "SUCCESS", raw=raw)
|
||||||
|
|
||||||
|
async def cancel_order_item(self, order_item_id: int, media_type: MediaType | int) -> GenericResult:
|
||||||
|
raw = await self._request(
|
||||||
|
"PUT",
|
||||||
|
"/order_item_cancel",
|
||||||
|
params={"orderItemId": order_item_id, "mediaType": _resolve_enum_value(media_type)},
|
||||||
|
)
|
||||||
|
return GenericResult(success=str(raw) == "SUCCESS", raw=raw)
|
||||||
|
|
||||||
|
async def send_order_message(
|
||||||
|
self,
|
||||||
|
order_id: int,
|
||||||
|
message_type: MessageType | str,
|
||||||
|
additional_text: str | None = None,
|
||||||
|
) -> GenericResult:
|
||||||
|
params: dict[str, Any] = {"orderId": order_id, "messageType": _resolve_enum_value(message_type)}
|
||||||
|
if additional_text:
|
||||||
|
params["additionalText"] = additional_text
|
||||||
|
raw = await self._request("PUT", "/order_message", params=params)
|
||||||
|
return GenericResult(success=True, raw=raw)
|
||||||
|
|
||||||
|
async def update_order_status(self, order_id: int, status: OrderStatus | str) -> GenericResult:
|
||||||
|
raw = await self._request("PUT", "/order_status", params={"orderId": order_id, "status": _resolve_enum_value(status)})
|
||||||
|
return GenericResult(success=str(raw) == "SUCCESS", raw=raw)
|
||||||
19
src/booklooker_client/config.py
Normal file
19
src/booklooker_client/config.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class BooklookerConfig(BaseModel):
|
||||||
|
"""Runtime configuration for Booklooker clients."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
api_key: str = Field(..., min_length=1)
|
||||||
|
base_url: str = Field(default="https://api.booklooker.de/2.0")
|
||||||
|
timeout: float = Field(default=30.0, gt=0)
|
||||||
|
user_agent: str = Field(default="booklooker-client/0.1.0")
|
||||||
|
auto_refresh_token: bool = Field(default=True)
|
||||||
|
token_idle_timeout_seconds: int = Field(default=600, ge=60)
|
||||||
|
openapi_path: Path = Field(default_factory=lambda: Path(__file__).resolve().parents[2] / "openapi.yaml")
|
||||||
68
src/booklooker_client/exceptions.py
Normal file
68
src/booklooker_client/exceptions.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class BooklookerError(Exception):
|
||||||
|
"""Base exception for the package."""
|
||||||
|
|
||||||
|
|
||||||
|
class ApiEnvelopeError(BooklookerError):
|
||||||
|
def __init__(self, code: str, detail: str | None = None) -> None:
|
||||||
|
self.code = code
|
||||||
|
self.detail = detail or code
|
||||||
|
super().__init__(self.detail)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationError(ApiEnvelopeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TokenExpiredError(AuthenticationError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitError(ApiEnvelopeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundError(ApiEnvelopeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationApiError(ApiEnvelopeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UploadError(ApiEnvelopeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ServerDownError(ApiEnvelopeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_ERROR_MAP: dict[str, type[ApiEnvelopeError]] = {
|
||||||
|
"AUTHENTICATION_FAILED": AuthenticationError,
|
||||||
|
"API_KEY_MISSING": AuthenticationError,
|
||||||
|
"TOKEN_MISSING": AuthenticationError,
|
||||||
|
"TOKEN_UNKNOWN": AuthenticationError,
|
||||||
|
"TOKEN_EXPIRED": TokenExpiredError,
|
||||||
|
"QUOTA_EXCEEDED": RateLimitError,
|
||||||
|
"NOT_FOUND": NotFoundError,
|
||||||
|
"INVALID_PARAMETERS": ValidationApiError,
|
||||||
|
"INVALID_ORDERID": ValidationApiError,
|
||||||
|
"INVALID_DATE": ValidationApiError,
|
||||||
|
"INVALID_DATE_FROM": ValidationApiError,
|
||||||
|
"INVALID_DATE_TO": ValidationApiError,
|
||||||
|
"STATUS_NOT_VALID": ValidationApiError,
|
||||||
|
"INVALID_MESSAGE_TYPE": ValidationApiError,
|
||||||
|
"FILE_MISSING": UploadError,
|
||||||
|
"INVALID_FILE_TYPE": UploadError,
|
||||||
|
"UPLOAD_FAILED": UploadError,
|
||||||
|
"QUEUE_FULL": UploadError,
|
||||||
|
"SERVER_DOWN": ServerDownError,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def raise_for_error_code(code: str) -> None:
|
||||||
|
error_cls = _ERROR_MAP.get(code, ApiEnvelopeError)
|
||||||
|
raise error_cls(code=code)
|
||||||
3
src/booklooker_client/generated/__init__.py
Normal file
3
src/booklooker_client/generated/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from .contracts import API_INFO, ENDPOINTS, SECURITY_SCHEMES, TAGS
|
||||||
|
|
||||||
|
__all__ = ["API_INFO", "ENDPOINTS", "SECURITY_SCHEMES", "TAGS"]
|
||||||
136
src/booklooker_client/generated/contracts.py
Normal file
136
src/booklooker_client/generated/contracts.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
"""Generated from openapi.yaml. Do not edit manually."""
|
||||||
|
|
||||||
|
API_INFO = {
|
||||||
|
"title": "booklooker REST API",
|
||||||
|
"version": "2.0",
|
||||||
|
"server_url": "https://api.booklooker.de/2.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
TAGS = [
|
||||||
|
"authentication",
|
||||||
|
"article",
|
||||||
|
"upload",
|
||||||
|
"image",
|
||||||
|
"order"
|
||||||
|
]
|
||||||
|
|
||||||
|
SECURITY_SCHEMES = {
|
||||||
|
"tokenAuth": {
|
||||||
|
"type": "apiKey",
|
||||||
|
"in": "query",
|
||||||
|
"name": "token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ENDPOINTS = {
|
||||||
|
"POST /authenticate": {
|
||||||
|
"summary": "Authentifizierung via API Key",
|
||||||
|
"tag": "authentication",
|
||||||
|
"parameters": [
|
||||||
|
"apiKey"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"DELETE /article": {
|
||||||
|
"summary": "Einzelnen Artikel zum Löschen vormerken",
|
||||||
|
"tag": "article",
|
||||||
|
"parameters": [
|
||||||
|
"orderNo"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"GET /article_list": {
|
||||||
|
"summary": "Liste aller eigenen aktiven Artikelnummern",
|
||||||
|
"tag": "article",
|
||||||
|
"parameters": [
|
||||||
|
"field",
|
||||||
|
"showPrice",
|
||||||
|
"showStock",
|
||||||
|
"mediaType"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"GET /article_status": {
|
||||||
|
"summary": "Abfragen des Status eines Artikels",
|
||||||
|
"tag": "article",
|
||||||
|
"parameters": [
|
||||||
|
"orderNo"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"GET /search": {
|
||||||
|
"summary": "Suche in der booklooker-Datenbank",
|
||||||
|
"tag": "article",
|
||||||
|
"parameters": []
|
||||||
|
},
|
||||||
|
"POST /file_import": {
|
||||||
|
"summary": "Upload von Angebots- oder Bilddateien",
|
||||||
|
"tag": "upload",
|
||||||
|
"parameters": [
|
||||||
|
"fileType",
|
||||||
|
"dataType",
|
||||||
|
"mediaType",
|
||||||
|
"formatID",
|
||||||
|
"encoding"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"GET /file_status": {
|
||||||
|
"summary": "Abfragen des Status einer hochgeladenen Angebotsdatei",
|
||||||
|
"tag": "upload",
|
||||||
|
"parameters": [
|
||||||
|
"filename",
|
||||||
|
"showErrors"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"DELETE /image": {
|
||||||
|
"summary": "Einzelne oder alle Bilder eines Artikels löschen",
|
||||||
|
"tag": "image",
|
||||||
|
"parameters": [
|
||||||
|
"orderNo",
|
||||||
|
"position"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"GET /import_status": {
|
||||||
|
"summary": "Abfragen der Anzahl unverarbeiteter hochgeladener Angebotsdateien",
|
||||||
|
"tag": "upload",
|
||||||
|
"parameters": []
|
||||||
|
},
|
||||||
|
"GET /order": {
|
||||||
|
"summary": "Download aller Bestellungen eines bestimmten Tages",
|
||||||
|
"tag": "order",
|
||||||
|
"parameters": [
|
||||||
|
"orderId",
|
||||||
|
"date",
|
||||||
|
"dateFrom",
|
||||||
|
"dateTo"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"PUT /order_cancel": {
|
||||||
|
"summary": "Stornieren einer kompletten Bestellung",
|
||||||
|
"tag": "order",
|
||||||
|
"parameters": [
|
||||||
|
"orderId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"PUT /order_item_cancel": {
|
||||||
|
"summary": "Stornieren der Bestellung eines Einzelartikels",
|
||||||
|
"tag": "order",
|
||||||
|
"parameters": [
|
||||||
|
"orderItemId",
|
||||||
|
"mediaType"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"PUT /order_message": {
|
||||||
|
"summary": "Versand einer Nachricht an den Kunden",
|
||||||
|
"tag": "order",
|
||||||
|
"parameters": [
|
||||||
|
"orderId",
|
||||||
|
"messageType",
|
||||||
|
"additionalText"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"PUT /order_status": {
|
||||||
|
"summary": "Setzen des Status einer Bestellung",
|
||||||
|
"tag": "order",
|
||||||
|
"parameters": [
|
||||||
|
"orderId",
|
||||||
|
"status"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/booklooker_client/generator.py
Normal file
85
src/booklooker_client/generator.py
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_parameter_names(parameters: list[dict[str, Any]], components: dict[str, Any]) -> list[str]:
|
||||||
|
resolved_names: list[str] = []
|
||||||
|
registry = components.get("parameters", {}) if isinstance(components, dict) else {}
|
||||||
|
|
||||||
|
for parameter in parameters or []:
|
||||||
|
candidate = parameter
|
||||||
|
if isinstance(parameter, dict) and "$ref" in parameter:
|
||||||
|
ref = str(parameter["$ref"])
|
||||||
|
if ref.startswith("#/components/parameters/"):
|
||||||
|
candidate = registry.get(ref.rsplit("/", 1)[-1], {})
|
||||||
|
|
||||||
|
if isinstance(candidate, dict) and candidate.get("name"):
|
||||||
|
resolved_names.append(str(candidate["name"]))
|
||||||
|
|
||||||
|
return resolved_names
|
||||||
|
|
||||||
|
|
||||||
|
def build_contract(openapi_path: Path) -> dict[str, Any]:
|
||||||
|
spec = yaml.safe_load(openapi_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
server_url = ""
|
||||||
|
servers = spec.get("servers") or []
|
||||||
|
if servers:
|
||||||
|
server_url = servers[0].get("url", "")
|
||||||
|
|
||||||
|
components = spec.get("components", {})
|
||||||
|
endpoints: dict[str, Any] = {}
|
||||||
|
for route, operations in (spec.get("paths") or {}).items():
|
||||||
|
for method, operation in operations.items():
|
||||||
|
endpoints[f"{method.upper()} {route}"] = {
|
||||||
|
"summary": operation.get("summary", ""),
|
||||||
|
"tag": (operation.get("tags") or [None])[0],
|
||||||
|
"parameters": _resolve_parameter_names(operation.get("parameters", []), components),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"api_info": {
|
||||||
|
"title": spec.get("info", {}).get("title", "booklooker REST API"),
|
||||||
|
"version": spec.get("info", {}).get("version", ""),
|
||||||
|
"server_url": server_url,
|
||||||
|
},
|
||||||
|
"tags": [tag.get("name") for tag in spec.get("tags", []) if isinstance(tag, dict)],
|
||||||
|
"security_schemes": spec.get("components", {}).get("securitySchemes", {}),
|
||||||
|
"endpoints": endpoints,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render_contract(contract: dict[str, Any]) -> str:
|
||||||
|
body = [
|
||||||
|
'"""Generated from openapi.yaml. Do not edit manually."""',
|
||||||
|
"",
|
||||||
|
f"API_INFO = {json.dumps(contract['api_info'], indent=4, ensure_ascii=False)}",
|
||||||
|
"",
|
||||||
|
f"TAGS = {json.dumps(contract['tags'], indent=4, ensure_ascii=False)}",
|
||||||
|
"",
|
||||||
|
f"SECURITY_SCHEMES = {json.dumps(contract['security_schemes'], indent=4, ensure_ascii=False)}",
|
||||||
|
"",
|
||||||
|
f"ENDPOINTS = {json.dumps(contract['endpoints'], indent=4, ensure_ascii=False)}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
return "\n".join(body)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> Path:
|
||||||
|
root = Path(__file__).resolve().parents[2]
|
||||||
|
openapi_path = root / "openapi.yaml"
|
||||||
|
output_path = root / "src" / "booklooker_client" / "generated" / "contracts.py"
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
contract = build_contract(openapi_path)
|
||||||
|
output_path.write_text(render_contract(contract), encoding="utf-8")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
path = main()
|
||||||
|
print(path)
|
||||||
31
src/booklooker_client/models/__init__.py
Normal file
31
src/booklooker_client/models/__init__.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
from .article import ArticleDeleteResult, ArticleField, ArticleList, ArticleListItem, ArticleStatus, ArticleStatusResult, MediaType
|
||||||
|
from .common import ApiEnvelope, AuthToken, FileImportErrorRecord, FileStatusResult, GenericResult, ImportQueueStatus, SearchResult, UploadReceipt
|
||||||
|
from .order import Address, MessageType, OrderBatch, OrderItem, OrderRecord, OrderStatus, PaymentMethod
|
||||||
|
from .webhook import MiddlewareEvent, WebhookEvent
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Address",
|
||||||
|
"ApiEnvelope",
|
||||||
|
"ArticleDeleteResult",
|
||||||
|
"ArticleField",
|
||||||
|
"ArticleList",
|
||||||
|
"ArticleListItem",
|
||||||
|
"ArticleStatus",
|
||||||
|
"ArticleStatusResult",
|
||||||
|
"AuthToken",
|
||||||
|
"FileImportErrorRecord",
|
||||||
|
"FileStatusResult",
|
||||||
|
"GenericResult",
|
||||||
|
"ImportQueueStatus",
|
||||||
|
"MediaType",
|
||||||
|
"MessageType",
|
||||||
|
"MiddlewareEvent",
|
||||||
|
"OrderBatch",
|
||||||
|
"OrderItem",
|
||||||
|
"OrderRecord",
|
||||||
|
"OrderStatus",
|
||||||
|
"PaymentMethod",
|
||||||
|
"SearchResult",
|
||||||
|
"UploadReceipt",
|
||||||
|
"WebhookEvent",
|
||||||
|
]
|
||||||
48
src/booklooker_client/models/article.py
Normal file
48
src/booklooker_client/models/article.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from enum import Enum, IntEnum
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class MediaType(IntEnum):
|
||||||
|
BOOKS = 0
|
||||||
|
MOVIES = 1
|
||||||
|
MUSIC = 2
|
||||||
|
AUDIOBOOKS = 3
|
||||||
|
GAMES = 4
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleField(str, Enum):
|
||||||
|
ORDER_NO = "orderNo"
|
||||||
|
ISBN = "isbn"
|
||||||
|
EAN = "ean"
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleStatus(str, Enum):
|
||||||
|
ACTIVE = "ACTIVE"
|
||||||
|
SOLD = "SOLD"
|
||||||
|
DELETED = "DELETED"
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleListItem(BaseModel):
|
||||||
|
value: str
|
||||||
|
price: Decimal | None = None
|
||||||
|
stock: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleList(BaseModel):
|
||||||
|
items: list[ArticleListItem] = Field(default_factory=list)
|
||||||
|
field: ArticleField = ArticleField.ORDER_NO
|
||||||
|
raw: str | list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleDeleteResult(BaseModel):
|
||||||
|
order_no: str
|
||||||
|
result: str = "SUCCESS"
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleStatusResult(BaseModel):
|
||||||
|
order_no: str
|
||||||
|
status: ArticleStatus
|
||||||
61
src/booklooker_client/models/common.py
Normal file
61
src/booklooker_client/models/common.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ApiEnvelope(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
status: Literal["OK", "NOK"]
|
||||||
|
returnValue: Any = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuthToken(BaseModel):
|
||||||
|
token: str
|
||||||
|
acquired_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
expires_after_seconds: int = 600
|
||||||
|
|
||||||
|
@property
|
||||||
|
def expired(self) -> bool:
|
||||||
|
age = datetime.now(timezone.utc) - self.acquired_at
|
||||||
|
return age.total_seconds() >= self.expires_after_seconds
|
||||||
|
|
||||||
|
|
||||||
|
class GenericResult(BaseModel):
|
||||||
|
success: bool = True
|
||||||
|
raw: Any = None
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResult(BaseModel):
|
||||||
|
raw: Any = None
|
||||||
|
|
||||||
|
|
||||||
|
class ImportQueueStatus(BaseModel):
|
||||||
|
pending_files: int
|
||||||
|
raw: Any = None
|
||||||
|
|
||||||
|
|
||||||
|
class FileImportErrorRecord(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
errorID: int | None = None
|
||||||
|
record: int | None = None
|
||||||
|
orderNo: str | None = None
|
||||||
|
title: str | None = None
|
||||||
|
details: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FileStatusResult(BaseModel):
|
||||||
|
filename: str | None = None
|
||||||
|
state: str | None = None
|
||||||
|
errors: list[FileImportErrorRecord] = Field(default_factory=list)
|
||||||
|
raw: Any = None
|
||||||
|
|
||||||
|
|
||||||
|
class UploadReceipt(BaseModel):
|
||||||
|
filename: str
|
||||||
|
result: str
|
||||||
|
raw: Any = None
|
||||||
90
src/booklooker_client/models/order.py
Normal file
90
src/booklooker_client/models/order.py
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum, IntEnum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class MessageType(str, Enum):
|
||||||
|
PAYMENT_INFORMATION = "PAYMENT_INFORMATION"
|
||||||
|
PAYMENT_REMINDER = "PAYMENT_REMINDER"
|
||||||
|
SHIPPING_NOTICE = "SHIPPING_NOTICE"
|
||||||
|
|
||||||
|
|
||||||
|
class OrderStatus(str, Enum):
|
||||||
|
BUYER_NO_REACTION = "BUYER_NO_REACTION"
|
||||||
|
CANCELED = "CANCELED"
|
||||||
|
PAID_WAITING_FOR_SHIPMENT = "PAID_WAITING_FOR_SHIPMENT"
|
||||||
|
READY_FOR_SHIPMENT = "READY_FOR_SHIPMENT"
|
||||||
|
SHIPPED_AND_PAID = "SHIPPED_AND_PAID"
|
||||||
|
SHIPPED_WAITING_FOR_PAYMENT = "SHIPPED_WAITING_FOR_PAYMENT"
|
||||||
|
TO_BE_PAID = "TO_BE_PAID"
|
||||||
|
VENDOR_NO_REACTION = "VENDOR_NO_REACTION"
|
||||||
|
WAITING_FOR_PAYMENT = "WAITING_FOR_PAYMENT"
|
||||||
|
WAITING_FOR_SHIPMENT = "WAITING_FOR_SHIPMENT"
|
||||||
|
WAITING_FOR_PAYMENT_INFO = "WAITING_FOR_PAYMENT_INFO"
|
||||||
|
RECEIVED_AND_PAID = "RECEIVED_AND_PAID"
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentMethod(IntEnum):
|
||||||
|
BANK_TRANSFER = 1
|
||||||
|
OPEN_INVOICE = 2
|
||||||
|
OPEN_INVOICE_PREPAY_RESERVED = 11
|
||||||
|
DIRECT_DEBIT = 3
|
||||||
|
CREDIT_CARD = 4
|
||||||
|
CASH_ON_DELIVERY = 5
|
||||||
|
PAYPAL = 6
|
||||||
|
SKRILL = 8
|
||||||
|
CASH_PICKUP = 9
|
||||||
|
SOFORT = 10
|
||||||
|
|
||||||
|
|
||||||
|
class Address(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
title: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
firstName: str | None = None
|
||||||
|
company: str | None = None
|
||||||
|
addressSupplement: str | None = None
|
||||||
|
street: str | None = None
|
||||||
|
zip: str | None = None
|
||||||
|
city: str | None = None
|
||||||
|
country: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrderItem(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
amount: int | None = None
|
||||||
|
author: str | None = None
|
||||||
|
mediaType: int | None = None
|
||||||
|
note: str | None = None
|
||||||
|
orderItemId: int | None = None
|
||||||
|
orderNo: str | None = None
|
||||||
|
orderTitle: str | None = None
|
||||||
|
singlePrice: float | None = None
|
||||||
|
status: str | None = None
|
||||||
|
totalPriceRebated: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrderRecord(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
orderId: int | None = None
|
||||||
|
orderDate: str | None = None
|
||||||
|
orderTime: str | None = None
|
||||||
|
buyerUsername: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
status: str | None = None
|
||||||
|
paymentId: int | None = None
|
||||||
|
comments: str | None = None
|
||||||
|
invoiceAddress: Address | None = None
|
||||||
|
deliveryAddress: Address | None = None
|
||||||
|
orderItems: list[OrderItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderBatch(BaseModel):
|
||||||
|
orders: list[OrderRecord] = Field(default_factory=list)
|
||||||
|
raw: Any = None
|
||||||
42
src/booklooker_client/models/webhook.py
Normal file
42
src/booklooker_client/models/webhook.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookEvent(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
||||||
|
|
||||||
|
event_type: str = Field(validation_alias=AliasChoices("event_type", "type", "event", "name"))
|
||||||
|
event_id: str = Field(default_factory=lambda: uuid4().hex, validation_alias=AliasChoices("event_id", "id"))
|
||||||
|
timestamp: datetime | None = Field(default=None, validation_alias=AliasChoices("timestamp", "createdAt", "created_at"))
|
||||||
|
order_id: int | None = Field(default=None, validation_alias=AliasChoices("orderId", "order_id"))
|
||||||
|
order_no: str | None = Field(default=None, validation_alias=AliasChoices("orderNo", "order_no"))
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _capture_payload(cls, value: Any) -> Any:
|
||||||
|
if isinstance(value, dict) and "payload" not in value:
|
||||||
|
copied = dict(value)
|
||||||
|
copied["payload"] = dict(value)
|
||||||
|
return copied
|
||||||
|
return value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def resource_id(self) -> str | None:
|
||||||
|
if self.order_id is not None:
|
||||||
|
return str(self.order_id)
|
||||||
|
return self.order_no
|
||||||
|
|
||||||
|
|
||||||
|
class MiddlewareEvent(BaseModel):
|
||||||
|
event_id: str
|
||||||
|
event_type: str
|
||||||
|
resource_id: str | None = None
|
||||||
|
resource_type: str | None = None
|
||||||
|
raw_payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
enriched_data: dict[str, Any] | None = None
|
||||||
101
src/booklooker_client/webhooks.py
Normal file
101
src/booklooker_client/webhooks.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .models.webhook import MiddlewareEvent, WebhookEvent
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryIdempotencyStore:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._seen: set[str] = set()
|
||||||
|
|
||||||
|
def has_seen(self, event_id: str) -> bool:
|
||||||
|
return event_id in self._seen
|
||||||
|
|
||||||
|
def mark_seen(self, event_id: str) -> None:
|
||||||
|
self._seen.add(event_id)
|
||||||
|
|
||||||
|
|
||||||
|
class BooklookerWebhookHelper:
|
||||||
|
"""Utility toolbox for parsing and enriching Booklooker push payloads."""
|
||||||
|
|
||||||
|
def __init__(self, idempotency_store: InMemoryIdempotencyStore | None = None) -> None:
|
||||||
|
self.idempotency_store = idempotency_store or InMemoryIdempotencyStore()
|
||||||
|
|
||||||
|
def parse_event(self, payload: dict[str, Any]) -> WebhookEvent:
|
||||||
|
return WebhookEvent.model_validate(payload)
|
||||||
|
|
||||||
|
def is_duplicate(self, event: WebhookEvent) -> bool:
|
||||||
|
return self.idempotency_store.has_seen(event.event_id)
|
||||||
|
|
||||||
|
def mark_processed(self, event: WebhookEvent) -> None:
|
||||||
|
self.idempotency_store.mark_seen(event.event_id)
|
||||||
|
|
||||||
|
def to_middleware_event(
|
||||||
|
self,
|
||||||
|
event: WebhookEvent,
|
||||||
|
*,
|
||||||
|
resource_type: str | None = None,
|
||||||
|
enriched_data: dict[str, Any] | None = None,
|
||||||
|
) -> MiddlewareEvent:
|
||||||
|
return MiddlewareEvent(
|
||||||
|
event_id=event.event_id,
|
||||||
|
event_type=event.event_type,
|
||||||
|
resource_id=event.resource_id,
|
||||||
|
resource_type=resource_type,
|
||||||
|
raw_payload=event.payload,
|
||||||
|
enriched_data=enriched_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
def enrich_with_client(self, payload: dict[str, Any] | WebhookEvent, client: Any) -> MiddlewareEvent:
|
||||||
|
event = payload if isinstance(payload, WebhookEvent) else self.parse_event(payload)
|
||||||
|
|
||||||
|
if self.is_duplicate(event):
|
||||||
|
return self.to_middleware_event(
|
||||||
|
event,
|
||||||
|
resource_type="duplicate",
|
||||||
|
enriched_data={"duplicate": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
enriched: dict[str, Any] | None = None
|
||||||
|
resource_type: str | None = None
|
||||||
|
|
||||||
|
if event.order_id is not None and hasattr(client, "get_orders"):
|
||||||
|
resource_type = "order"
|
||||||
|
date_hint = event.timestamp.date().isoformat() if event.timestamp else None
|
||||||
|
enriched = client.get_orders(order_id=event.order_id, date=date_hint).model_dump(mode="json")
|
||||||
|
elif event.order_no and hasattr(client, "get_article_status"):
|
||||||
|
resource_type = "article"
|
||||||
|
enriched = client.get_article_status(event.order_no).model_dump(mode="json")
|
||||||
|
|
||||||
|
self.mark_processed(event)
|
||||||
|
return self.to_middleware_event(event, resource_type=resource_type, enriched_data=enriched)
|
||||||
|
|
||||||
|
async def enrich_with_async_client(self, payload: dict[str, Any] | WebhookEvent, client: Any) -> MiddlewareEvent:
|
||||||
|
event = payload if isinstance(payload, WebhookEvent) else self.parse_event(payload)
|
||||||
|
|
||||||
|
if self.is_duplicate(event):
|
||||||
|
return self.to_middleware_event(
|
||||||
|
event,
|
||||||
|
resource_type="duplicate",
|
||||||
|
enriched_data={"duplicate": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
enriched: dict[str, Any] | None = None
|
||||||
|
resource_type: str | None = None
|
||||||
|
|
||||||
|
if event.order_id is not None and hasattr(client, "get_orders"):
|
||||||
|
resource_type = "order"
|
||||||
|
date_hint = event.timestamp.date().isoformat() if event.timestamp else None
|
||||||
|
order_batch = await client.get_orders(order_id=event.order_id, date=date_hint)
|
||||||
|
enriched = order_batch.model_dump(mode="json")
|
||||||
|
elif event.order_no and hasattr(client, "get_article_status"):
|
||||||
|
resource_type = "article"
|
||||||
|
article_state = await client.get_article_status(event.order_no)
|
||||||
|
enriched = article_state.model_dump(mode="json")
|
||||||
|
|
||||||
|
self.mark_processed(event)
|
||||||
|
return self.to_middleware_event(event, resource_type=resource_type, enriched_data=enriched)
|
||||||
|
|
||||||
|
def fastapi_receiver_snippet(self, route: str = "/webhooks/booklooker") -> str:
|
||||||
|
return f'''from fastapi import FastAPI, Request\nfrom booklooker_client import BooklookerConfig, SyncBooklookerClient, BooklookerWebhookHelper\n\napp = FastAPI()\nhelper = BooklookerWebhookHelper()\nclient = SyncBooklookerClient(BooklookerConfig(api_key="YOUR_API_KEY"))\n\n@app.post("{route}")\nasync def receive_booklooker_webhook(request: Request):\n payload = await request.json()\n event = helper.enrich_with_client(payload, client)\n return {{"accepted": True, "event": event.model_dump(mode="json")}}\n'''
|
||||||
27
tests/test_models.py
Normal file
27
tests/test_models.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
from booklooker_client import BooklookerConfig, SyncBooklookerClient
|
||||||
|
from booklooker_client.models import ArticleField, ArticleList, ArticleListItem, AuthToken
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_defaults() -> None:
|
||||||
|
config = BooklookerConfig(api_key="demo")
|
||||||
|
assert config.base_url == "https://api.booklooker.de/2.0"
|
||||||
|
assert config.timeout > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_token_not_immediately_expired() -> None:
|
||||||
|
token = AuthToken(token="abc", expires_after_seconds=600)
|
||||||
|
assert token.expired is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_article_list_model() -> None:
|
||||||
|
article_list = ArticleList(items=[ArticleListItem(value="ABC-1")], field=ArticleField.ORDER_NO)
|
||||||
|
assert article_list.items[0].value == "ABC-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_exposes_generated_endpoints() -> None:
|
||||||
|
client = SyncBooklookerClient(BooklookerConfig(api_key="demo"))
|
||||||
|
try:
|
||||||
|
assert "POST /authenticate" in client.available_endpoints
|
||||||
|
assert "GET /order" in client.available_endpoints
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
41
tests/test_webhooks.py
Normal file
41
tests/test_webhooks.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
from booklooker_client import BooklookerWebhookHelper
|
||||||
|
from booklooker_client.models.order import OrderBatch, OrderRecord
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def get_orders(self, *, order_id=None, date=None, date_from=None, date_to=None):
|
||||||
|
return OrderBatch(orders=[OrderRecord(orderId=order_id, buyerUsername="alice")])
|
||||||
|
|
||||||
|
def get_article_status(self, order_no: str):
|
||||||
|
class _Result:
|
||||||
|
def model_dump(self, mode="json"):
|
||||||
|
return {"order_no": order_no, "status": "ACTIVE"}
|
||||||
|
|
||||||
|
return _Result()
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_helper_enriches_order_payload() -> None:
|
||||||
|
helper = BooklookerWebhookHelper()
|
||||||
|
event = helper.enrich_with_client(
|
||||||
|
{
|
||||||
|
"event_type": "order.created",
|
||||||
|
"event_id": "evt-1",
|
||||||
|
"orderId": 1234,
|
||||||
|
},
|
||||||
|
FakeClient(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert event.resource_type == "order"
|
||||||
|
assert event.resource_id == "1234"
|
||||||
|
assert event.enriched_data is not None
|
||||||
|
assert event.enriched_data["orders"][0]["buyerUsername"] == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_detection() -> None:
|
||||||
|
helper = BooklookerWebhookHelper()
|
||||||
|
first = helper.enrich_with_client({"event_type": "order.created", "event_id": "evt-2", "orderId": 99}, FakeClient())
|
||||||
|
second = helper.enrich_with_client({"event_type": "order.created", "event_id": "evt-2", "orderId": 99}, FakeClient())
|
||||||
|
|
||||||
|
assert first.resource_type == "order"
|
||||||
|
assert second.resource_type == "duplicate"
|
||||||
|
assert second.enriched_data == {"duplicate": True}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue