From 1d8ee1bba6e409dd4ea6f8bb589bba9a55b94882 Mon Sep 17 00:00:00 2001 From: claudi Date: Thu, 16 Apr 2026 14:42:19 +0200 Subject: [PATCH] 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. --- .gitignore | 9 + README.md | 36 + examples/sync_usage.py | 14 + examples/webhook_fastapi.py | 19 + openapi.yaml | 1620 ++++++++++++++++++ pyproject.toml | 38 + src/booklooker_client/__init__.py | 11 + src/booklooker_client/client.py | 510 ++++++ src/booklooker_client/config.py | 19 + src/booklooker_client/exceptions.py | 68 + src/booklooker_client/generated/__init__.py | 3 + src/booklooker_client/generated/contracts.py | 136 ++ src/booklooker_client/generator.py | 85 + src/booklooker_client/models/__init__.py | 31 + src/booklooker_client/models/article.py | 48 + src/booklooker_client/models/common.py | 61 + src/booklooker_client/models/order.py | 90 + src/booklooker_client/models/webhook.py | 42 + src/booklooker_client/webhooks.py | 101 ++ tests/test_models.py | 27 + tests/test_webhooks.py | 41 + 21 files changed, 3009 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 examples/sync_usage.py create mode 100644 examples/webhook_fastapi.py create mode 100644 openapi.yaml create mode 100644 pyproject.toml create mode 100644 src/booklooker_client/__init__.py create mode 100644 src/booklooker_client/client.py create mode 100644 src/booklooker_client/config.py create mode 100644 src/booklooker_client/exceptions.py create mode 100644 src/booklooker_client/generated/__init__.py create mode 100644 src/booklooker_client/generated/contracts.py create mode 100644 src/booklooker_client/generator.py create mode 100644 src/booklooker_client/models/__init__.py create mode 100644 src/booklooker_client/models/article.py create mode 100644 src/booklooker_client/models/common.py create mode 100644 src/booklooker_client/models/order.py create mode 100644 src/booklooker_client/models/webhook.py create mode 100644 src/booklooker_client/webhooks.py create mode 100644 tests/test_models.py create mode 100644 tests/test_webhooks.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..04ae1b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +.pytest_cache/ +*.pyc +*.pyo +*.pyd +.venv/ +dist/ +build/ +*.egg-info/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b3c392b --- /dev/null +++ b/README.md @@ -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. diff --git a/examples/sync_usage.py b/examples/sync_usage.py new file mode 100644 index 0000000..c9da976 --- /dev/null +++ b/examples/sync_usage.py @@ -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) diff --git a/examples/webhook_fastapi.py b/examples/webhook_fastapi.py new file mode 100644 index 0000000..2de0229 --- /dev/null +++ b/examples/webhook_fastapi.py @@ -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")} diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..0df902b --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,1620 @@ +openapi: 3.1.0 +info: + title: booklooker REST API + description: | +

+ Die Booklooker REST API ermöglicht die sichere und einfache Kommunikation mit verschiedenen + Booklooker-Schnittstellen. Es besteht die Möglichkeit, Artikel und Aufträge abzufragen, zu ändern, zu stornieren + etc. Auch der automatische Import neuer oder geänderter Artikel ist ohne Weiteres möglich. +

+

+ Diese API basiert auf REST. Zur Benutzung führen Sie bitte die folgenden Schritte durch: +

+
    +
  1. + Sie benötigen Ihren persönlichen API Key, diesen erhalten Sie im Bereich + Persönliche Daten +
  2. +
  3. + Benutzen Sie anschließend die Schnittstelle + authenticate + via HTTP POST und Sie erhalten einen Token, + welcher für alle folgenden Aufrufe benötigt wird. + Sofern Sie 10 Minuten keine Schnittstelle aufrufen, + verfällt der Token und Sie müssen sich erneut authentifizieren. +
  4. +
  5. + Verwenden Sie eine der unten aufgelisteten Schnittstellen. + Die Beschreibung jeder Schnittstelle enthält die benötigten Parameter und die möglichen Rückgabewerte. +
  6. +
+

+ Zur Kommunikation können verschiedene Programmiersprachen zum Einsatz kommen. Wir stellen Ihnen hier ein + Beispiel in PHP + zur Verfügung. +

+

+ Weiterhin bieten wir hier eine OpenAPI Spezifikation an. +

+ version: '2.0' +servers: + - url: 'https://api.booklooker.de/2.0' +tags: + - name: authentication + - name: article + - name: upload + - name: image + - name: order +paths: + /authenticate: + post: + tags: + - authentication + summary: Authentifizierung via API Key + description: | + Authentifizierung via API Key. Ihren persönlichen API Key erhalten Sie + hier. + parameters: + - name: apiKey + description: Ihr persönlicher API Key + in: query + required: true + schema: + type: string + responses: + '200': + content: + application/json: + schema: + type: object + properties: + status: + type: string + returnValue: + type: mixed + examples: + SUCCESS: + description: Der Token, welcher für alle weiteren Aufrufe benötigt wird. + value: + status: OK + returnValue: REST_API_TOKEN + API_KEY_MISSING: + description: Der API Key ist leer oder nicht vorhanden. + value: + status: NOK + returnValue: API_KEY_MISSING + AUTHENTICATION_FAILED: + description: Der API Key ist nicht bekannt. + value: + status: NOK + returnValue: AUTHENTICATION_FAILED + ENCODING_ERROR: + $ref: '#/components/responses/ENCODING_ERROR' + INVALID_INTERFACE: + $ref: '#/components/responses/INVALID_INTERFACE' + INVALID_REQUEST_METHOD: + $ref: '#/components/responses/INVALID_REQUEST_METHOD' + QUOTA_EXCEEDED: + $ref: '#/components/responses/QUOTA_EXCEEDED' + SERVER_DOWN: + $ref: '#/components/responses/SERVER_DOWN' + TEMPORARILY_BLOCKED: + $ref: '#/components/responses/TEMPORARILY_BLOCKED' + /article: + delete: + tags: + - article + summary: Einzelnen Artikel zum Löschen vormerken + description: Einzelnen Artikel zum Löschen vormerken. Der Artikel wird innerhalb der nächsten 2 Stunden gelöscht. + security: + - tokenAuth: [ ] + parameters: + - $ref: '#/components/parameters/orderNoParam' + responses: + '200': + content: + application/json: + schema: + type: object + properties: + status: + type: string + returnValue: + type: mixed + examples: + SUCCESS: + description: Der Artikel wurde erfolgreich für den Löschvorgang vorgemerkt. + value: + status: OK + returnValue: SUCCESS + INVALID_PARAMETERS: + $ref: '#/components/responses/INVALID_PARAMETER_ORDERNO' + NOT_FOUND: + $ref: '#/components/responses/NOT_FOUND_ORDERNO' + ENCODING_ERROR: + $ref: '#/components/responses/ENCODING_ERROR' + INVALID_INTERFACE: + $ref: '#/components/responses/INVALID_INTERFACE' + INVALID_REQUEST_METHOD: + $ref: '#/components/responses/INVALID_REQUEST_METHOD' + QUOTA_EXCEEDED: + $ref: '#/components/responses/QUOTA_EXCEEDED' + SERVER_DOWN: + $ref: '#/components/responses/SERVER_DOWN' + TEMPORARILY_BLOCKED: + $ref: '#/components/responses/TEMPORARILY_BLOCKED' + TOKEN_EXPIRED: + $ref: '#/components/responses/TOKEN_EXPIRED' + TOKEN_MISSING: + $ref: '#/components/responses/TOKEN_MISSING' + TOKEN_UNKNOWN: + $ref: '#/components/responses/TOKEN_UNKNOWN' + /article_list: + get: + tags: + - article + summary: Liste aller eigenen aktiven Artikelnummern + description: | + Liste aller eigenen aktiven Artikelnummern. +

