Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 78 additions & 1 deletion brenger/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
from requests import Response

from .exceptions import APIClientError, APIServerError
from .models import ShipmentCreateRequest, ShipmentResponse
from .models import (ShipmentCreateRequest, ShipmentResponse, V2QuoteRequest,
V2QuoteResponse, V2RefundResponse,
V2ShipmentCreateRequest, V2ShipmentCreateResponse,
V2StatusResponse)

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -58,3 +61,77 @@ def _handle_response_errors(self, response: Response) -> None:
logger.error(f"Client Error: {error_message}")
raise APIClientError(f"Client Error: {error_message}")
response.raise_for_status()


V2_BASE_URL = "https://external-api.brenger.nl/v2/partners"


class BrengerV2APIClient:
def __init__(self, api_key: str) -> None:
self.api_key = api_key
self.session = requests.Session()
self.session.headers.update({"X-AUTH-TOKEN": self.api_key, **HEADERS})

def get_quote(self, quote_data: V2QuoteRequest) -> V2QuoteResponse:
url = f"{V2_BASE_URL}/quote"
response = self.session.post(
url, data=quote_data.model_dump_json(exclude_none=True).encode("utf-8")
)
self._handle_response_errors(response)
logger.info("Quote retrieved successfully")
return V2QuoteResponse(**response.json())

def create_shipment(
self, shipment_data: V2ShipmentCreateRequest
) -> V2ShipmentCreateResponse:
url = f"{V2_BASE_URL}/shipments"
response = self.session.post(
url, data=shipment_data.model_dump_json(exclude_none=True).encode("utf-8")
)
self._handle_response_errors(response)
logger.info(
"V2 Shipment created successfully with ID: %s",
response.json().get("shipment_id"),
)
return V2ShipmentCreateResponse(**response.json())

def get_shipment_status(self, shipment_id: str) -> V2StatusResponse:
url = f"{V2_BASE_URL}/shipments/{shipment_id}/status"
response = self.session.get(url)
self._handle_response_errors(response)
logger.info("Shipment status retrieved for ID: %s", shipment_id)
return V2StatusResponse(**response.json())

def cancel_shipment(self, shipment_id: str) -> None:
url = f"{V2_BASE_URL}/shipments/{shipment_id}/cancel"
response = self.session.post(url)
self._handle_response_errors(response)
logger.info("Shipment cancelled successfully for ID: %s", shipment_id)

def get_refund(self, shipment_id: str) -> V2RefundResponse:
url = f"{V2_BASE_URL}/shipments/{shipment_id}/refunds"
response = self.session.get(url)
self._handle_response_errors(response)
logger.info("Refund retrieved for shipment ID: %s", shipment_id)
return V2RefundResponse(**response.json())

def _handle_response_errors(self, response: Response) -> None:
if response.status_code == 500:
logger.error("Server Error")
raise APIServerError("Internal Server Error")
elif response.status_code >= 400:
response_json = response.json()
error_description = response_json.get("description", "An error occurred")
error_hint = response_json.get("hint", "Hint not provided")
error_validation = response_json.get(
"validation_errors", "Validation not provided"
)
error_message = (
f" Status code: {response.status_code} - "
f"Error description: {error_description} -"
f" Error hint: {error_hint} - "
f" validation errors: {error_validation}"
)
logger.error(f"Client Error: {error_message}")
raise APIClientError(f"Client Error: {error_message}")
response.raise_for_status()
144 changes: 144 additions & 0 deletions brenger/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,147 @@ class ShipmentResponse(BaseModel):

class Config:
extra = "ignore"


# ============================================================================
# V2 API Models
# ============================================================================


class V2Address(BaseModel):
country: str
administrative_area: Optional[str] = None
locality: str
postal_code: str
line1: str
line2: Optional[str] = None
lat: Optional[float] = None
lng: Optional[float] = None

_strip_whitespace = field_validator(
"line1", "line2", "postal_code", "locality", mode="before"
)(strip_whitespace)


class V2Stop(BaseModel):
email: str
phone_number: Optional[str] = None
instructions: Optional[str] = None
first_name: str
last_name: Optional[str] = None
company_name: Optional[str] = None
preferred_locale: Optional[str] = None
address: V2Address

_strip_whitespace = field_validator(
"email", "first_name", "last_name", "company_name", mode="before"
)(strip_whitespace)


class V2Item(BaseModel):
title: str
category: str = "other"
images: Optional[List[str]] = None
width: int
height: int
length: int
count: int

_strip_whitespace = field_validator("title", "category", mode="before")(
strip_whitespace
)


class V2Amount(BaseModel):
currency: str
value: str


class V2Price(BaseModel):
vat: V2Amount
incl_vat: V2Amount
excl_vat: V2Amount


class V2Feasible(BaseModel):
value: bool
reasons: List[str]


class V2QuoteRequest(BaseModel):
pickup: Dict
delivery: Dict
external_reference: Optional[str] = None
items: List[V2Item]


class V2QuoteResponse(BaseModel):
price: V2Price
feasible: V2Feasible
pickup: Dict
delivery: Dict
external_reference: Optional[str] = None
items: List[V2Item]

class Config:
extra = "ignore"


class V2ShipmentCreateRequest(BaseModel):
pickup: V2Stop
delivery: V2Stop
external_reference: str
external_listing_url: Optional[str] = None
external_private_url: Optional[str] = None
items: List[V2Item]
price: Optional[V2Price] = None


class V2ShipmentCreateResponse(BaseModel):
shipment_id: str
pickup: V2Stop
delivery: V2Stop
pickup_url: str
delivery_url: str
shipment_url: str
external_reference: str
external_listing_url: Optional[str] = None
external_private_url: Optional[str] = None
items: List[V2Item]
price: V2Price

class Config:
extra = "ignore"


class V2Event(BaseModel):
id: str
timestamp: str
status: str


class V2StatusResponse(BaseModel):
shipment_id: str
external_reference: str
status: str
events: List[V2Event]

class Config:
extra = "ignore"


class V2RefundResponse(BaseModel):
refund_id: str
amount: V2Price
code: str

class Config:
extra = "ignore"


class V2WebhookPayload(BaseModel):
event_id: str
shipment_id: str
external_reference: str
timestamp: str
status: str
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "brenger"
version = "0.1.8"
version = "2.0.0"
description = ""
authors = ["Omer <omer@whoppah.com>"]
readme = "README.md"
Expand Down