Add typed Pydantic models for positions and document payments, enhance EasybillClient with pagination and retry handling, and implement unit tests for workflow helpers.
This commit is contained in:
parent
b324671286
commit
57d6a49986
6 changed files with 375 additions and 5 deletions
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"forgejo.preferredRemote": "forgejo"
|
||||
}
|
||||
16
README.md
16
README.md
|
|
@ -9,6 +9,8 @@ The initial implementation is in place and includes:
|
|||
- a project scaffold with packaging and tests
|
||||
- sync and async wrapper clients
|
||||
- authentication helpers for bearer and basic auth
|
||||
- typed Pydantic facades for customers, documents, positions, and document payments
|
||||
- pagination helpers and basic retry handling for HTTP 429 rate limits
|
||||
- a webhook parser for JSON and form payloads
|
||||
- a reproducible generation script based on the provided Swagger specification
|
||||
|
||||
|
|
@ -26,6 +28,20 @@ Generate the raw clients from the API description:
|
|||
python scripts/generate_client.py --mode both
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
from easybill_client import EasybillClient
|
||||
|
||||
with EasybillClient(api_key="YOUR_API_TOKEN", max_retries=2, retry_backoff=1.0) as client:
|
||||
customer_page = client.list_customers(limit=50)
|
||||
position = client.get_position(5)
|
||||
payment = client.create_document_payment(document_id=10, amount=1999, reference="INV-10")
|
||||
|
||||
for customer in client.iter_all_customers(limit=100):
|
||||
print(customer.id, customer.company_name)
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
- `src/easybill_client`: public package and middleware-friendly helpers
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
from .auth import EasybillAuth, basic_auth_header, bearer_auth_header
|
||||
from .client import AsyncEasybillClient, EasybillClient
|
||||
from .models import Customer, Document, PagedResult, Pagination, WebhookEnvelope, WebhookSubscription
|
||||
from .models import Customer, Document, DocumentPayment, PagedResult, Pagination, Position, WebhookEnvelope, WebhookSubscription
|
||||
from .webhooks import EasybillWebhookParser
|
||||
|
||||
__all__ = [
|
||||
"AsyncEasybillClient",
|
||||
"Customer",
|
||||
"Document",
|
||||
"DocumentPayment",
|
||||
"EasybillAuth",
|
||||
"EasybillClient",
|
||||
"EasybillWebhookParser",
|
||||
"PagedResult",
|
||||
"Pagination",
|
||||
"Position",
|
||||
"WebhookEnvelope",
|
||||
"WebhookSubscription",
|
||||
"basic_auth_header",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .auth import EasybillAuth
|
||||
from .models import Customer, Document, PagedResult, WebhookSubscription
|
||||
from .models import Customer, Document, DocumentPayment, EasybillBaseModel, PagedResult, Position, WebhookSubscription
|
||||
|
||||
|
||||
class EasybillAPIError(RuntimeError):
|
||||
|
|
@ -26,12 +28,16 @@ class _BaseEasybillClient:
|
|||
api_prefix: str = "/rest/v1",
|
||||
timeout: float = 30.0,
|
||||
default_limit: int = 100,
|
||||
max_retries: int = 0,
|
||||
retry_backoff: float = 1.0,
|
||||
transport: httpx.BaseTransport | httpx.AsyncBaseTransport | None = None,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_prefix = api_prefix.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.default_limit = default_limit
|
||||
self.max_retries = max_retries
|
||||
self.retry_backoff = retry_backoff
|
||||
self.transport = transport
|
||||
self.auth = EasybillAuth(api_key, email=email, use_basic_auth=use_basic_auth)
|
||||
self.default_headers = {
|
||||
|
|
@ -68,7 +74,7 @@ class _BaseEasybillClient:
|
|||
return cleaned or None
|
||||
|
||||
@staticmethod
|
||||
def _parse_paged_result(data: dict[str, Any], item_model: type[Customer] | type[Document] | type[WebhookSubscription]) -> PagedResult:
|
||||
def _parse_paged_result(data: dict[str, Any], item_model: type[EasybillBaseModel]) -> PagedResult:
|
||||
items = [item_model.model_validate(item) for item in data.get("items", [])]
|
||||
return PagedResult(
|
||||
page=data.get("page", 1),
|
||||
|
|
@ -78,6 +84,15 @@ class _BaseEasybillClient:
|
|||
items=items,
|
||||
)
|
||||
|
||||
def _get_retry_delay(self, response: httpx.Response, attempt: int) -> float:
|
||||
retry_after = response.headers.get("Retry-After")
|
||||
if retry_after is not None:
|
||||
try:
|
||||
return max(0.0, float(retry_after))
|
||||
except ValueError:
|
||||
pass
|
||||
return max(0.0, self.retry_backoff * attempt)
|
||||
|
||||
|
||||
class EasybillClient(_BaseEasybillClient):
|
||||
def __init__(self, api_key: str, **kwargs: Any) -> None:
|
||||
|
|
@ -100,7 +115,17 @@ class EasybillClient(_BaseEasybillClient):
|
|||
self.close()
|
||||
|
||||
def request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
||||
response = self._client.request(method, self._build_path(path), **kwargs)
|
||||
response: httpx.Response | None = None
|
||||
for attempt in range(self.max_retries + 1):
|
||||
response = self._client.request(method, self._build_path(path), **kwargs)
|
||||
if response.status_code != 429 or attempt >= self.max_retries:
|
||||
return self._check_response(response)
|
||||
|
||||
delay = self._get_retry_delay(response, attempt + 1)
|
||||
if delay > 0:
|
||||
time.sleep(delay)
|
||||
|
||||
assert response is not None
|
||||
return self._check_response(response)
|
||||
|
||||
def get_json(self, path: str, **kwargs: Any) -> Any:
|
||||
|
|
@ -125,6 +150,74 @@ class EasybillClient(_BaseEasybillClient):
|
|||
data = self.get_json("/documents", params=params)
|
||||
return self._parse_paged_result(data, Document)
|
||||
|
||||
def get_position(self, position_id: int) -> Position:
|
||||
return Position.model_validate(self.get_json(f"/positions/{position_id}"))
|
||||
|
||||
def list_positions(self, *, page: int = 1, limit: int | None = None, **filters: Any) -> PagedResult:
|
||||
params = self._clean_params({"page": page, "limit": limit or self.default_limit, **filters})
|
||||
data = self.get_json("/positions", params=params)
|
||||
return self._parse_paged_result(data, Position)
|
||||
|
||||
def list_document_payments(self, *, page: int = 1, limit: int | None = None, **filters: Any) -> PagedResult:
|
||||
params = self._clean_params({"page": page, "limit": limit or self.default_limit, **filters})
|
||||
data = self.get_json("/document-payments", params=params)
|
||||
return self._parse_paged_result(data, DocumentPayment)
|
||||
|
||||
def create_document_payment(
|
||||
self,
|
||||
*,
|
||||
document_id: int,
|
||||
amount: int,
|
||||
reference: str | None = None,
|
||||
type: str = "CASH",
|
||||
payment_at: str | None = None,
|
||||
) -> DocumentPayment:
|
||||
payload = {
|
||||
"document_id": document_id,
|
||||
"amount": amount,
|
||||
"reference": reference,
|
||||
"type": type,
|
||||
"payment_at": payment_at,
|
||||
}
|
||||
data = self.post_json("/document-payments", json=self._clean_params(payload))
|
||||
return DocumentPayment.model_validate(data)
|
||||
|
||||
def iter_all_customers(self, *, limit: int | None = None, **filters: Any):
|
||||
page = 1
|
||||
while True:
|
||||
result = self.list_customers(page=page, limit=limit, **filters)
|
||||
yield from result.items
|
||||
if page >= result.pages:
|
||||
break
|
||||
page += 1
|
||||
|
||||
def iter_all_documents(self, *, limit: int | None = None, **filters: Any):
|
||||
page = 1
|
||||
while True:
|
||||
result = self.list_documents(page=page, limit=limit, **filters)
|
||||
yield from result.items
|
||||
if page >= result.pages:
|
||||
break
|
||||
page += 1
|
||||
|
||||
def iter_all_positions(self, *, limit: int | None = None, **filters: Any):
|
||||
page = 1
|
||||
while True:
|
||||
result = self.list_positions(page=page, limit=limit, **filters)
|
||||
yield from result.items
|
||||
if page >= result.pages:
|
||||
break
|
||||
page += 1
|
||||
|
||||
def iter_all_document_payments(self, *, limit: int | None = None, **filters: Any):
|
||||
page = 1
|
||||
while True:
|
||||
result = self.list_document_payments(page=page, limit=limit, **filters)
|
||||
yield from result.items
|
||||
if page >= result.pages:
|
||||
break
|
||||
page += 1
|
||||
|
||||
def list_webhooks(self, *, page: int = 1, limit: int | None = None) -> PagedResult:
|
||||
params = self._clean_params({"page": page, "limit": limit or self.default_limit})
|
||||
data = self.get_json("/webhooks", params=params)
|
||||
|
|
@ -173,7 +266,17 @@ class AsyncEasybillClient(_BaseEasybillClient):
|
|||
await self.aclose()
|
||||
|
||||
async def request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
||||
response = await self._client.request(method, self._build_path(path), **kwargs)
|
||||
response: httpx.Response | None = None
|
||||
for attempt in range(self.max_retries + 1):
|
||||
response = await self._client.request(method, self._build_path(path), **kwargs)
|
||||
if response.status_code != 429 or attempt >= self.max_retries:
|
||||
return self._check_response(response)
|
||||
|
||||
delay = self._get_retry_delay(response, attempt + 1)
|
||||
if delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
assert response is not None
|
||||
return self._check_response(response)
|
||||
|
||||
async def get_json(self, path: str, **kwargs: Any) -> Any:
|
||||
|
|
@ -200,6 +303,78 @@ class AsyncEasybillClient(_BaseEasybillClient):
|
|||
data = await self.get_json("/documents", params=params)
|
||||
return self._parse_paged_result(data, Document)
|
||||
|
||||
async def get_position(self, position_id: int) -> Position:
|
||||
return Position.model_validate(await self.get_json(f"/positions/{position_id}"))
|
||||
|
||||
async def list_positions(self, *, page: int = 1, limit: int | None = None, **filters: Any) -> PagedResult:
|
||||
params = self._clean_params({"page": page, "limit": limit or self.default_limit, **filters})
|
||||
data = await self.get_json("/positions", params=params)
|
||||
return self._parse_paged_result(data, Position)
|
||||
|
||||
async def list_document_payments(self, *, page: int = 1, limit: int | None = None, **filters: Any) -> PagedResult:
|
||||
params = self._clean_params({"page": page, "limit": limit or self.default_limit, **filters})
|
||||
data = await self.get_json("/document-payments", params=params)
|
||||
return self._parse_paged_result(data, DocumentPayment)
|
||||
|
||||
async def create_document_payment(
|
||||
self,
|
||||
*,
|
||||
document_id: int,
|
||||
amount: int,
|
||||
reference: str | None = None,
|
||||
type: str = "CASH",
|
||||
payment_at: str | None = None,
|
||||
) -> DocumentPayment:
|
||||
payload = {
|
||||
"document_id": document_id,
|
||||
"amount": amount,
|
||||
"reference": reference,
|
||||
"type": type,
|
||||
"payment_at": payment_at,
|
||||
}
|
||||
data = await self.post_json("/document-payments", json=self._clean_params(payload))
|
||||
return DocumentPayment.model_validate(data)
|
||||
|
||||
async def iter_all_customers(self, *, limit: int | None = None, **filters: Any):
|
||||
page = 1
|
||||
while True:
|
||||
result = await self.list_customers(page=page, limit=limit, **filters)
|
||||
for item in result.items:
|
||||
yield item
|
||||
if page >= result.pages:
|
||||
break
|
||||
page += 1
|
||||
|
||||
async def iter_all_documents(self, *, limit: int | None = None, **filters: Any):
|
||||
page = 1
|
||||
while True:
|
||||
result = await self.list_documents(page=page, limit=limit, **filters)
|
||||
for item in result.items:
|
||||
yield item
|
||||
if page >= result.pages:
|
||||
break
|
||||
page += 1
|
||||
|
||||
async def iter_all_positions(self, *, limit: int | None = None, **filters: Any):
|
||||
page = 1
|
||||
while True:
|
||||
result = await self.list_positions(page=page, limit=limit, **filters)
|
||||
for item in result.items:
|
||||
yield item
|
||||
if page >= result.pages:
|
||||
break
|
||||
page += 1
|
||||
|
||||
async def iter_all_document_payments(self, *, limit: int | None = None, **filters: Any):
|
||||
page = 1
|
||||
while True:
|
||||
result = await self.list_document_payments(page=page, limit=limit, **filters)
|
||||
for item in result.items:
|
||||
yield item
|
||||
if page >= result.pages:
|
||||
break
|
||||
page += 1
|
||||
|
||||
async def list_webhooks(self, *, page: int = 1, limit: int | None = None) -> PagedResult:
|
||||
params = self._clean_params({"page": page, "limit": limit or self.default_limit})
|
||||
data = await self.get_json("/webhooks", params=params)
|
||||
|
|
|
|||
|
|
@ -42,6 +42,25 @@ class Document(EasybillBaseModel):
|
|||
is_draft: bool | None = None
|
||||
|
||||
|
||||
class Position(EasybillBaseModel):
|
||||
id: int | None = None
|
||||
number: str | None = None
|
||||
type: str | None = None
|
||||
description: str | None = None
|
||||
price: int | float | None = None
|
||||
quantity: int | float | None = None
|
||||
unit: str | None = None
|
||||
|
||||
|
||||
class DocumentPayment(EasybillBaseModel):
|
||||
id: int | None = None
|
||||
document_id: int | None = None
|
||||
amount: int | None = None
|
||||
reference: str | None = None
|
||||
type: str | None = None
|
||||
payment_at: str | None = None
|
||||
|
||||
|
||||
class WebhookSubscription(EasybillBaseModel):
|
||||
id: int | None = None
|
||||
url: str
|
||||
|
|
|
|||
155
tests/test_workflow_helpers.py
Normal file
155
tests/test_workflow_helpers.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import httpx
|
||||
import pytest
|
||||
|
||||
from easybill_client import AsyncEasybillClient, EasybillClient
|
||||
|
||||
|
||||
class WorkflowTransport(httpx.MockTransport):
|
||||
def __init__(self):
|
||||
self.rate_limit_hits = 0
|
||||
super().__init__(self._handler)
|
||||
|
||||
def _handler(self, request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/positions/5":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": 5,
|
||||
"number": "ART-5",
|
||||
"type": "PRODUCT",
|
||||
"description": "Premium Support",
|
||||
"price": 4900,
|
||||
},
|
||||
)
|
||||
|
||||
if request.url.path == "/document-payments":
|
||||
if request.method == "POST":
|
||||
body = request.read().decode()
|
||||
assert "INV-1" in body
|
||||
return httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"id": 88,
|
||||
"document_id": 10,
|
||||
"amount": 1999,
|
||||
"reference": "INV-1",
|
||||
"type": "CASH",
|
||||
},
|
||||
)
|
||||
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"page": 1,
|
||||
"pages": 1,
|
||||
"limit": 100,
|
||||
"total": 1,
|
||||
"items": [
|
||||
{
|
||||
"id": 88,
|
||||
"document_id": 10,
|
||||
"amount": 1999,
|
||||
"reference": "INV-1",
|
||||
"type": "CASH",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
if request.url.path == "/customers":
|
||||
page = request.url.params.get("page", "1")
|
||||
if page == "1":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"page": 1,
|
||||
"pages": 2,
|
||||
"limit": 1,
|
||||
"total": 2,
|
||||
"items": [{"id": 1, "company_name": "ACME GmbH"}],
|
||||
},
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"page": 2,
|
||||
"pages": 2,
|
||||
"limit": 1,
|
||||
"total": 2,
|
||||
"items": [{"id": 2, "company_name": "Example AG"}],
|
||||
},
|
||||
)
|
||||
|
||||
if request.url.path == "/documents":
|
||||
if self.rate_limit_hits == 0:
|
||||
self.rate_limit_hits += 1
|
||||
return httpx.Response(429, headers={"Retry-After": "0"}, json={"error": "slow down"})
|
||||
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"page": 1,
|
||||
"pages": 1,
|
||||
"limit": 100,
|
||||
"total": 1,
|
||||
"items": [{"id": 10, "number": "RE-10", "amount": 1999}],
|
||||
},
|
||||
)
|
||||
|
||||
return httpx.Response(404, json={"error": "not found"})
|
||||
|
||||
|
||||
class AsyncWorkflowTransport(httpx.MockTransport):
|
||||
def __init__(self):
|
||||
super().__init__(self._handler)
|
||||
|
||||
@staticmethod
|
||||
def _handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/positions":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"page": 1,
|
||||
"pages": 1,
|
||||
"limit": 100,
|
||||
"total": 1,
|
||||
"items": [{"id": 7, "number": "ART-7", "description": "Hosting"}],
|
||||
},
|
||||
)
|
||||
return httpx.Response(404, json={"error": "not found"})
|
||||
|
||||
|
||||
def test_sync_workflow_helpers_cover_positions_payments_pagination_and_retry():
|
||||
transport = WorkflowTransport()
|
||||
client = EasybillClient(api_key="token", transport=transport, max_retries=1, retry_backoff=0)
|
||||
try:
|
||||
position = client.get_position(5)
|
||||
payments = client.list_document_payments(document_id=10)
|
||||
created_payment = client.create_document_payment(document_id=10, amount=1999, reference="INV-1")
|
||||
all_customers = list(client.iter_all_customers(limit=1))
|
||||
all_payments = list(client.iter_all_document_payments(document_id=10))
|
||||
documents = client.list_documents()
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
assert position.id == 5
|
||||
assert position.price == 4900
|
||||
assert payments.items[0].reference == "INV-1"
|
||||
assert created_payment.id == 88
|
||||
assert [customer.id for customer in all_customers] == [1, 2]
|
||||
assert [payment.id for payment in all_payments] == [88]
|
||||
assert documents.items[0].number == "RE-10"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_workflow_helpers_list_positions_as_models():
|
||||
client = AsyncEasybillClient(api_key="token", transport=AsyncWorkflowTransport())
|
||||
try:
|
||||
positions = await client.list_positions()
|
||||
iterated = [item async for item in client.iter_all_positions()]
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
assert positions.total == 1
|
||||
assert positions.items[0].number == "ART-7"
|
||||
assert iterated[0].id == 7
|
||||
Loading…
Add table
Add a link
Reference in a new issue