+ Bitte beachten: Wird ein Artikel gelöscht, so wird dieser nicht sofort aus den aktuell + angebotenen Artikel gelöscht, sondern mit einer Verzögerung von im Schnitt 1-2 Stunden. + Um den aktuellen Status eines Artikels zu ermitteln, benutzen Sie bitte die Schnittstelle + article_status. +

+ security: + - tokenAuth: [ ] + parameters: + - name: field + description: | + Rückgabefeld, mögliche Werte: + + in: query + schema: + type: string + default: orderNo + enum: + - orderNo + - isbn + - ean + - name: showPrice + description: | + Übermittlung des Preises, mögliche Werte: + +

+ Wird der Parameter showPrice mit dem Wert 1 übergeben, + so werden die Artikelpreise TAB-getrennt zurückgegeben. +

+ in: query + schema: + type: integer + default: 0 + minimum: 0 + maximum: 1 + - name: showStock + description: | + Übermittlung des Bestands, mögliche Werte: + +

+ Wird der Parameter showStock mit dem Wert 1 übergeben, + so werden ausschließlich die Bestellnummern und Bestandsangaben der Artikel zurückgegeben, + die einen Bestand >1 besitzen. + Alle übrigen Parameter (field, showPrice, mediaType) werden in diesem Fall ignoriert. +

+ in: query + schema: + type: integer + default: 0 + minimum: 0 + maximum: 1 + - name: mediaType + description: | + Einschränkung nach Medientyp, mögliche Werte: + + in: query + schema: + type: integer + default: n/a + minimum: 0 + maximum: 4 + responses: + '200': + content: + application/json: + schema: + type: object + properties: + status: + type: string + returnValue: + type: mixed + examples: + SUCCESS: + description: | + Eine Liste der Artikelnummern (bzw. ISBN/EAN) aller vom Anbieter aktuell angebotenen Artikel, + getrennt mit einem einfachen Zeilenumbruch (\n). + value: + status: OK + returnValue: "" + INVALID_PARAMETERS: + description: | + Einer der Parameter field oder mediaType wurde mit einem falschen Wert übergeben. + value: + status: NOK + returnValue: INVALID_PARAMETERS + PARAMETER_MISMATCH: + description: | + Der Parameter field mit Wert isbn oder ean kann nur verwendet werden, + wenn der Parameter mediaType vorhanden ist. + value: + status: NOK + returnValue: PARAMETER_MISMATCH + ENCODING_ERROR: + $ref: '#/components/responses/ENCODING_ERROR' + INVALID_INTERFACE: + $ref: '#/components/responses/INVALID_INTERFACE' + INVALID_REQUEST_METHOD: + $ref: '#/components/responses/INVALID_REQUEST_METHOD' + QUOTA_EXCEEDED: + $ref: '#/components/responses/QUOTA_EXCEEDED' + SERVER_DOWN: + $ref: '#/components/responses/SERVER_DOWN' + TEMPORARILY_BLOCKED: + $ref: '#/components/responses/TEMPORARILY_BLOCKED' + TOKEN_EXPIRED: + $ref: '#/components/responses/TOKEN_EXPIRED' + TOKEN_MISSING: + $ref: '#/components/responses/TOKEN_MISSING' + TOKEN_UNKNOWN: + $ref: '#/components/responses/TOKEN_UNKNOWN' + /article_status: + get: + tags: + - article + summary: Abfragen des Status eines Artikels + description: Abfragen des Status eines Artikels + security: + - tokenAuth: [ ] + parameters: + - $ref: '#/components/parameters/orderNoParam' + responses: + '200': + content: + application/json: + schema: + type: object + properties: + status: + type: string + returnValue: + type: mixed + examples: + SOLD: + description: Der Artikel wurde verkauft und steht nicht mehr zum Verkauf. + value: + status: OK + returnValue: SOLD + DELETED: + description: Der Artikel wurde gelöscht, ist aber noch in der Datenbank. + value: + status: OK + returnValue: DELETED + ACTIVE: + description: Der Artikel steht zum Verkauf. + value: + status: OK + returnValue: ACTIVE + INVALID_PARAMETERS: + $ref: '#/components/responses/INVALID_PARAMETER_ORDERNO' + NOT_FOUND: + $ref: '#/components/responses/NOT_FOUND_ORDERNO' + ENCODING_ERROR: + $ref: '#/components/responses/ENCODING_ERROR' + INVALID_INTERFACE: + $ref: '#/components/responses/INVALID_INTERFACE' + INVALID_REQUEST_METHOD: + $ref: '#/components/responses/INVALID_REQUEST_METHOD' + QUOTA_EXCEEDED: + $ref: '#/components/responses/QUOTA_EXCEEDED' + SERVER_DOWN: + $ref: '#/components/responses/SERVER_DOWN' + TEMPORARILY_BLOCKED: + $ref: '#/components/responses/TEMPORARILY_BLOCKED' + TOKEN_EXPIRED: + $ref: '#/components/responses/TOKEN_EXPIRED' + TOKEN_MISSING: + $ref: '#/components/responses/TOKEN_MISSING' + TOKEN_UNKNOWN: + $ref: '#/components/responses/TOKEN_UNKNOWN' + /search: + get: + tags: + - article + summary: Suche in der booklooker-Datenbank + description: Suche in der booklooker-Datenbank + security: + - tokenAuth: [ ] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + status: + type: string + returnValue: + type: mixed + examples: + SUCCESS: + description: | + Es wird eine selbsterklärende Liste mit den gefundenen Artikeln zurückgeliefert. + value: + status: OK + returnValue: "" + ENCODING_ERROR: + $ref: '#/components/responses/ENCODING_ERROR' + INVALID_INTERFACE: + $ref: '#/components/responses/INVALID_INTERFACE' + INVALID_REQUEST_METHOD: + $ref: '#/components/responses/INVALID_REQUEST_METHOD' + QUOTA_EXCEEDED: + $ref: '#/components/responses/QUOTA_EXCEEDED' + SERVER_DOWN: + $ref: '#/components/responses/SERVER_DOWN' + TEMPORARILY_BLOCKED: + $ref: '#/components/responses/TEMPORARILY_BLOCKED' + TOKEN_EXPIRED: + $ref: '#/components/responses/TOKEN_EXPIRED' + TOKEN_MISSING: + $ref: '#/components/responses/TOKEN_MISSING' + TOKEN_UNKNOWN: + $ref: '#/components/responses/TOKEN_UNKNOWN' + /file_import: + post: + tags: + - upload + summary: Upload von Angebots- oder Bilddateien + description: Upload von Angebots- oder Bilddateien + security: + - tokenAuth: [ ] + requestBody: + require: true + description: | +

