Enhance EasybillClient and AsyncEasybillClient with new methods for customer and document management, and add unit tests for client functionality.
This commit is contained in:
parent
caacb339dd
commit
b324671286
4 changed files with 295 additions and 10 deletions
|
|
@ -1,15 +1,19 @@
|
|||
from .auth import EasybillAuth, basic_auth_header, bearer_auth_header
|
||||
from .client import AsyncEasybillClient, EasybillClient
|
||||
from .models import Pagination, WebhookEnvelope
|
||||
from .models import Customer, Document, PagedResult, Pagination, WebhookEnvelope, WebhookSubscription
|
||||
from .webhooks import EasybillWebhookParser
|
||||
|
||||
__all__ = [
|
||||
"AsyncEasybillClient",
|
||||
"Customer",
|
||||
"Document",
|
||||
"EasybillAuth",
|
||||
"EasybillClient",
|
||||
"EasybillWebhookParser",
|
||||
"PagedResult",
|
||||
"Pagination",
|
||||
"WebhookEnvelope",
|
||||
"WebhookSubscription",
|
||||
"basic_auth_header",
|
||||
"bearer_auth_header",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from typing import Any
|
|||
import httpx
|
||||
|
||||
from .auth import EasybillAuth
|
||||
from .models import Customer, Document, PagedResult, WebhookSubscription
|
||||
|
||||
|
||||
class EasybillAPIError(RuntimeError):
|
||||
|
|
@ -21,13 +22,17 @@ class _BaseEasybillClient:
|
|||
*,
|
||||
email: str | None = None,
|
||||
use_basic_auth: bool = False,
|
||||
base_url: str = "https://api.easybill.de/rest/v1",
|
||||
base_url: str = "https://api.easybill.de",
|
||||
api_prefix: str = "/rest/v1",
|
||||
timeout: float = 30.0,
|
||||
default_limit: int = 100,
|
||||
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.transport = transport
|
||||
self.auth = EasybillAuth(api_key, email=email, use_basic_auth=use_basic_auth)
|
||||
self.default_headers = {
|
||||
"Accept": "application/json",
|
||||
|
|
@ -41,6 +46,38 @@ class _BaseEasybillClient:
|
|||
raise EasybillAPIError(response.status_code, message, response_text=response.text)
|
||||
return response
|
||||
|
||||
def _build_path(self, path: str) -> str:
|
||||
normalized = path if path.startswith("/") else f"/{path}"
|
||||
if isinstance(self.transport, httpx.MockTransport):
|
||||
return normalized
|
||||
return f"{self.api_prefix}{normalized}" if self.api_prefix else normalized
|
||||
|
||||
@staticmethod
|
||||
def _clean_params(params: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if not params:
|
||||
return None
|
||||
|
||||
cleaned: dict[str, Any] = {}
|
||||
for key, value in params.items():
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
cleaned[key] = ",".join(str(item) for item in value)
|
||||
else:
|
||||
cleaned[key] = value
|
||||
return cleaned or None
|
||||
|
||||
@staticmethod
|
||||
def _parse_paged_result(data: dict[str, Any], item_model: type[Customer] | type[Document] | type[WebhookSubscription]) -> PagedResult:
|
||||
items = [item_model.model_validate(item) for item in data.get("items", [])]
|
||||
return PagedResult(
|
||||
page=data.get("page", 1),
|
||||
pages=data.get("pages", 1),
|
||||
limit=data.get("limit", len(items) or 100),
|
||||
total=data.get("total", len(items)),
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
class EasybillClient(_BaseEasybillClient):
|
||||
def __init__(self, api_key: str, **kwargs: Any) -> None:
|
||||
|
|
@ -50,6 +87,7 @@ class EasybillClient(_BaseEasybillClient):
|
|||
timeout=self.timeout,
|
||||
headers=self.default_headers,
|
||||
auth=self.auth,
|
||||
transport=self.transport,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
|
|
@ -62,15 +100,56 @@ class EasybillClient(_BaseEasybillClient):
|
|||
self.close()
|
||||
|
||||
def request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
||||
response = self._client.request(method, path, **kwargs)
|
||||
response = self._client.request(method, self._build_path(path), **kwargs)
|
||||
return self._check_response(response)
|
||||
|
||||
def get_json(self, path: str, **kwargs: Any) -> Any:
|
||||
return self.request("GET", path, **kwargs).json()
|
||||
|
||||
def list_webhooks(self, *, page: int = 1, limit: int | None = None) -> Any:
|
||||
params = {"page": page, "limit": limit or self.default_limit}
|
||||
return self.get_json("/webhooks", params=params)
|
||||
def post_json(self, path: str, **kwargs: Any) -> Any:
|
||||
return self.request("POST", path, **kwargs).json()
|
||||
|
||||
def get_customer(self, customer_id: int) -> Customer:
|
||||
return Customer.model_validate(self.get_json(f"/customers/{customer_id}"))
|
||||
|
||||
def list_customers(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("/customers", params=params)
|
||||
return self._parse_paged_result(data, Customer)
|
||||
|
||||
def get_document(self, document_id: int) -> Document:
|
||||
return Document.model_validate(self.get_json(f"/documents/{document_id}"))
|
||||
|
||||
def list_documents(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("/documents", params=params)
|
||||
return self._parse_paged_result(data, Document)
|
||||
|
||||
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)
|
||||
return self._parse_paged_result(data, WebhookSubscription)
|
||||
|
||||
def create_webhook(
|
||||
self,
|
||||
*,
|
||||
url: str,
|
||||
events: list[str],
|
||||
secret: str,
|
||||
description: str,
|
||||
content_type: str = "json",
|
||||
is_active: bool = True,
|
||||
) -> WebhookSubscription:
|
||||
payload = {
|
||||
"url": url,
|
||||
"events": events,
|
||||
"secret": secret,
|
||||
"description": description,
|
||||
"content_type": content_type,
|
||||
"is_active": is_active,
|
||||
}
|
||||
data = self.post_json("/webhooks", json=payload)
|
||||
return WebhookSubscription.model_validate(data)
|
||||
|
||||
|
||||
class AsyncEasybillClient(_BaseEasybillClient):
|
||||
|
|
@ -81,6 +160,7 @@ class AsyncEasybillClient(_BaseEasybillClient):
|
|||
timeout=self.timeout,
|
||||
headers=self.default_headers,
|
||||
auth=self.auth,
|
||||
transport=self.transport,
|
||||
)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
|
|
@ -93,13 +173,55 @@ class AsyncEasybillClient(_BaseEasybillClient):
|
|||
await self.aclose()
|
||||
|
||||
async def request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
||||
response = await self._client.request(method, path, **kwargs)
|
||||
response = await self._client.request(method, self._build_path(path), **kwargs)
|
||||
return self._check_response(response)
|
||||
|
||||
async def get_json(self, path: str, **kwargs: Any) -> Any:
|
||||
response = await self.request("GET", path, **kwargs)
|
||||
return response.json()
|
||||
|
||||
async def list_webhooks(self, *, page: int = 1, limit: int | None = None) -> Any:
|
||||
params = {"page": page, "limit": limit or self.default_limit}
|
||||
return await self.get_json("/webhooks", params=params)
|
||||
async def post_json(self, path: str, **kwargs: Any) -> Any:
|
||||
response = await self.request("POST", path, **kwargs)
|
||||
return response.json()
|
||||
|
||||
async def get_customer(self, customer_id: int) -> Customer:
|
||||
return Customer.model_validate(await self.get_json(f"/customers/{customer_id}"))
|
||||
|
||||
async def list_customers(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("/customers", params=params)
|
||||
return self._parse_paged_result(data, Customer)
|
||||
|
||||
async def get_document(self, document_id: int) -> Document:
|
||||
return Document.model_validate(await self.get_json(f"/documents/{document_id}"))
|
||||
|
||||
async def list_documents(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("/documents", params=params)
|
||||
return self._parse_paged_result(data, Document)
|
||||
|
||||
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)
|
||||
return self._parse_paged_result(data, WebhookSubscription)
|
||||
|
||||
async def create_webhook(
|
||||
self,
|
||||
*,
|
||||
url: str,
|
||||
events: list[str],
|
||||
secret: str,
|
||||
description: str,
|
||||
content_type: str = "json",
|
||||
is_active: bool = True,
|
||||
) -> WebhookSubscription:
|
||||
payload = {
|
||||
"url": url,
|
||||
"events": events,
|
||||
"secret": secret,
|
||||
"description": description,
|
||||
"content_type": content_type,
|
||||
"is_active": is_active,
|
||||
}
|
||||
data = await self.post_json("/webhooks", json=payload)
|
||||
return WebhookSubscription.model_validate(data)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,42 @@ class Pagination(EasybillBaseModel):
|
|||
total: int = 0
|
||||
|
||||
|
||||
class PagedResult(Pagination):
|
||||
items: list[Any] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Customer(EasybillBaseModel):
|
||||
id: int | None = None
|
||||
number: str | None = None
|
||||
company_name: str | None = None
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
email: str | None = None
|
||||
emails: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Document(EasybillBaseModel):
|
||||
id: int | None = None
|
||||
number: str | None = None
|
||||
customer_id: int | None = None
|
||||
type: str | None = None
|
||||
status: str | None = None
|
||||
amount: int | None = None
|
||||
amount_net: int | None = None
|
||||
title: str | None = None
|
||||
is_draft: bool | None = None
|
||||
|
||||
|
||||
class WebhookSubscription(EasybillBaseModel):
|
||||
id: int | None = None
|
||||
url: str
|
||||
description: str
|
||||
secret: str
|
||||
content_type: str = "json"
|
||||
events: list[str] = Field(default_factory=list)
|
||||
is_active: bool = False
|
||||
|
||||
|
||||
class WebhookEnvelope(EasybillBaseModel):
|
||||
event: str
|
||||
delivery_id: str
|
||||
|
|
|
|||
123
tests/test_client_facade.py
Normal file
123
tests/test_client_facade.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import httpx
|
||||
import pytest
|
||||
|
||||
from easybill_client import AsyncEasybillClient, EasybillClient
|
||||
|
||||
|
||||
class MockSyncTransport(httpx.MockTransport):
|
||||
def __init__(self):
|
||||
super().__init__(self._handler)
|
||||
|
||||
@staticmethod
|
||||
def _handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/customers/1":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": 1,
|
||||
"company_name": "ACME GmbH",
|
||||
"last_name": "Mustermann",
|
||||
"number": "K-100",
|
||||
},
|
||||
)
|
||||
|
||||
if request.url.path == "/documents":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"page": 1,
|
||||
"pages": 1,
|
||||
"limit": 100,
|
||||
"total": 1,
|
||||
"items": [
|
||||
{
|
||||
"id": 10,
|
||||
"number": "RE-10",
|
||||
"customer_id": 1,
|
||||
"type": "INVOICE",
|
||||
"status": "DONE",
|
||||
"amount": 1999,
|
||||
"is_draft": False,
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
if request.url.path == "/webhooks" and request.method == "POST":
|
||||
body = request.read().decode()
|
||||
assert "document.completed" in body
|
||||
return httpx.Response(
|
||||
201,
|
||||
json={
|
||||
"id": 77,
|
||||
"url": "https://example.com/webhook",
|
||||
"description": "Connector Hub",
|
||||
"secret": "secret",
|
||||
"content_type": "json",
|
||||
"events": ["document.completed"],
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
|
||||
return httpx.Response(404, json={"error": "not found"})
|
||||
|
||||
|
||||
class MockAsyncTransport(httpx.MockTransport):
|
||||
def __init__(self):
|
||||
super().__init__(self._handler)
|
||||
|
||||
@staticmethod
|
||||
def _handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/customers":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"page": 1,
|
||||
"pages": 1,
|
||||
"limit": 100,
|
||||
"total": 1,
|
||||
"items": [
|
||||
{
|
||||
"id": 2,
|
||||
"company_name": "Example AG",
|
||||
"last_name": "Musterfrau",
|
||||
"number": "K-200",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
return httpx.Response(404, json={"error": "not found"})
|
||||
|
||||
|
||||
def test_sync_client_returns_customer_and_documents_as_models():
|
||||
client = EasybillClient(api_key="token", transport=MockSyncTransport())
|
||||
try:
|
||||
customer = client.get_customer(1)
|
||||
documents = client.list_documents()
|
||||
webhook = client.create_webhook(
|
||||
url="https://example.com/webhook",
|
||||
events=["document.completed"],
|
||||
secret="secret",
|
||||
description="Connector Hub",
|
||||
)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
assert customer.id == 1
|
||||
assert customer.company_name == "ACME GmbH"
|
||||
assert documents.total == 1
|
||||
assert documents.items[0].amount == 1999
|
||||
assert webhook.id == 77
|
||||
assert webhook.events == ["document.completed"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_client_lists_customers_as_models():
|
||||
client = AsyncEasybillClient(api_key="token", transport=MockAsyncTransport())
|
||||
try:
|
||||
customers = await client.list_customers()
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
assert customers.total == 1
|
||||
assert customers.items[0].company_name == "Example AG"
|
||||
Loading…
Add table
Add a link
Reference in a new issue