# 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", {})