+ Die hochzuladende Datei. +

+

+ Ihre Angebotsdatei können Sie entweder als Textdatei oder als komprimiertes ZIP-Archiv übergeben. + Standardmäßig müssen die Textdateien in der Kodierung ISO 8859-1 vorliegen. + Wenn Ihre Daten in einer anderen Kodierung, bspw. UTF-8 vorliegen, + verwenden Sie sich bitte den Parameter encoding. +

+

+ Bilddateien müssen als ZIP-Archiv übergeben werden. + Weitere Hinweise finden Sie auf der Seite zum + manuellen Hochladen. + Die hochgeladenen Dateien werden in der Reihenfolge des Uploads verarbeitet. +

+

+ Maximale Dateigröße: 80 MB. +

+ content: + application/octet-stream: + schema: + type: string + format: binary + parameters: + - name: fileType + description: | + Dateityp, mögliche Werte: + + in: query + schema: + type: string + default: article + enum: + - pic + - article + - name: dataType + description: | + Art des Uploads, mögliche Werte: + + in: query + required: true + schema: + type: integer + default: n/a + minimum: 0 + maximum: 2 + - name: mediaType + description: | + Medientyp, mögliche Werte: + + in: query + schema: + type: integer + default: 0 + minimum: 0 + maximum: 4 + - name: formatID + description: | + Interne ID des Formats, legt Feldreihenfolge und Texttrenner fest. + Für das booklooker-Format verwenden Sie bitte den Wert 1, + für mehr Informationen wenden Sie sich bitte an daten@booklooker.de + in: query + schema: + type: integer + default: n/a (Es wird das Format des letzten Uploads verwendet.) + - name: encoding + description: | + Character Encoding der Datei, mögliche Werte: + + in: query + schema: + type: string + default: Es wird das Encoding des letzten Uploads verwendet, beim Erst-Upload CP-1252. + responses: + '200': + content: + application/json: + schema: + type: object + properties: + status: + type: string + returnValue: + type: mixed + examples: + SUCCESS: + description: Upload erfolgreich. + value: + status: OK + returnValue: SUCCESS + INVALID_PARAMETERS: + description: | + Einer der Parameter fileType, dataType oder mediaType + wurde nicht oder mit einem falschen Wert übergeben. + value: + status: NOK + returnValue: INVALID_PARAMETERS + FORMAT_ID_MISMATCH: + description: | + Die Kombination der Werte der Parameter formatID und mediaType + kann nicht verarbeitet werden. + value: + status: NOK + returnValue: FORMAT_ID_MISMATCH + FILE_MISSING: + description: Es wurde keine Datei übertragen. + value: + status: NOK + returnValue: FILE_MISSING + MAX_SIZE_EXCEEDED: + description: Die maximal erlaubte Dateigröße wurde überschritten. + value: + status: NOK + returnValue: MAX_SIZE_EXCEEDED + INVALID_FILE_TYPE: + description: Falscher Dateityp. + value: + status: NOK + returnValue: INVALID_FILE_TYPE + UPLOAD_FAILED: + description: Der Upload der Datei ist fehlgeschlagen. + value: + status: NOK + returnValue: UPLOAD_FAILED + REPLACE_AMOUNT_EXCEEDED: + description: | + Uploads im Ersetzen-Modus sind bei einem Angebotsbestand + > 1.000.000 Artikel nur in Absprache möglich. Bitte wenden Sie sich an daten@booklooker.de + value: + status: NOK + returnValue: REPLACE_AMOUNT_EXCEEDED + IDENTICAL_FILE_UPLOADED: + description: Die Datei ist bereits im System vorhanden. + value: + status: NOK + returnValue: IDENTICAL_FILE_UPLOADED + QUEUE_FULL: + description: | + Die Warteschlange der hochgeladenen Dateien ist voll + – entweder mehr als 200 Dateien oder mehr als 250 MB. + value: + status: NOK + returnValue: QUEUE_FULL + ENCODING_ERROR: + $ref: '#/components/responses/ENCODING_ERROR' + INVALID_INTERFACE: + $ref: '#/components/responses/INVALID_INTERFACE' + INVALID_REQUEST_METHOD: + $ref: '#/components/responses/INVALID_REQUEST_METHOD' + QUOTA_EXCEEDED: + $ref: '#/components/responses/QUOTA_EXCEEDED' + SERVER_DOWN: + $ref: '#/components/responses/SERVER_DOWN' + TEMPORARILY_BLOCKED: + $ref: '#/components/responses/TEMPORARILY_BLOCKED' + TOKEN_EXPIRED: + $ref: '#/components/responses/TOKEN_EXPIRED' + TOKEN_MISSING: + $ref: '#/components/responses/TOKEN_MISSING' + TOKEN_UNKNOWN: + $ref: '#/components/responses/TOKEN_UNKNOWN' + /file_status: + get: + tags: + - upload + summary: Abfragen des Status einer hochgeladenen Angebotsdatei + description: Abfragen des Status einer hochgeladenen Angebotsdatei + security: + - tokenAuth: [ ] + parameters: + - name: filename + description: | + Name der hochgeladenen Datei. +

+ Existieren mehrere hochgeladene Dateien mit diesem Namen, + bezieht sich der zurückgegebene Wert auf die zuletzt hochgeladene Datei. +

+ in: query + required: true + schema: + type: string + - name: showErrors + description: | + Der Rückgabewert ist eine Liste von Fehlern, die beim Import der Datei identifiziert wurden. + +

Mögliche Werte:

+ + + +

+ Achtung: Die Liste der Fehler kann nur für komplett verarbeitete Dateien abgefragt werden! +

