- Remove |safe from |tojson in HTML attributes (x-data) - quotes must become " for browsers to parse correctly - Update LANG-002 and LANG-003 architecture rules to document correct |tojson usage patterns: - HTML attributes: |tojson (no |safe) - Script blocks: |tojson|safe - Fix validator to warn when |tojson|safe is used in x-data (breaks HTML attribute parsing) - Improve code quality across services, APIs, and tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
488 lines
13 KiB
Python
488 lines
13 KiB
Python
# app/services/letzshop/client_service.py
|
|
"""
|
|
GraphQL client for Letzshop marketplace API.
|
|
|
|
Handles authentication, request formatting, and error handling
|
|
for all Letzshop API operations.
|
|
"""
|
|
|
|
import logging
|
|
import time
|
|
from typing import Any
|
|
|
|
import requests
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Default API endpoint
|
|
DEFAULT_ENDPOINT = "https://letzshop.lu/graphql"
|
|
|
|
|
|
class LetzshopClientError(Exception):
|
|
"""Base exception for Letzshop client errors."""
|
|
|
|
def __init__(self, message: str, response_data: dict | None = None):
|
|
super().__init__(message)
|
|
self.message = message
|
|
self.response_data = response_data
|
|
|
|
|
|
class LetzshopAuthError(LetzshopClientError):
|
|
"""Raised when authentication fails."""
|
|
|
|
|
|
class LetzshopAPIError(LetzshopClientError):
|
|
"""Raised when the API returns an error response."""
|
|
|
|
|
|
class LetzshopConnectionError(LetzshopClientError):
|
|
"""Raised when connection to the API fails."""
|
|
|
|
|
|
# ============================================================================
|
|
# GraphQL Queries
|
|
# ============================================================================
|
|
|
|
QUERY_SHIPMENTS = """
|
|
query GetShipments($state: ShipmentState) {
|
|
shipments(state: $state) {
|
|
nodes {
|
|
id
|
|
number
|
|
state
|
|
createdAt
|
|
updatedAt
|
|
order {
|
|
id
|
|
number
|
|
email
|
|
totalPrice {
|
|
amount
|
|
currency
|
|
}
|
|
lineItems {
|
|
nodes {
|
|
id
|
|
name
|
|
quantity
|
|
price {
|
|
amount
|
|
currency
|
|
}
|
|
}
|
|
}
|
|
shippingAddress {
|
|
firstName
|
|
lastName
|
|
company
|
|
address1
|
|
address2
|
|
city
|
|
zip
|
|
country
|
|
}
|
|
billingAddress {
|
|
firstName
|
|
lastName
|
|
company
|
|
address1
|
|
address2
|
|
city
|
|
zip
|
|
country
|
|
}
|
|
}
|
|
inventoryUnits {
|
|
nodes {
|
|
id
|
|
state
|
|
variant {
|
|
id
|
|
sku
|
|
name
|
|
}
|
|
}
|
|
}
|
|
tracking {
|
|
code
|
|
provider
|
|
}
|
|
}
|
|
pageInfo {
|
|
hasNextPage
|
|
endCursor
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
QUERY_SHIPMENT_BY_ID = """
|
|
query GetShipment($id: ID!) {
|
|
node(id: $id) {
|
|
... on Shipment {
|
|
id
|
|
number
|
|
state
|
|
createdAt
|
|
updatedAt
|
|
order {
|
|
id
|
|
number
|
|
email
|
|
totalPrice {
|
|
amount
|
|
currency
|
|
}
|
|
}
|
|
inventoryUnits {
|
|
nodes {
|
|
id
|
|
state
|
|
variant {
|
|
id
|
|
sku
|
|
name
|
|
}
|
|
}
|
|
}
|
|
tracking {
|
|
code
|
|
provider
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
# ============================================================================
|
|
# GraphQL Mutations
|
|
# ============================================================================
|
|
|
|
MUTATION_CONFIRM_INVENTORY_UNITS = """
|
|
mutation ConfirmInventoryUnits($input: ConfirmInventoryUnitsInput!) {
|
|
confirmInventoryUnits(input: $input) {
|
|
inventoryUnits {
|
|
id
|
|
state
|
|
}
|
|
errors {
|
|
id
|
|
code
|
|
message
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
MUTATION_REJECT_INVENTORY_UNITS = """
|
|
mutation RejectInventoryUnits($input: RejectInventoryUnitsInput!) {
|
|
returnInventoryUnits(input: $input) {
|
|
inventoryUnits {
|
|
id
|
|
state
|
|
}
|
|
errors {
|
|
id
|
|
code
|
|
message
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
MUTATION_SET_SHIPMENT_TRACKING = """
|
|
mutation SetShipmentTracking($input: SetShipmentTrackingInput!) {
|
|
setShipmentTracking(input: $input) {
|
|
shipment {
|
|
id
|
|
tracking {
|
|
code
|
|
provider
|
|
}
|
|
}
|
|
errors {
|
|
code
|
|
message
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
|
|
class LetzshopClient:
|
|
"""
|
|
GraphQL client for Letzshop marketplace API.
|
|
|
|
Usage:
|
|
client = LetzshopClient(api_key="your-api-key")
|
|
shipments = client.get_shipments(state="unconfirmed")
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
api_key: str,
|
|
endpoint: str = DEFAULT_ENDPOINT,
|
|
timeout: int = 30,
|
|
):
|
|
"""
|
|
Initialize the Letzshop client.
|
|
|
|
Args:
|
|
api_key: The Letzshop API key (Bearer token).
|
|
endpoint: The GraphQL endpoint URL.
|
|
timeout: Request timeout in seconds.
|
|
"""
|
|
self.api_key = api_key
|
|
self.endpoint = endpoint
|
|
self.timeout = timeout
|
|
self._session: requests.Session | None = None
|
|
|
|
@property
|
|
def session(self) -> requests.Session:
|
|
"""Get or create a requests session."""
|
|
if self._session is None:
|
|
self._session = requests.Session()
|
|
self._session.headers.update(
|
|
{
|
|
"Authorization": f"Bearer {self.api_key}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
)
|
|
return self._session
|
|
|
|
def close(self) -> None:
|
|
"""Close the HTTP session."""
|
|
if self._session is not None:
|
|
self._session.close()
|
|
self._session = None
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
self.close()
|
|
return False
|
|
|
|
def _execute(
|
|
self,
|
|
query: str,
|
|
variables: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Execute a GraphQL query or mutation.
|
|
|
|
Args:
|
|
query: The GraphQL query or mutation string.
|
|
variables: Optional variables for the query.
|
|
|
|
Returns:
|
|
The response data from the API.
|
|
|
|
Raises:
|
|
LetzshopAuthError: If authentication fails.
|
|
LetzshopAPIError: If the API returns an error.
|
|
LetzshopConnectionError: If the request fails.
|
|
"""
|
|
payload = {"query": query}
|
|
if variables:
|
|
payload["variables"] = variables
|
|
|
|
logger.debug(f"Executing GraphQL request to {self.endpoint}")
|
|
|
|
try:
|
|
response = self.session.post(
|
|
self.endpoint,
|
|
json=payload,
|
|
timeout=self.timeout,
|
|
)
|
|
except requests.exceptions.Timeout as e:
|
|
raise LetzshopConnectionError(f"Request timed out: {e}") from e
|
|
except requests.exceptions.ConnectionError as e:
|
|
raise LetzshopConnectionError(f"Connection failed: {e}") from e
|
|
except requests.exceptions.RequestException as e:
|
|
raise LetzshopConnectionError(f"Request failed: {e}") from e
|
|
|
|
# Handle HTTP-level errors
|
|
if response.status_code == 401:
|
|
raise LetzshopAuthError(
|
|
"Authentication failed. Please check your API key.",
|
|
response_data={"status_code": 401},
|
|
)
|
|
if response.status_code == 403:
|
|
raise LetzshopAuthError(
|
|
"Access forbidden. Your API key may not have the required permissions.",
|
|
response_data={"status_code": 403},
|
|
)
|
|
if response.status_code >= 500:
|
|
raise LetzshopAPIError(
|
|
f"Letzshop server error (HTTP {response.status_code})",
|
|
response_data={"status_code": response.status_code},
|
|
)
|
|
|
|
# Parse JSON response
|
|
try:
|
|
data = response.json()
|
|
except ValueError as e:
|
|
raise LetzshopAPIError(
|
|
f"Invalid JSON response: {response.text[:200]}"
|
|
) from e
|
|
|
|
# Check for GraphQL errors
|
|
if "errors" in data and data["errors"]:
|
|
error_messages = [
|
|
err.get("message", "Unknown error") for err in data["errors"]
|
|
]
|
|
raise LetzshopAPIError(
|
|
f"GraphQL errors: {'; '.join(error_messages)}",
|
|
response_data=data,
|
|
)
|
|
|
|
return data.get("data", {})
|
|
|
|
# ========================================================================
|
|
# Connection Testing
|
|
# ========================================================================
|
|
|
|
def test_connection(self) -> tuple[bool, float, str | None]:
|
|
"""
|
|
Test the connection to Letzshop API.
|
|
|
|
Returns:
|
|
Tuple of (success, response_time_ms, error_message).
|
|
"""
|
|
test_query = """
|
|
query TestConnection {
|
|
__typename
|
|
}
|
|
"""
|
|
|
|
start_time = time.time()
|
|
|
|
try:
|
|
self._execute(test_query)
|
|
elapsed_ms = (time.time() - start_time) * 1000
|
|
return True, elapsed_ms, None
|
|
except LetzshopClientError as e:
|
|
elapsed_ms = (time.time() - start_time) * 1000
|
|
return False, elapsed_ms, str(e)
|
|
|
|
# ========================================================================
|
|
# Shipment Queries
|
|
# ========================================================================
|
|
|
|
def get_shipments(
|
|
self,
|
|
state: str | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""
|
|
Get shipments from Letzshop.
|
|
|
|
Args:
|
|
state: Optional state filter (e.g., "unconfirmed", "confirmed").
|
|
|
|
Returns:
|
|
List of shipment data dictionaries.
|
|
"""
|
|
variables = {}
|
|
if state:
|
|
variables["state"] = state
|
|
|
|
data = self._execute(QUERY_SHIPMENTS, variables)
|
|
shipments_data = data.get("shipments", {})
|
|
return shipments_data.get("nodes", [])
|
|
|
|
def get_unconfirmed_shipments(self) -> list[dict[str, Any]]:
|
|
"""Get all unconfirmed shipments."""
|
|
return self.get_shipments(state="unconfirmed")
|
|
|
|
def get_shipment_by_id(self, shipment_id: str) -> dict[str, Any] | None:
|
|
"""
|
|
Get a single shipment by its ID.
|
|
|
|
Args:
|
|
shipment_id: The Letzshop shipment ID.
|
|
|
|
Returns:
|
|
Shipment data or None if not found.
|
|
"""
|
|
data = self._execute(QUERY_SHIPMENT_BY_ID, {"id": shipment_id})
|
|
return data.get("node")
|
|
|
|
# ========================================================================
|
|
# Fulfillment Mutations
|
|
# ========================================================================
|
|
|
|
def confirm_inventory_units(
|
|
self,
|
|
inventory_unit_ids: list[str],
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Confirm inventory units for fulfillment.
|
|
|
|
Args:
|
|
inventory_unit_ids: List of inventory unit IDs to confirm.
|
|
|
|
Returns:
|
|
Response data including confirmed units and any errors.
|
|
"""
|
|
variables = {
|
|
"input": {
|
|
"inventoryUnitIds": inventory_unit_ids,
|
|
}
|
|
}
|
|
|
|
data = self._execute(MUTATION_CONFIRM_INVENTORY_UNITS, variables)
|
|
return data.get("confirmInventoryUnits", {})
|
|
|
|
def reject_inventory_units(
|
|
self,
|
|
inventory_unit_ids: list[str],
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Reject/return inventory units.
|
|
|
|
Args:
|
|
inventory_unit_ids: List of inventory unit IDs to reject.
|
|
|
|
Returns:
|
|
Response data including rejected units and any errors.
|
|
"""
|
|
variables = {
|
|
"input": {
|
|
"inventoryUnitIds": inventory_unit_ids,
|
|
}
|
|
}
|
|
|
|
data = self._execute(MUTATION_REJECT_INVENTORY_UNITS, variables)
|
|
return data.get("returnInventoryUnits", {})
|
|
|
|
def set_shipment_tracking(
|
|
self,
|
|
shipment_id: str,
|
|
tracking_code: str,
|
|
tracking_provider: str,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Set tracking information for a shipment.
|
|
|
|
Args:
|
|
shipment_id: The Letzshop shipment ID.
|
|
tracking_code: The tracking number.
|
|
tracking_provider: The carrier code (e.g., "dhl", "ups").
|
|
|
|
Returns:
|
|
Response data including updated shipment and any errors.
|
|
"""
|
|
variables = {
|
|
"input": {
|
|
"shipmentId": shipment_id,
|
|
"tracking": {
|
|
"code": tracking_code,
|
|
"provider": tracking_provider,
|
|
},
|
|
}
|
|
}
|
|
|
|
data = self._execute(MUTATION_SET_SHIPMENT_TRACKING, variables)
|
|
return data.get("setShipmentTracking", {})
|