-
Notifications
You must be signed in to change notification settings - Fork 0
Add Brenger API v2 client and models #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,16 +1,32 @@ | ||||||||||||||||||
| import logging | ||||||||||||||||||
|
|
||||||||||||||||||
| import requests | ||||||||||||||||||
| from requests import Response | ||||||||||||||||||
| from requests import RequestException, 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__) | ||||||||||||||||||
|
|
||||||||||||||||||
| BASE_URL = "https://external-api.brenger.nl/{namespace}" | ||||||||||||||||||
| HEADERS = {"Accept": "application/json", "Content-Type": "application/json"} | ||||||||||||||||||
| DEFAULT_TIMEOUT = 30 | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| def _extract_error_details(response: Response) -> tuple[str, str, str]: | ||||||||||||||||||
| try: | ||||||||||||||||||
| response_json = response.json() | ||||||||||||||||||
| except ValueError: | ||||||||||||||||||
| return ("An error occurred", "Hint not provided", "Validation not provided") | ||||||||||||||||||
|
|
||||||||||||||||||
| 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") | ||||||||||||||||||
| return (error_description, error_hint, error_validation) | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| class BrengerAPIClient: | ||||||||||||||||||
|
|
@@ -39,22 +55,107 @@ def get_shipment(self, shipment_id: str) -> ShipmentResponse: | |||||||||||||||||
| return ShipmentResponse(**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" | ||||||||||||||||||
| if 500 <= response.status_code < 600: | ||||||||||||||||||
| logger.error("Server Error: status code %s", response.status_code) | ||||||||||||||||||
| raise APIServerError( | ||||||||||||||||||
| f"Brenger API server error (status code: {response.status_code})" | ||||||||||||||||||
| ) | ||||||||||||||||||
| if response.status_code >= 400: | ||||||||||||||||||
| error_description, error_hint, error_validation = _extract_error_details( | ||||||||||||||||||
| response | ||||||||||||||||||
| ) | ||||||||||||||||||
| 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("Client Error: %s", error_message) | ||||||||||||||||||
| raise APIClientError(f"Client Error: {error_message}") | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| V2_BASE_URL = "https://external-api.brenger.nl/v2/partners" | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| class BrengerV2APIClient: | ||||||||||||||||||
| def __init__( | ||||||||||||||||||
| self, api_key: str, timeout: int = DEFAULT_TIMEOUT, base_url: str = None | ||||||||||||||||||
| ) -> None: | ||||||||||||||||||
| self.api_key = api_key | ||||||||||||||||||
| self.timeout = timeout | ||||||||||||||||||
| self.base_url = base_url or V2_BASE_URL | ||||||||||||||||||
| 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"{self.base_url}/quote" | ||||||||||||||||||
| response = self._request( | ||||||||||||||||||
| "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"{self.base_url}/shipments" | ||||||||||||||||||
| response = self._request( | ||||||||||||||||||
| "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()) | ||||||||||||||||||
|
Comment on lines
+111
to
+115
|
||||||||||||||||||
|
|
||||||||||||||||||
| def get_shipment_status(self, shipment_id: str) -> V2StatusResponse: | ||||||||||||||||||
| url = f"{self.base_url}/shipments/{shipment_id}/status" | ||||||||||||||||||
| response = self._request("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"{self.base_url}/shipments/{shipment_id}/cancel" | ||||||||||||||||||
| response = self._request("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"{self.base_url}/shipments/{shipment_id}/refunds" | ||||||||||||||||||
| response = self._request("get", url) | ||||||||||||||||||
| self._handle_response_errors(response) | ||||||||||||||||||
| logger.info("Refund retrieved for shipment ID: %s", shipment_id) | ||||||||||||||||||
| return V2RefundResponse(**response.json()) | ||||||||||||||||||
|
|
||||||||||||||||||
| def _request(self, method: str, url: str, **kwargs) -> Response: | ||||||||||||||||||
| try: | ||||||||||||||||||
| return self.session.request(method, url, timeout=self.timeout, **kwargs) | ||||||||||||||||||
| except RequestException as exc: | ||||||||||||||||||
| logger.error("Network error while calling Brenger v2 API", exc_info=True) | ||||||||||||||||||
| raise APIServerError(f"Failed to call Brenger v2 API: {exc}") from exc | ||||||||||||||||||
|
|
||||||||||||||||||
| def _handle_response_errors(self, response: Response) -> None: | ||||||||||||||||||
| if 500 <= response.status_code < 600: | ||||||||||||||||||
| logger.error("Server Error: status code %s", response.status_code) | ||||||||||||||||||
| raise APIServerError( | ||||||||||||||||||
| f"Brenger API server error (status code: {response.status_code})" | ||||||||||||||||||
| ) | ||||||||||||||||||
| if response.status_code >= 400: | ||||||||||||||||||
| error_description, error_hint, error_validation = _extract_error_details( | ||||||||||||||||||
| response | ||||||||||||||||||
| ) | ||||||||||||||||||
| error_message = ( | ||||||||||||||||||
| f" Status code: {response.status_code} - " | ||||||||||||||||||
| f"Error description: {error_description} -" | ||||||||||||||||||
| f" Error hint: {error_hint} - " | ||||||||||||||||||
| f" validation errors: {error_validation}" | ||||||||||||||||||
|
Comment on lines
+155
to
+158
|
||||||||||||||||||
| f" Status code: {response.status_code} - " | |
| f"Error description: {error_description} -" | |
| f" Error hint: {error_hint} - " | |
| f" validation errors: {error_validation}" | |
| f"Status code: {response.status_code} - " | |
| f"Error description: {error_description} - " | |
| f"Error hint: {error_hint} - " | |
| f"Validation errors: {error_validation}" |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -145,3 +145,151 @@ 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" | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
| "line1", "line2", "postal_code", "locality", mode="before" | |
| "country", | |
| "administrative_area", | |
| "line1", | |
| "line2", | |
| "postal_code", | |
| "locality", | |
| mode="before", |
Copilot
AI
Feb 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The V2Stop model is missing strip_whitespace validation on the 'phone_number' and 'instructions' fields. The V1 Contact model applies strip_whitespace to the 'phone' field, and the codebase convention indicates this is necessary to prevent API failures. For consistency with V1 models and to prevent potential API issues, these fields should also have strip_whitespace validation applied.
| "email", "first_name", "last_name", "company_name", mode="before" | |
| "email", | |
| "phone_number", | |
| "instructions", | |
| "first_name", | |
| "last_name", | |
| "company_name", | |
| mode="before", |
Copilot
AI
Feb 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The V2Stop model is missing strip_whitespace validation on the 'preferred_locale' field. Following the codebase convention that all string fields should have strip_whitespace validation to prevent Brenger API failures, this field should be included in the validator.
| "email", "first_name", "last_name", "company_name", mode="before" | |
| "email", | |
| "first_name", | |
| "last_name", | |
| "company_name", | |
| "preferred_locale", | |
| mode="before", |
Copilot
AI
Feb 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The V2Stop model is missing the name length validation that exists in the V1 Contact model. The V1 Contact model includes check_name_length validation that pads names shorter than 3 characters with dots to meet Brenger API requirements. The V2Stop model should include similar validation for first_name and last_name fields to prevent API validation errors, unless the V2 API has different requirements.
| @field_validator("first_name", "last_name", mode="before") | |
| @classmethod | |
| def check_name_length(cls, value: Optional[str]) -> Optional[str]: | |
| """ | |
| Brenger API has validation for first name and last name to be min 3 characters. | |
| Validation makes sure that if the length of the name is less than 3, it is padded with dots. | |
| """ | |
| if value is None: | |
| return value | |
| if len(value) < 3: | |
| value += "." * (4 - len(value)) | |
| return value |
Copilot
AI
Feb 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The V2Item model is missing the strip_whitespace validation on the 'images' field. While 'images' is a list of strings, the individual strings in the list could contain trailing whitespace. For consistency with V1 models and to prevent API failures (as noted in the strip_whitespace function comment), consider adding validation to strip whitespace from individual image URLs.
| @field_validator("images", mode="before") | |
| @classmethod | |
| def strip_images(cls, value: Optional[List[str]]) -> Optional[List[str]]: | |
| if value is None: | |
| return value | |
| return [v.rstrip() if isinstance(v, str) else v for v in value] |
Copilot
AI
Feb 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The V2Amount model is missing strip_whitespace validation on string fields 'currency' and 'value'. Following the codebase convention that all string fields should have strip_whitespace validation to prevent Brenger API failures, these fields should have the validator applied.
| _strip_whitespace = field_validator("currency", "value", mode="before")( | |
| strip_whitespace | |
| ) |
Copilot
AI
Feb 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The V2QuoteRequest model is missing strip_whitespace validation on the 'external_reference' field. Following the codebase convention that all string fields should have strip_whitespace validation to prevent Brenger API failures, this field should have the validator applied.
| _strip_whitespace = field_validator("external_reference", mode="before")( | |
| strip_whitespace | |
| ) |
Copilot
AI
Feb 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The V2ShipmentCreateRequest model is missing strip_whitespace validation on string fields 'external_reference', 'external_listing_url', and 'external_private_url'. Following the codebase convention that all string fields should have strip_whitespace validation to prevent Brenger API failures, these fields should have the validator applied.
| _strip_whitespace = field_validator( | |
| "external_reference", | |
| "external_listing_url", | |
| "external_private_url", | |
| mode="before", | |
| )(strip_whitespace) |
Copilot
AI
Feb 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The V2Event and V2WebhookPayload models use string types for the 'timestamp' field. This is less type-safe and makes it harder to work with dates/times programmatically. Consider using datetime type with proper validators (similar to the shipping_date validation in ShipmentCreateRequest) to ensure timestamp parsing and validation, unless the API specifically requires string format in a non-standard way.
| 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" | ||||||
|
||||||
| version = "2.0.0" | |
| version = "0.2.0" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar to create_shipment, response.json() is called twice here - once for logging (line 82) and once for returning the parsed response. While requests.Response.json() typically caches the result, it's more efficient and clearer to store the parsed JSON in a variable and reuse it.