+ in: query + required: false + schema: + type: integer + default: 0 + minimum: 0 + maximum: 1 + responses: + '200': + content: + application/json: + schema: + type: object + properties: + status: + type: string + returnValue: + type: mixed + examples: + FILE_RECEIVED: + description: Die Datei wurde vollständig hochgeladen, aber noch nicht verarbeitet. + value: + status: OK + returnValue: FILE_RECEIVED + UPLOAD_IN_PROGRESS: + description: Die Datei befindet sich aktuell in Verarbeitung. + value: + status: OK + returnValue: UPLOAD_IN_PROGRESS + UPLOAD_DONE: + description: Die Datei komplett verarbeitet. + value: + status: OK + returnValue: UPLOAD_DONE + SHOW_ERRORS: + description: | +

+ Sofern der Parameter showErrors mit dem Wert 1 übergeben wurde, ist die Rückgabe + eine Liste von Fehlern, welche je Zeile einen Artikel mit diesen Werten enthält: +

+ + + + + + + + +
SpalteBeschreibung
errorIDID des Fehlers (siehe unten)
recordDatensatz
orderNoBestellnummer
titleTitel (sofern vorhanden)
detailsweitere Informationen, warum der Artikel abgelehnt wurde (sofern vorhanden)
+ +

+ errorID kann folgende Werte annehmen: +

+ + + + + + + + + + + + + + + + + + + + + + + + +
errorIDBeschreibung
1Mehrfach vergebene Bestellnummer
2Mehrfach vorhandene identische Angebote (Dubletten)
3(wird nicht mehr verwendet)
4Neubücher in privatem Account
5Fehlende Formatangabe (z. B. CD, DVD, LP etc.)
6Hörbuch in Bücherdatei
7Fehlende oder ungültige FSK-Angabe
8Nicht unterstützter Artikeltyp
9(wird nicht mehr verwendet)
10Das Feld Titel ist leer
11Das Feld Preis oder Währung ist leer oder wurde nicht gefunden
12Preis von Neubuch weicht ab vom gebundenen Preis
13Grenze von 5000 VLB-Artikel überschritten, gesamte Datei wird nicht verarbeitet
14E-Books sind nicht erlaubt
15Keine Daten zur ISBN vorhanden
16FSK 18
17Maximale Artikelanzahl überschritten
18Erscheinungsdatum liegt in der Zukunft
19Preisangaben von 100.000 EUR und darüber werden nicht unterstützt
20Unerwünschter Verlag
21Nicht-westeuropäisches Zeichen in Bestellnummer
+ value: + status: OK + returnValue: "" + INVALID_PARAMETERS: + description: Der Parameter filename ist leer oder nicht vorhanden. + value: + status: NOK + returnValue: INVALID_PARAMETERS + FILE_NOT_FOUND: + description: Die Datei wurde nicht gefunden. + value: + status: NOK + returnValue: FILE_NOT_FOUND + UPLOAD_FAILED: + description: Datei konnte nicht eingelesen werden (es wurde kein gültiger Datensatz gefunden). + value: + status: NOK + returnValue: UPLOAD_FAILED + ENCODING_ERROR: + $ref: '#/components/responses/ENCODING_ERROR' + INVALID_INTERFACE: + $ref: '#/components/responses/INVALID_INTERFACE' + INVALID_REQUEST_METHOD: + $ref: '#/components/responses/INVALID_REQUEST_METHOD' + QUOTA_EXCEEDED: + $ref: '#/components/responses/QUOTA_EXCEEDED' + SERVER_DOWN: + $ref: '#/components/responses/SERVER_DOWN' + TEMPORARILY_BLOCKED: + $ref: '#/components/responses/TEMPORARILY_BLOCKED' + TOKEN_EXPIRED: + $ref: '#/components/responses/TOKEN_EXPIRED' + TOKEN_MISSING: + $ref: '#/components/responses/TOKEN_MISSING' + TOKEN_UNKNOWN: + $ref: '#/components/responses/TOKEN_UNKNOWN' + /image: + delete: + tags: + - image + summary: Einzelne oder alle Bilder eines Artikels löschen + description: Einzelne oder alle Bilder eines Artikels löschen + security: + - tokenAuth: [ ] + parameters: + - $ref: '#/components/parameters/orderNoParam' + - name: position + description: | +

+ Position des Bildes, falls nicht vorhanden werden alle (!) Bilder des Artikels gelöscht. +

+

+ Achtung: Beim Löschen mehrerer (aber nicht aller) Bilder eines Artikels muss + berücksichtigt werden, dass sich beim Aufruf dieser Schnittstelle die Position der nachfolgenden Bilder + ändert: Wenn ein Artikel z.B. 5 Bilder hat und das Bild an Position 2 gelöscht wird, + ändert sich die Position der Bilder 3, 4, 5 in 2, 3, 4. +

