From f4030d405b72559f6535611480ce013b3b7ec8a3 Mon Sep 17 00:00:00 2001 From: David Grayston Date: Tue, 18 Mar 2025 23:20:31 +0000 Subject: [PATCH 1/2] httpx example --- .../Resources/Products/AsyncProductsClient.py | 69 ++++++++++ setup.py | 2 + .../Products/test_AsyncProductsClient.py | 129 ++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 paddle_billing/Resources/Products/AsyncProductsClient.py create mode 100644 tests/Functional/Resources/Products/test_AsyncProductsClient.py diff --git a/paddle_billing/Resources/Products/AsyncProductsClient.py b/paddle_billing/Resources/Products/AsyncProductsClient.py new file mode 100644 index 00000000..fff602a4 --- /dev/null +++ b/paddle_billing/Resources/Products/AsyncProductsClient.py @@ -0,0 +1,69 @@ +import httpx +import json + +from paddle_billing import Environment +from paddle_billing.Operation import Operation +from paddle_billing.Json.PayloadEncoder import PayloadEncoder + +from paddle_billing.Entities.Collections import Paginator, ProductCollection +from paddle_billing.Entities.Product import Product + +from paddle_billing.ResponseParser import ResponseParser + +from paddle_billing.Resources.Products.Operations import CreateProduct, ListProducts + + +class AsyncProductsClient: + def __init__(self, api_key: str, version: int = 1, env: Environment = Environment.PRODUCTION): + default_headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "Paddle-Version": str(version), + } + + self.client = httpx.AsyncClient(base_url=env.base_url, headers=default_headers) + + async def _make_request( + self, + method: str, + url: str, + params: dict | None = None, + payload: dict | Operation | None = None, + ): + try: + response = await self.client.request( + method=method, + url=url, + params=params, + content=json.dumps(payload, cls=PayloadEncoder) if payload is not None else None, + ) + response.raise_for_status() + + return response + except httpx.HTTPStatusError as e: + api_error = None + if e.response is not None: + response_parser = ResponseParser(e.response) + api_error = response_parser.get_error() + + if api_error is not None: + raise api_error + + raise + + async def list(self, operation: ListProducts = None) -> ProductCollection: + if operation is None: + operation = ListProducts() + + response = await self._make_request("GET", "/products", params=operation.get_parameters()) + parser = ResponseParser(response) + + return ProductCollection.from_list( + parser.get_data(), Paginator(self.client, parser.get_pagination(), ProductCollection) + ) + + async def create(self, operation: CreateProduct) -> Product: + response = await self._make_request("POST", "/products", payload=operation) + parser = ResponseParser(response) + + return Product.from_dict(parser.get_data()) diff --git a/setup.py b/setup.py index 4e6472a8..32ba7016 100644 --- a/setup.py +++ b/setup.py @@ -27,6 +27,8 @@ "dev": [ "pytest>=7.4.4,<8.4.0", "pytest-cov~=4.1.0", + "pytest-httpx", + "pytest-asyncio", "requests-mock~=1.11.0", "setuptools>=69.0.3", "pre-commit>=3.8.0", diff --git a/tests/Functional/Resources/Products/test_AsyncProductsClient.py b/tests/Functional/Resources/Products/test_AsyncProductsClient.py new file mode 100644 index 00000000..f8c1dccf --- /dev/null +++ b/tests/Functional/Resources/Products/test_AsyncProductsClient.py @@ -0,0 +1,129 @@ +import pytest +from json import loads, dumps +from os import getenv +from pytest import raises + +from paddle_billing.Exceptions.ApiError import ApiError +from paddle_billing.Entities.Shared import TaxCategory, CustomData +from paddle_billing.Resources.Shared.Operations import Pager + +from paddle_billing import Environment + +from paddle_billing.Resources.Products.Operations import CreateProduct, ListProducts +from paddle_billing.Resources.Products.AsyncProductsClient import AsyncProductsClient + +from tests.Utils.ReadsFixture import ReadsFixtures + + +class TestAsyncProductsClient: + @pytest.mark.asyncio + async def test_create_product( + self, + httpx_mock, + ): + base_url = Environment.SANDBOX.base_url + expected_request_body = ReadsFixtures.read_raw_json_fixture("request/create_full") + expected_response_body = ReadsFixtures.read_raw_json_fixture("response/minimal_entity") + expected_url = f"{base_url}/products" + httpx_mock.add_response(method="POST", url=expected_url, status_code=201, text=expected_response_body) + + client = AsyncProductsClient( + api_key=getenv("PADDLE_API_SECRET_KEY"), + version=1, + env=Environment.SANDBOX, + ) + + product = await client.create( + CreateProduct( + name="ChatApp Full", + tax_category=TaxCategory.Standard, + description="Spend more time engaging with students with ChataApp Education.", + image_url="https://paddle-sandbox.s3.amazonaws.com/user/10889/2nmP8MQSret0aWeDemRw_icon1.png", + custom_data=CustomData( + { + "features": { + "reports": True, + "crm": False, + "data_retention": True, + }, + } + ), + ), + ) + + assert product.id == "pro_01h7zcgmdc6tmwtjehp3sh7azf" + + last_request = httpx_mock.get_requests()[-1] + assert ( + last_request.url == expected_url + ), "The URL does not match the expected URL, verify the query string is correct" + assert loads(last_request.content) == loads( + expected_request_body + ), "The request JSON doesn't match the expected fixture JSON" + + @pytest.mark.asyncio + async def test_create_product_bad_request( + self, + httpx_mock, + ): + base_url = Environment.SANDBOX.base_url + expected_response_body = dumps( + { + "error": { + "type": "request_error", + "code": "bad_request", + "detail": "Invalid request", + "documentation_url": "https://developer.paddle.com/v1/errors/shared/bad_request", + "errors": [{"field": "some_field", "message": "Some error message"}], + }, + "meta": {"request_id": "f00bb3ca-399d-4686-889c-50b028f4c912"}, + } + ) + expected_url = f"{base_url}/products" + httpx_mock.add_response(method="POST", url=expected_url, status_code=400, text=expected_response_body) + + client = AsyncProductsClient( + api_key=getenv("PADDLE_API_SECRET_KEY"), + version=1, + env=Environment.SANDBOX, + ) + + with raises(ApiError) as exception_info: + await client.create( + CreateProduct( + name="ChatApp Full", + tax_category=TaxCategory.Standard, + ), + ) + + api_error = exception_info.value + + assert api_error.detail == "Invalid request" + assert api_error.error_type == "request_error" + assert api_error.error_code == "bad_request" + assert api_error.docs_url == "https://developer.paddle.com/v1/errors/shared/bad_request" + assert api_error.field_errors[0].field == "some_field" + assert api_error.field_errors[0].error == "Some error message" + + @pytest.mark.asyncio + async def test_list_products( + self, + httpx_mock, + ): + base_url = Environment.SANDBOX.base_url + expected_response_body = ReadsFixtures.read_raw_json_fixture("response/list_default") + expected_url = f"{base_url}/products?order_by=id[asc]&per_page=50" + + httpx_mock.add_response(method="GET", url=expected_url, status_code=200, text=expected_response_body) + + client = AsyncProductsClient( + api_key=getenv("PADDLE_API_SECRET_KEY"), + version=1, + env=Environment.SANDBOX, + ) + + products = await client.list(ListProducts(Pager())) + + product = products.items[0] + + assert product.id == "pro_01h1vjes1y163xfj1rh1tkfb65" From 4d4afa27cc6c3f06feec62d02b226a373d726cf9 Mon Sep 17 00:00:00 2001 From: David Grayston Date: Wed, 19 Mar 2025 02:22:44 +0000 Subject: [PATCH 2/2] httpx async client example --- paddle_billing/AsyncClient.py | 50 ++++++++++++++++++ .../Resources/Products/AsyncProductsClient.py | 52 +++---------------- .../Products/test_AsyncProductsClient.py | 14 ++--- 3 files changed, 65 insertions(+), 51 deletions(-) create mode 100644 paddle_billing/AsyncClient.py diff --git a/paddle_billing/AsyncClient.py b/paddle_billing/AsyncClient.py new file mode 100644 index 00000000..0c81dd82 --- /dev/null +++ b/paddle_billing/AsyncClient.py @@ -0,0 +1,50 @@ +import httpx +import json + +from paddle_billing import Environment +from paddle_billing.Operation import Operation +from paddle_billing.Json.PayloadEncoder import PayloadEncoder + +from paddle_billing.ResponseParser import ResponseParser + +from paddle_billing.Resources.Products.AsyncProductsClient import AsyncProductsClient + + +class AsyncClient: + def __init__(self, api_key: str, version: int = 1, env: Environment = Environment.PRODUCTION): + default_headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "Paddle-Version": str(version), + } + + self.client = httpx.AsyncClient(base_url=env.base_url, headers=default_headers) + self.products = AsyncProductsClient(self) + + async def make_request( + self, + method: str, + url: str, + params: dict | None = None, + payload: dict | Operation | None = None, + ): + try: + response = await self.client.request( + method=method, + url=url, + params=params, + content=json.dumps(payload, cls=PayloadEncoder) if payload is not None else None, + ) + response.raise_for_status() + + return response + except httpx.HTTPStatusError as e: + api_error = None + if e.response is not None: + response_parser = ResponseParser(e.response) + api_error = response_parser.get_error() + + if api_error is not None: + raise api_error + + raise diff --git a/paddle_billing/Resources/Products/AsyncProductsClient.py b/paddle_billing/Resources/Products/AsyncProductsClient.py index fff602a4..5cd1f975 100644 --- a/paddle_billing/Resources/Products/AsyncProductsClient.py +++ b/paddle_billing/Resources/Products/AsyncProductsClient.py @@ -1,10 +1,3 @@ -import httpx -import json - -from paddle_billing import Environment -from paddle_billing.Operation import Operation -from paddle_billing.Json.PayloadEncoder import PayloadEncoder - from paddle_billing.Entities.Collections import Paginator, ProductCollection from paddle_billing.Entities.Product import Product @@ -12,50 +5,21 @@ from paddle_billing.Resources.Products.Operations import CreateProduct, ListProducts +from typing import TYPE_CHECKING -class AsyncProductsClient: - def __init__(self, api_key: str, version: int = 1, env: Environment = Environment.PRODUCTION): - default_headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - "Paddle-Version": str(version), - } - - self.client = httpx.AsyncClient(base_url=env.base_url, headers=default_headers) +if TYPE_CHECKING: + from paddle_billing.AsyncClient import AsyncClient - async def _make_request( - self, - method: str, - url: str, - params: dict | None = None, - payload: dict | Operation | None = None, - ): - try: - response = await self.client.request( - method=method, - url=url, - params=params, - content=json.dumps(payload, cls=PayloadEncoder) if payload is not None else None, - ) - response.raise_for_status() - return response - except httpx.HTTPStatusError as e: - api_error = None - if e.response is not None: - response_parser = ResponseParser(e.response) - api_error = response_parser.get_error() - - if api_error is not None: - raise api_error - - raise +class AsyncProductsClient: + def __init__(self, client: "AsyncClient"): + self.client = client async def list(self, operation: ListProducts = None) -> ProductCollection: if operation is None: operation = ListProducts() - response = await self._make_request("GET", "/products", params=operation.get_parameters()) + response = await self.client.make_request("GET", "/products", params=operation.get_parameters()) parser = ResponseParser(response) return ProductCollection.from_list( @@ -63,7 +27,7 @@ async def list(self, operation: ListProducts = None) -> ProductCollection: ) async def create(self, operation: CreateProduct) -> Product: - response = await self._make_request("POST", "/products", payload=operation) + response = await self.client.make_request("POST", "/products", payload=operation) parser = ResponseParser(response) return Product.from_dict(parser.get_data()) diff --git a/tests/Functional/Resources/Products/test_AsyncProductsClient.py b/tests/Functional/Resources/Products/test_AsyncProductsClient.py index f8c1dccf..4507c86b 100644 --- a/tests/Functional/Resources/Products/test_AsyncProductsClient.py +++ b/tests/Functional/Resources/Products/test_AsyncProductsClient.py @@ -10,7 +10,7 @@ from paddle_billing import Environment from paddle_billing.Resources.Products.Operations import CreateProduct, ListProducts -from paddle_billing.Resources.Products.AsyncProductsClient import AsyncProductsClient +from paddle_billing.AsyncClient import AsyncClient from tests.Utils.ReadsFixture import ReadsFixtures @@ -27,13 +27,13 @@ async def test_create_product( expected_url = f"{base_url}/products" httpx_mock.add_response(method="POST", url=expected_url, status_code=201, text=expected_response_body) - client = AsyncProductsClient( + client = AsyncClient( api_key=getenv("PADDLE_API_SECRET_KEY"), version=1, env=Environment.SANDBOX, ) - product = await client.create( + product = await client.products.create( CreateProduct( name="ChatApp Full", tax_category=TaxCategory.Standard, @@ -82,14 +82,14 @@ async def test_create_product_bad_request( expected_url = f"{base_url}/products" httpx_mock.add_response(method="POST", url=expected_url, status_code=400, text=expected_response_body) - client = AsyncProductsClient( + client = AsyncClient( api_key=getenv("PADDLE_API_SECRET_KEY"), version=1, env=Environment.SANDBOX, ) with raises(ApiError) as exception_info: - await client.create( + await client.products.create( CreateProduct( name="ChatApp Full", tax_category=TaxCategory.Standard, @@ -116,13 +116,13 @@ async def test_list_products( httpx_mock.add_response(method="GET", url=expected_url, status_code=200, text=expected_response_body) - client = AsyncProductsClient( + client = AsyncClient( api_key=getenv("PADDLE_API_SECRET_KEY"), version=1, env=Environment.SANDBOX, ) - products = await client.list(ListProducts(Pager())) + products = await client.products.list(ListProducts(Pager())) product = products.items[0]