+ in: query + schema: + type: integer + default: n/a + responses: + '200': + content: + application/json: + schema: + type: object + properties: + status: + type: string + returnValue: + type: mixed + examples: + SUCCESS: + description: Das Bild wurde bzw. die Bilder wurden erfolgreich gelöscht. + value: + status: OK + returnValue: SUCCESS + INVALID_PARAMETERS: + $ref: '#/components/responses/INVALID_PARAMETER_ORDERNO' + ARTICLE_NOT_FOUND: + description: Die Bestellnummer wurde nicht gefunden. + value: + status: NOK + returnValue: ARTICLE_NOT_FOUND + IMAGE_NOT_FOUND: + description: Das Bild wurde nicht gefunden. + value: + status: NOK + returnValue: IMAGE_NOT_FOUND + IMAGE_NOT_DELETED: + description: Das Bild konnte nicht gelöscht werden. + value: + status: NOK + returnValue: IMAGE_NOT_DELETED + IMAGES_NOT_DELETED: + description: Die Bilder konnten nicht gelöscht werden. + value: + status: NOK + returnValue: IMAGES_NOT_DELETED + ENCODING_ERROR: + $ref: '#/components/responses/ENCODING_ERROR' + INVALID_INTERFACE: + $ref: '#/components/responses/INVALID_INTERFACE' + INVALID_REQUEST_METHOD: + $ref: '#/components/responses/INVALID_REQUEST_METHOD' + QUOTA_EXCEEDED: + $ref: '#/components/responses/QUOTA_EXCEEDED' + SERVER_DOWN: + $ref: '#/components/responses/SERVER_DOWN' + TEMPORARILY_BLOCKED: + $ref: '#/components/responses/TEMPORARILY_BLOCKED' + TOKEN_EXPIRED: + $ref: '#/components/responses/TOKEN_EXPIRED' + TOKEN_MISSING: + $ref: '#/components/responses/TOKEN_MISSING' + TOKEN_UNKNOWN: + $ref: '#/components/responses/TOKEN_UNKNOWN' + /import_status: + get: + tags: + - upload + summary: Abfragen der Anzahl unverarbeiteter hochgeladener Angebotsdateien + description: Abfragen der Anzahl unverarbeiteter hochgeladener Angebotsdateien + security: + - tokenAuth: [ ] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + status: + type: string + returnValue: + type: mixed + examples: + SUCCESS: + description: Anzahl der aktuell unverarbeiteten Dateien + value: + status: OK + returnValue: "" + ENCODING_ERROR: + $ref: '#/components/responses/ENCODING_ERROR' + INVALID_INTERFACE: + $ref: '#/components/responses/INVALID_INTERFACE' + INVALID_REQUEST_METHOD: + $ref: '#/components/responses/INVALID_REQUEST_METHOD' + QUOTA_EXCEEDED: + $ref: '#/components/responses/QUOTA_EXCEEDED' + SERVER_DOWN: + $ref: '#/components/responses/SERVER_DOWN' + TEMPORARILY_BLOCKED: + $ref: '#/components/responses/TEMPORARILY_BLOCKED' + TOKEN_EXPIRED: + $ref: '#/components/responses/TOKEN_EXPIRED' + TOKEN_MISSING: + $ref: '#/components/responses/TOKEN_MISSING' + TOKEN_UNKNOWN: + $ref: '#/components/responses/TOKEN_UNKNOWN' + /order: + get: + tags: + - order + summary: Download aller Bestellungen eines bestimmten Tages + description: Download aller Bestellungen eines bestimmten Tages bzw. Zeitintervalls + security: + - tokenAuth: [ ] + parameters: + - name: orderId + description: | + Interne booklooker orderId der Bestellung, wird von der Schnittstelle + order zurückgeliefert. + in: query + required: true + schema: + type: integer + - name: date + description: | + Datum, zu dem Bestellungen angefordert werden, im Format YYYY-MM-DD. + Wenn übergeben, hat dieser Parameter Vorrang vor dateFrom und dateTo + in: query + required: false + schema: + type: string + - name: dateFrom + description: Startdatum, zu dem Bestellungen angefordert werden, im Format YYYY-MM-DD + in: query + required: false + schema: + type: string + - name: dateTo + description: Enddatum, zu dem Bestellungen angefordert werden, im Format YYYY-MM-DD + in: query + required: false + schema: + type: string + responses: + '200': + content: + application/json: + schema: + type: object + properties: + status: + type: string + returnValue: + type: mixed + examples: + SUCCESS: + description: | +

Alle Bestellungen des angeforderten Tages bzw. Zeitintervalls.

+

+ fett · Das Element ist immer vorhanden.
+ normal · Das Element ist nur dann vorhanden, wenn ein Wert existiert. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ElementBedeutung
orderIdInterne Booklooker orderId der Bestellung
+ orderDate
+ orderTime +
Datum und Uhrzeit der Bestellung
+ accountHolder
+ accountIban
+ accountBic +
+ Falls der Besteller als Zahlungsart Lastschrift gewählt hat und eine Bankverbindung + angegeben hat, stehen in diesen Feldern die Kontodaten: Kontoinhaber, IBAN und BIC. +
buyerPositiveRatingPercentage + Prozentzahl der positiven Bewertungen des Käufers + (0.0 - falls der Käufer noch keine Bewertung erhalten hat) +
buyerTotalRatingNumberAnzahl der erhaltenen Bewertungen des Käufers
buyerUsernameBenutzername des Käufers, bei Händlern der Firmenname
calculatedShippingCost + Dieses Element wird nur übergeben, wenn die Versandkosten berechnet werden konnten, + bspw. bei vorhandener Gewichtsangabe und Nutzung der Gewichts-Staffel-Tabelle zur + Portoberechnung, oder bei Nutzung von Pauschalpreisen +
commentsFreitextfeld, in das Kunden bei Bestellung spezielle Wünsche eintragen können
currentProvisionNet + Aktuelle Provision ohne MwSt. unter Berücksichtigung von Stornierungen - siehe auch + originalProvisionNet +
emailE-Mail-Adresse des Käufers
originalProvisionNet + Ursprüngliche Provision ohne MwSt. ohne Berücksichtigung von Stornierungen - siehe auch + currentProvisionNet. +
+ paymentConfirmed
+ transactionId +
+ Aktuell nur für Zahlungsart PayPal: Falls uns der Zeitpunkt der erfolgreichen Bezahlung vom + Zahlungsdienstleister gemeldet wurde, steht hier der Zeitpunkt im Format + YYYY-MM-DD HH:MM:SS. Weiterhin die von PayPal übermittelte txn_id der zugehörigen + Bezahlung. +
paymentId + Die vom Besteller gewählte Zahlungsart. Folgende Werte sind möglich: +
    +
  • 1 · Banküberweisung (Vorkasse)
  • +
  • 2 · Offene Rechnung
  • +
  • 11 · Offene Rechnung (Vorkasse vorbehalten)
  • +
  • 3 · Lastschrift (Vorkasse)
  • +
  • 4 · Kreditkarte (Vorkasse)
  • +
  • 5 · Nachnahme
  • +
  • 6 · PayPal (Vorkasse)
  • +
  • 8 · Skrill (Vorkasse)
  • +
  • 9 · Selbstabholung und Barzahlung
  • +
  • 10 · Sofortüberweisung
  • +
+
status + Mögliche Rückgabewerte siehe + order_status +
telTelefonnummer des Käufers
ustIdNrUmsatzsteuer-Identifikationsnummer des Käufers
+ invoiceAddress
+ deliveryAddress +
+ Rechnungs- und ggf. Lieferanschrift + + + + + + + + + + +
titleAnrede
nameNachname
firstNameVorname
companyFirma
addressSupplementZusatz
streetStraße und Hausnummer
zipPLZ
cityOrt
countryLand
+
orderItems + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
amountBestellmenge
author + Geistiger Schöpfer des Artikels, abhängig vom mediaType (s.u.): +
    +
  • Buch · Autor/in
  • +
  • Hörbuch · Autor/in
  • +
  • Film · Regisseur/in
  • +
  • Tonträger · Interpret/in
  • +
  • Spiel · Autor/in
  • +
+
infotext + Beschreibungstext des Artikels (diese Option muss von unserem Kundendienst aktiviert + werden, bitte schreiben Sie bei Bedarf eine E-Mail an support@booklooker.de +
mediaType +
    +
  • 0 · Buch
  • +
  • 3 · Hörbuch
  • +
  • 1 · Film
  • +
  • 2 · Tonträger
  • +
  • 4 · Spiel
  • +
+
notePersönliche Notiz/Lagerort (nur wenn vorhanden)
orderItemId + Interne booklooker orderItemId des Artikels, wichtig bei Nutzung der Schnittstelle + order_item_cancel +
orderNoIhre Bestellnummer
orderTitleTitel des Artikels
singlePricePreis des einzelnen Bestellpostens (unabhängig von Amount)
status +
    +
  • Ordered
  • +
  • Canceled
  • +
+
totalPriceRebatedGesamtpreis des Bestellpostens abzgl. evtl. Rabatte
+
+ value: + status: OK + returnValue: "" + INVALID_ORDERID: + description: Der Parameter orderId hat ein falsches Format. + value: + status: NOK + returnValue: INVALID_ORDERID + INVALID_DATE: + description: Der Parameter date hat ein falsches Datumsformat. + value: + status: NOK + returnValue: INVALID_DATE + INVALID_DATE_FROM: + description: Der Parameter dateFrom hat ein falsches Datumsformat. + value: + status: NOK + returnValue: INVALID_DATE_FROM + INVALID_DATE_TO: + description: Der Parameter dateTo hat ein falsches Datumsformat. + value: + status: NOK + returnValue: INVALID_DATE_TO + INVALID_DATE_INTERVAL: + description: Das maximal erlaubte Intervall beträgt 1 Monat. + value: + status: NOK + returnValue: INVALID_DATE_INTERVAL + ENCODING_ERROR: + $ref: '#/components/responses/ENCODING_ERROR' + INVALID_INTERFACE: + $ref: '#/components/responses/INVALID_INTERFACE' + INVALID_REQUEST_METHOD: + $ref: '#/components/responses/INVALID_REQUEST_METHOD' + QUOTA_EXCEEDED: + $ref: '#/components/responses/QUOTA_EXCEEDED' + SERVER_DOWN: + $ref: '#/components/responses/SERVER_DOWN' + TEMPORARILY_BLOCKED: + $ref: '#/components/responses/TEMPORARILY_BLOCKED' + TOKEN_EXPIRED: + $ref: '#/components/responses/TOKEN_EXPIRED' + TOKEN_MISSING: + $ref: '#/components/responses/TOKEN_MISSING' + TOKEN_UNKNOWN: + $ref: '#/components/responses/TOKEN_UNKNOWN' + /order_cancel: + put: + tags: + - order + summary: Stornieren einer kompletten Bestellung + description: Stornieren einer kompletten Bestellung + security: + - tokenAuth: [ ] + parameters: + - $ref: '#/components/parameters/orderIdParam' + responses: + '200': + content: + application/json: + schema: + type: object + properties: + status: + type: string + returnValue: + type: mixed + examples: + SUCCESS: + description: Die Bestellung wurde erfolgreich storniert. + value: + status: OK + returnValue: SUCCESS + INVALID_PARAMETERS: + description: Der Parameter orderId ist leer oder nicht vorhanden. + value: + status: NOK + returnValue: INVALID_PARAMETERS + NOT_FOUND: + $ref: '#/components/responses/NOT_FOUND_ORDERID' + NO_ARTICLES_FOUND: + description: Es wurden keine zu stornierenden Artikel gefunden. + value: + status: NOK + returnValue: NO_ARTICLES_FOUND + SIX_WEEKS_EXCEEDED: + $ref: '#/components/responses/SIX_WEEKS_EXCEEDED' + ENCODING_ERROR: + $ref: '#/components/responses/ENCODING_ERROR' + INVALID_INTERFACE: + $ref: '#/components/responses/INVALID_INTERFACE' + INVALID_REQUEST_METHOD: + $ref: '#/components/responses/INVALID_REQUEST_METHOD' + QUOTA_EXCEEDED: + $ref: '#/components/responses/QUOTA_EXCEEDED' + SERVER_DOWN: + $ref: '#/components/responses/SERVER_DOWN' + TEMPORARILY_BLOCKED: + $ref: '#/components/responses/TEMPORARILY_BLOCKED' + TOKEN_EXPIRED: + $ref: '#/components/responses/TOKEN_EXPIRED' + TOKEN_MISSING: + $ref: '#/components/responses/TOKEN_MISSING' + TOKEN_UNKNOWN: + $ref: '#/components/responses/TOKEN_UNKNOWN' + /order_item_cancel: + put: + tags: + - order + summary: Stornieren der Bestellung eines Einzelartikels + description: Stornieren der Bestellung eines Einzelartikels + security: + - tokenAuth: [ ] + parameters: + - name: orderItemId + description: | + Interne booklooker "orderItemId" des Artikels, wird von der Schnittstelle + order zurückgeliefert. + in: query + required: true + schema: + type: integer + - name: mediaType + description: | + Medientyp, mögliche Werte: + + in: query + required: true + schema: + type: integer + minimum: 0 + maximum: 4 + responses: + '200': + content: + application/json: + schema: + type: object + properties: + status: + type: string + returnValue: + type: mixed + examples: + SUCCESS: + description: Die Artikel wurde erfolgreich storniert. + value: + status: OK + returnValue: SUCCESS + INVALID_PARAMETERS: + description: | + Einer der Parameter orderItemId oder mediaType ist leer oder nicht vorhanden. + Auch ein falscher Wert für den Parameter mediaType erzeugt diesen Fehler. + value: + status: NOK + returnValue: INVALID_PARAMETERS + NOT_FOUND: + description: | + Die orderItemId wurde nicht gefunden. + Bitte achten Sie darauf, ausschließlich die orderItemId zu verwenden, + die Sie über die Schnittstelle + order ausgelesen haben. + value: + status: NOK + returnValue: NOT_FOUND + SIX_WEEKS_EXCEEDED: + $ref: '#/components/responses/SIX_WEEKS_EXCEEDED' + ENCODING_ERROR: + $ref: '#/components/responses/ENCODING_ERROR' + INVALID_INTERFACE: + $ref: '#/components/responses/INVALID_INTERFACE' + INVALID_REQUEST_METHOD: + $ref: '#/components/responses/INVALID_REQUEST_METHOD' + QUOTA_EXCEEDED: + $ref: '#/components/responses/QUOTA_EXCEEDED' + SERVER_DOWN: + $ref: '#/components/responses/SERVER_DOWN' + TEMPORARILY_BLOCKED: + $ref: '#/components/responses/TEMPORARILY_BLOCKED' + TOKEN_EXPIRED: + $ref: '#/components/responses/TOKEN_EXPIRED' + TOKEN_MISSING: + $ref: '#/components/responses/TOKEN_MISSING' + TOKEN_UNKNOWN: + $ref: '#/components/responses/TOKEN_UNKNOWN' + /order_message: + put: + tags: + - order + summary: Versand einer Nachricht an den Kunden + description: Versand einer Nachricht an den Kunden + security: + - tokenAuth: [ ] + parameters: + - $ref: '#/components/parameters/orderIdParam' + - name: messageType + description: | +

Art der Nachricht, mögliche Werte

+ + + + + +
WertBeschreibung
PAYMENT_INFORMATIONZahlungsinformationen senden
PAYMENT_REMINDERZahlungserinnerung senden
SHIPPING_NOTICEVersandmitteilung senden
+ in: query + required: true + schema: + type: string + enum: + - PAYMENT_INFORMATION + - PAYMENT_REMINDER + - SHIPPING_NOTICE + - name: additionalText + description: Zusätzlicher Text, der in die Nachricht aufgenommen wird + in: query + schema: + type: string + responses: + '200': + content: + application/json: + schema: + type: object + properties: + status: + type: string + returnValue: + type: mixed + examples: + SUCCESS: + description: Zurückgegeben wird der Nachrichtentext, der an den Kunden per Mail versandt wurde. + value: + status: OK + returnValue: "" + INVALID_ORDERID: + description: Der Parameter orderId fehlt oder ist nicht korrekt angegeben. + value: + status: NOK + returnValue: INVALID_ORDERID + INVALID_MESSAGE_TYPE: + description: Der Parameter messageType fehlt oder ist nicht korrekt angegeben. + value: + status: NOK + returnValue: INVALID_MESSAGE_TYPE + ORDER_NOT_FOUND: + $ref: '#/components/responses/NOT_FOUND_ORDERID' + NO_RECIPIENT: + description: Es ist kein Handelspartner für diesen Verkauf eingetragen. + value: + status: NOK + returnValue: NO_RECIPIENT + NO_ARTICLES: + description: Alle im Verkauf enthaltenen Artikel sind storniert. + value: + status: NOK + returnValue: NO_ARTICLES + ALREADY_SENT: + description: Die Nachricht dieses Typs wurde bereits versandt (nur bei Versandmitteilungen). + value: + status: NOK + returnValue: ALREADY_SENT + TIME_RESTRICTION: + description: | + Zeitliche Beschränkungen verhindern den Versand der Nachricht. Es gelten folgende Regeln: + + value: + status: NOK + returnValue: TIME_RESTRICTION + ENCODING_ERROR: + $ref: '#/components/responses/ENCODING_ERROR' + INVALID_INTERFACE: + $ref: '#/components/responses/INVALID_INTERFACE' + INVALID_REQUEST_METHOD: + $ref: '#/components/responses/INVALID_REQUEST_METHOD' + QUOTA_EXCEEDED: + $ref: '#/components/responses/QUOTA_EXCEEDED' + SERVER_DOWN: + $ref: '#/components/responses/SERVER_DOWN' + TEMPORARILY_BLOCKED: + $ref: '#/components/responses/TEMPORARILY_BLOCKED' + TOKEN_EXPIRED: + $ref: '#/components/responses/TOKEN_EXPIRED' + TOKEN_MISSING: + $ref: '#/components/responses/TOKEN_MISSING' + TOKEN_UNKNOWN: + $ref: '#/components/responses/TOKEN_UNKNOWN' + /order_status: + put: + tags: + - order + summary: Setzen des Status einer Bestellung + description: Setzen des Status einer Bestellung + security: + - tokenAuth: [ ] + parameters: + - $ref: '#/components/parameters/orderIdParam' + - name: status + in: query + required: true + schema: + type: string + enum: + - BUYER_NO_REACTION + - CANCELED + - PAID_WAITING_FOR_SHIPMENT + - READY_FOR_SHIPMENT + - SHIPPED_AND_PAID + - SHIPPED_WAITING_FOR_PAYMENT + - TO_BE_PAID + - VENDOR_NO_REACTION + - WAITING_FOR_PAYMENT + - WAITING_FOR_SHIPMENT + description: | +

Neuer Status der Bestellung

+ +

+ Achtung: Das Setzen des Status CANCELED + ist nicht gleichbedeutend mit dem Aufruf der Schnittstelle + order_item_cancel. + Der Status CANCELED dient lediglich der + Eigenorganisation des Verkäufers. Um eine Bestellung so zu + stornieren, dass keine Provision anfällt, muss + order_item_cancel + aufgerufen werden. +

+ +

Bei Verkäufen sind die folgenden Status möglich

+ + + + + + + + + +
StatusBedeutung
WAITING_FOR_PAYMENTwarte auf Zahlung
READY_FOR_SHIPMENTfertig zum Versand
SHIPPED_WAITING_FOR_PAYMENTversendet, warte auf Zahlung
SHIPPED_AND_PAIDversendet & bezahlt
BUYER_NO_REACTIONKunde reagiert nicht
CANCELEDstorniert
+ +

Bei Käufen sind die folgenden Status möglich

+ + + + + + + + + + +
StatusBedeutung
TO_BE_PAIDZahlung offen
WAITING_FOR_PAYMENT_INFOwarte auf Zahlungsinformationen
WAITING_FOR_SHIPMENTwarte auf Lieferung
PAID_WAITING_FOR_SHIPMENTbezahlt, warte auf Lieferung
RECEIVED_AND_PAIDerhalten & bezahlt
VENDOR_NO_REACTIONVerkäufer reagiert nicht
CANCELEDstorniert
+ responses: + '200': + content: + application/json: + schema: + type: object + properties: + status: + type: string + returnValue: + type: mixed + examples: + SUCCESS: + description: Der Status wurde erfolgreich gesetzt. + value: + status: OK + returnValue: SUCCESS + INVALID_PARAMETERS: + description: Einer der Parameter orderId oder status ist leer oder nicht vorhanden. + value: + status: NOK + returnValue: INVALID_PARAMETERS + NOT_FOUND: + $ref: '#/components/responses/NOT_FOUND_ORDERID' + STATUS_NOT_VALID: + description: Kein gültiger Wert für den Parameter status. + value: + status: NOK + returnValue: STATUS_NOT_VALID + ENCODING_ERROR: + $ref: '#/components/responses/ENCODING_ERROR' + INVALID_INTERFACE: + $ref: '#/components/responses/INVALID_INTERFACE' + INVALID_REQUEST_METHOD: + $ref: '#/components/responses/INVALID_REQUEST_METHOD' + QUOTA_EXCEEDED: + $ref: '#/components/responses/QUOTA_EXCEEDED' + SERVER_DOWN: + $ref: '#/components/responses/SERVER_DOWN' + TEMPORARILY_BLOCKED: + $ref: '#/components/responses/TEMPORARILY_BLOCKED' + TOKEN_EXPIRED: + $ref: '#/components/responses/TOKEN_EXPIRED' + TOKEN_MISSING: + $ref: '#/components/responses/TOKEN_MISSING' + TOKEN_UNKNOWN: + $ref: '#/components/responses/TOKEN_UNKNOWN' +components: + securitySchemes: + tokenAuth: + type: apiKey + in: query + name: token + parameters: + orderNoParam: + name: orderNo + description: Ihre Bestellnummer + in: query + required: true + schema: + type: string + orderIdParam: + name: orderId + description: | + Interne booklooker orderId der Bestellung, wird von der Schnittstelle + order zurückgeliefert. + in: query + required: true + schema: + type: integer + responses: + SERVER_DOWN: + description: Aufgrund von Wartungsarbeiten ist die REST API momentan nicht verfügbar. + value: + status: NOK + returnValue: SERVER_DOWN + INVALID_INTERFACE: + description: Es wurde eine ungültige Schnittstelle verwendet. + value: + status: NOK + returnValue: INVALID_INTERFACE + INVALID_REQUEST_METHOD: + description: Die Schnittstelle wurde mit einer ungültigen HTTP-Methode aufgerufen. + value: + status: NOK + returnValue: INVALID_REQUEST_METHOD + TOKEN_MISSING: + description: Der Token ist leer oder nicht vorhanden. + value: + status: NOK + returnValue: TOKEN_MISSING + TOKEN_UNKNOWN: + description: Der Token wurde nicht gefunden. + value: + status: NOK + returnValue: TOKEN_UNKNOWN + TOKEN_EXPIRED: + description: Der Token ist nicht mehr gültig, eine erneute Authentifizierung ist notwendig. + value: + status: NOK + returnValue: TOKEN_EXPIRED + TEMPORARILY_BLOCKED: + description: Der Zugang wurde vorübergehend gesperrt, bitte wenden Sie sich an unseren Kundensupport. + value: + status: NOK + returnValue: TEMPORARILY_BLOCKED + QUOTA_EXCEEDED: + description: Die maximale Anzahl Abfragen/Minute wurde überschritten. + value: + status: NOK + returnValue: QUOTA_EXCEEDED + ENCODING_ERROR: + description: Die Rückgabe enthält ungültige Zeichen. + value: + status: NOK + returnValue: ENCODING_ERROR + SIX_WEEKS_EXCEEDED: + description: Eine Stornierung ist nur 6 Wochen nach Bestelleingang möglich. + value: + status: NOK + returnValue: SIX_WEEKS_EXCEEDED + INVALID_PARAMETER_ORDERNO: + description: Der Parameter orderNo ist leer oder nicht vorhanden. + value: + status: NOK + returnValue: INVALID_PARAMETERS + NOT_FOUND_ORDERNO: + description: Die Bestellnummer wurde nicht gefunden. + value: + status: NOK + returnValue: NOT_FOUND + NOT_FOUND_ORDERID: + description: Die orderId wurde nicht gefunden. + value: + status: NOK + returnValue: NOT_FOUND diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a782471 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/src/booklooker_client/__init__.py b/src/booklooker_client/__init__.py new file mode 100644 index 0000000..8d97cc9 --- /dev/null +++ b/src/booklooker_client/__init__.py @@ -0,0 +1,11 @@ +from .client import AsyncBooklookerClient, SyncBooklookerClient +from .config import BooklookerConfig +from .webhooks import BooklookerWebhookHelper, InMemoryIdempotencyStore + +__all__ = [ + "AsyncBooklookerClient", + "SyncBooklookerClient", + "BooklookerConfig", + "BooklookerWebhookHelper", + "InMemoryIdempotencyStore", +] diff --git a/src/booklooker_client/client.py b/src/booklooker_client/client.py new file mode 100644 index 0000000..d3541c1 --- /dev/null +++ b/src/booklooker_client/client.py @@ -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) diff --git a/src/booklooker_client/config.py b/src/booklooker_client/config.py new file mode 100644 index 0000000..749edc0 --- /dev/null +++ b/src/booklooker_client/config.py @@ -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") diff --git a/src/booklooker_client/exceptions.py b/src/booklooker_client/exceptions.py new file mode 100644 index 0000000..657bc8e --- /dev/null +++ b/src/booklooker_client/exceptions.py @@ -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) diff --git a/src/booklooker_client/generated/__init__.py b/src/booklooker_client/generated/__init__.py new file mode 100644 index 0000000..6ac24a8 --- /dev/null +++ b/src/booklooker_client/generated/__init__.py @@ -0,0 +1,3 @@ +from .contracts import API_INFO, ENDPOINTS, SECURITY_SCHEMES, TAGS + +__all__ = ["API_INFO", "ENDPOINTS", "SECURITY_SCHEMES", "TAGS"] diff --git a/src/booklooker_client/generated/contracts.py b/src/booklooker_client/generated/contracts.py new file mode 100644 index 0000000..6ec7cd9 --- /dev/null +++ b/src/booklooker_client/generated/contracts.py @@ -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" + ] + } +} diff --git a/src/booklooker_client/generator.py b/src/booklooker_client/generator.py new file mode 100644 index 0000000..42a615b --- /dev/null +++ b/src/booklooker_client/generator.py @@ -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) diff --git a/src/booklooker_client/models/__init__.py b/src/booklooker_client/models/__init__.py new file mode 100644 index 0000000..6458a70 --- /dev/null +++ b/src/booklooker_client/models/__init__.py @@ -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", +] diff --git a/src/booklooker_client/models/article.py b/src/booklooker_client/models/article.py new file mode 100644 index 0000000..26428c4 --- /dev/null +++ b/src/booklooker_client/models/article.py @@ -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 diff --git a/src/booklooker_client/models/common.py b/src/booklooker_client/models/common.py new file mode 100644 index 0000000..609460e --- /dev/null +++ b/src/booklooker_client/models/common.py @@ -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 diff --git a/src/booklooker_client/models/order.py b/src/booklooker_client/models/order.py new file mode 100644 index 0000000..b78a439 --- /dev/null +++ b/src/booklooker_client/models/order.py @@ -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 diff --git a/src/booklooker_client/models/webhook.py b/src/booklooker_client/models/webhook.py new file mode 100644 index 0000000..bf8c277 --- /dev/null +++ b/src/booklooker_client/models/webhook.py @@ -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 diff --git a/src/booklooker_client/webhooks.py b/src/booklooker_client/webhooks.py new file mode 100644 index 0000000..61d1cf4 --- /dev/null +++ b/src/booklooker_client/webhooks.py @@ -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''' diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..2915131 --- /dev/null +++ b/tests/test_models.py @@ -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() diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 0000000..6fedd24 --- /dev/null +++ b/tests/test_webhooks.py @@ -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}