diff --git a/README.md b/README.md index e1dac75..bf0c328 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ - **Declarative**: Define API methods using standard Python type hints. - **Type-Safe**: Full support for static type checking. -- **Backend Agnostic**: Works with `httpx`, `aiohttp`, `requests`, `niquests` and `zapros`. +- **Backend Agnostic**: Works with `httpx`, `httpx2`, `aiohttp`, `requests`, `niquests` and `zapros`. - **Extensible**: Powerful middleware and error handling systems. ## Installation @@ -48,6 +48,8 @@ To include a specific HTTP backend (recommended): ```bash pip install "unihttp[httpx]" # For HTTPX (Sync/Async) support # OR +pip install "unihttp[httpx2]" # For HTTPX2 (Sync/Async) support +# OR pip install "unihttp[niquests]" # For niquests (Sync/Async) support # OR pip install "unihttp[requests]" # For Requests (Sync) support diff --git a/pyproject.toml b/pyproject.toml index 869df34..03b2d12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ Issues = "https://github.com/goduni/unihttp/issues" [project.optional-dependencies] httpx = ["httpx>=0.28.1"] +httpx2 = ["httpx2>=2.0.0"] requests = ["requests>=2.32.0"] aiohttp = ["aiohttp>=3.10.0"] niquests = ["niquests>=3.17.0"] @@ -53,6 +54,7 @@ msgspec = ["msgspec>=0.18.0"] [dependency-groups] optionals = [ "unihttp[httpx]", + "unihttp[httpx2]", "unihttp[requests]", "unihttp[aiohttp]", "unihttp[niquests]", diff --git a/src/unihttp/clients/httpx2.py b/src/unihttp/clients/httpx2.py new file mode 100644 index 0000000..fb880dc --- /dev/null +++ b/src/unihttp/clients/httpx2.py @@ -0,0 +1,196 @@ +import json +from collections.abc import Callable +from typing import Any +from urllib.parse import urljoin + +import httpx2 +from httpx2 import AsyncClient, Client + +from unihttp.clients.base import BaseAsyncClient, BaseSyncClient +from unihttp.exceptions import NetworkError, RequestTimeoutError +from unihttp.http import UploadFile +from unihttp.http.request import HTTPRequest +from unihttp.http.response import HTTPResponse +from unihttp.middlewares.base import AsyncMiddleware, Middleware +from unihttp.serialize import RequestDumper, ResponseLoader + + +class HTTPX2SyncClient(BaseSyncClient): + """Synchronous client implementation using the `httpx2` library.""" + + def __init__( + self, + base_url: str, + request_dumper: RequestDumper, + response_loader: ResponseLoader, + middleware: list[Middleware] | None = None, + session: Client | None = None, + json_dumps: Callable[[Any], str] = json.dumps, + json_loads: Callable[[str | bytes | bytearray], Any] = json.loads, + ): + super().__init__( + base_url=base_url, + request_dumper=request_dumper, + response_loader=response_loader, + middleware=middleware, + json_dumps=json_dumps, + json_loads=json_loads, + ) + + if session is None: + session = Client() + + self._session = session + + def _convert_files(self, files: dict[str, Any]) -> list[tuple[str, Any]]: + """Convert files to a list of tuples for httpx2.""" + file_list = [] + for key, value in files.items(): + if isinstance(value, list): + for item in value: + if isinstance(item, UploadFile): + file_list.append((key, item.to_tuple())) + else: + file_list.append((key, item)) + elif isinstance(value, UploadFile): + file_list.append((key, value.to_tuple())) + else: + file_list.append((key, value)) + return file_list + + def make_request(self, request: HTTPRequest) -> HTTPResponse: + content = None + + if request.body: + if request.form or request.file: + raise ValueError( + "Cannot use Body with Form or File. " + "Use Form for fields in multipart requests." + ) + content = self.json_dumps(request.body) + if "Content-Type" not in request.header: + request.header["Content-Type"] = "application/json" + + try: + files = self._convert_files(request.file) if request.file else None + response = self._session.request( + method=request.method, + url=urljoin(self.base_url, request.url), + headers=request.header, + params=request.query, + files=files, + content=content, + data=request.form, + ) + except httpx2.NetworkError as e: + raise NetworkError(str(e)) from e + except httpx2.TimeoutException as e: + raise RequestTimeoutError(str(e)) from e + + response_data: Any = None + if response.content: + try: + response_data = self.json_loads(response.content) + except (ValueError, TypeError): + response_data = response.content + + return HTTPResponse( + status_code=response.status_code, + headers=response.headers, + cookies=response.cookies, + data=response_data, + raw_response=response, + ) + + def close(self) -> None: + self._session.close() + + +class HTTPX2AsyncClient(BaseAsyncClient): + """Asynchronous client implementation using the `httpx2` library.""" + + def __init__( + self, + base_url: str, + request_dumper: RequestDumper, + response_loader: ResponseLoader, + middleware: list[AsyncMiddleware] | None = None, + session: AsyncClient | None = None, + json_dumps: Callable[[Any], str] = json.dumps, + json_loads: Callable[[str | bytes | bytearray], Any] = json.loads, + ): + super().__init__( + base_url=base_url, + request_dumper=request_dumper, + response_loader=response_loader, + middleware=middleware, + json_dumps=json_dumps, + json_loads=json_loads, + ) + + if session is None: + session = AsyncClient() + + self._session = session + + def _convert_files(self, files: dict[str, Any]) -> list[tuple[str, Any]]: + """Convert files to a list of tuples for httpx2.""" + file_list = [] + for key, value in files.items(): + if isinstance(value, list): + for item in value: + if isinstance(item, UploadFile): + file_list.append((key, item.to_tuple())) + else: + file_list.append((key, item)) + elif isinstance(value, UploadFile): + file_list.append((key, value.to_tuple())) + else: + file_list.append((key, value)) + return file_list + + async def make_request(self, request: HTTPRequest) -> HTTPResponse: + content = None + if request.body: + if request.form or request.file: + raise ValueError( + "Cannot use Body with Form or File. " + "Use Form for fields in multipart requests." + ) + content = self.json_dumps(request.body) + if "Content-Type" not in request.header: + request.header["Content-Type"] = "application/json" + + try: + files = self._convert_files(request.file) if request.file else None + response = await self._session.request( + method=request.method, + url=urljoin(self.base_url, request.url), + headers=request.header, + params=request.query, + files=files, + content=content, + data=request.form, + ) + except httpx2.NetworkError as e: + raise NetworkError(str(e)) from e + except httpx2.TimeoutException as e: + raise RequestTimeoutError(str(e)) from e + + response_data: Any = None + if response.content: + try: + response_data = self.json_loads(response.content) + except (ValueError, TypeError): + response_data = response.content + + return HTTPResponse( + status_code=response.status_code, + headers=response.headers, + cookies=response.cookies, + data=response_data, + raw_response=response, + ) + + async def close(self) -> None: + await self._session.aclose() diff --git a/tests/test_clients/test_defaults.py b/tests/test_clients/test_defaults.py index 27e32b7..031a157 100644 --- a/tests/test_clients/test_defaults.py +++ b/tests/test_clients/test_defaults.py @@ -1,6 +1,7 @@ import pytest from unihttp.clients.aiohttp import AiohttpAsyncClient from unihttp.clients.httpx import HTTPXAsyncClient, HTTPXSyncClient +from unihttp.clients.httpx2 import HTTPX2AsyncClient, HTTPX2SyncClient from unihttp.clients.requests import RequestsSyncClient @@ -24,6 +25,19 @@ def test_httpx_sync_default_session(mock_request_dumper, mock_response_loader): assert client._session is not None +@pytest.mark.asyncio +async def test_httpx2_async_default_session(mock_request_dumper, mock_response_loader): + client = HTTPX2AsyncClient("http://base", mock_request_dumper, mock_response_loader) + await client.close() + assert client._session is not None + + +def test_httpx2_sync_default_session(mock_request_dumper, mock_response_loader): + client = HTTPX2SyncClient("http://base", mock_request_dumper, mock_response_loader) + client.close() + assert client._session is not None + + def test_requests_default_session(mock_request_dumper, mock_response_loader): client = RequestsSyncClient("http://base", mock_request_dumper, mock_response_loader) client.close() diff --git a/tests/test_clients/test_httpx2.py b/tests/test_clients/test_httpx2.py new file mode 100644 index 0000000..1818db5 --- /dev/null +++ b/tests/test_clients/test_httpx2.py @@ -0,0 +1,212 @@ +from unittest.mock import AsyncMock, Mock + +import httpx2 +import pytest +from unihttp.clients.httpx2 import HTTPX2AsyncClient +from unihttp.exceptions import NetworkError, RequestTimeoutError +from unihttp.http.request import HTTPRequest + + +@pytest.fixture +def mock_client(): + return AsyncMock(spec=httpx2.AsyncClient) + + +@pytest.mark.asyncio +async def test_httpx2_make_request(mock_request_dumper, mock_response_loader, mock_client): + client = HTTPX2AsyncClient( + base_url="http://base", + request_dumper=mock_request_dumper, + response_loader=mock_response_loader, + session=mock_client + ) + + # Mock response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {"Content-Type": "application/json"} + mock_response.cookies = {} + mock_response.json.return_value = {"key": "value"} + mock_response.content = b'{"key": "value"}' + mock_response.text = '{"key": "value"}' + + mock_client.request.return_value = mock_response + + request = HTTPRequest( + url="/test", + method="POST", + header={"Auth": "123"}, + path={}, + query={"q": "1"}, + body={"data": "abc"}, + file={}, + form={} + ) + + response = await client.make_request(request) + + # Verify call arguments + mock_client.request.assert_called_once_with( + method="POST", + url="http://base/test", + headers={"Auth": "123", "Content-Type": "application/json"}, + params={"q": "1"}, + data={}, + files=None, + content='{"data": "abc"}' + ) + + # Verify response mapping + assert response.status_code == 200 + assert response.data == {"key": "value"} + + assert response.data == {"key": "value"} + + +@pytest.mark.asyncio +async def test_httpx2_upload_file(mock_request_dumper, mock_response_loader, mock_client): + from unihttp.http import UploadFile + + client = HTTPX2AsyncClient( + base_url="http://base", + request_dumper=mock_request_dumper, + response_loader=mock_response_loader, + session=mock_client + ) + + # Mock response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = b'{"status": "ok"}' + mock_response.text = '{"status": "ok"}' + mock_client.request.return_value = mock_response + + request = HTTPRequest( + url="/upload", + method="POST", + header={}, + path={}, + query={}, + body=None, + file={"doc": UploadFile(b"content", filename="test.txt")}, + form={} + ) + + await client.make_request(request) + + mock_client.request.assert_called_once_with( + method="POST", + url="http://base/upload", + headers={}, + params={}, + data={}, + files=[("doc", ("test.txt", b"content", "application/octet-stream"))], + content=None + ) + + +@pytest.mark.asyncio +async def test_httpx2_close(mock_request_dumper, mock_response_loader, mock_client): + client = HTTPX2AsyncClient( + base_url="http://base", + request_dumper=mock_request_dumper, + response_loader=mock_response_loader, + session=mock_client + ) + await client.close() + mock_client.aclose.assert_called_once() + + +@pytest.mark.asyncio +async def test_httpx2_network_error(mock_request_dumper, mock_response_loader, mock_client): + client = HTTPX2AsyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_client) + mock_client.request.side_effect = httpx2.NetworkError("Network error") + + request = HTTPRequest("GET", "url", {}, {}, {}, {}, {}, {}) + + with pytest.raises(NetworkError): + await client.make_request(request) + + +@pytest.mark.asyncio +async def test_httpx2_timeout_error(mock_request_dumper, mock_response_loader, mock_client): + client = HTTPX2AsyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_client) + mock_client.request.side_effect = httpx2.TimeoutException("Timed out") + + request = HTTPRequest("url", "GET", {}, {}, {}, {}, {}, {}) + + with pytest.raises(RequestTimeoutError): + await client.make_request(request) + + +@pytest.mark.asyncio +async def test_httpx2_body_and_form_error(mock_request_dumper, mock_response_loader, mock_client): + client = HTTPX2AsyncClient( + base_url="http://base", + request_dumper=mock_request_dumper, + response_loader=mock_response_loader, + session=mock_client + ) + + request = HTTPRequest( + url="/test", + method="POST", + header={}, + path={}, + query={}, + body={"some": "body"}, + file=None, + form={"some": "form"} + ) + + with pytest.raises(ValueError, match="Cannot use Body with Form or File"): + await client.make_request(request) + + +@pytest.mark.asyncio +async def test_httpx2_file_list_conversion(mock_request_dumper, mock_response_loader, mock_client): + from unihttp.http import UploadFile + + client = HTTPX2AsyncClient( + base_url="http://base", + request_dumper=mock_request_dumper, + response_loader=mock_response_loader, + session=mock_client + ) + + # Mock response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = b'{}' + mock_response.text = '{}' + mock_client.request.return_value = mock_response + + request = HTTPRequest( + url="/upload", + method="POST", + header={}, + path={}, + query={}, + body=None, + file={ + "files": [ + UploadFile(b"content1", filename="f1.txt"), + ("f2.txt", b"content2") + ], + "single_upload_file": UploadFile(b"content3", filename="f3.txt"), + "single_tuple": ("f4.txt", b"content4") + }, + form={} + ) + + await client.make_request(request) + + mock_client.request.assert_called_once() + call_kwargs = mock_client.request.call_args[1] + files = call_kwargs["files"] + + # Verify order and content + assert files[0] == ("files", ("f1.txt", b"content1", "application/octet-stream")) + assert files[1] == ("files", ("f2.txt", b"content2")) + assert files[2] == ("single_upload_file", ("f3.txt", b"content3", "application/octet-stream")) + assert files[3] == ("single_tuple", ("f4.txt", b"content4")) diff --git a/tests/test_clients/test_httpx2_sync.py b/tests/test_clients/test_httpx2_sync.py new file mode 100644 index 0000000..6357dbd --- /dev/null +++ b/tests/test_clients/test_httpx2_sync.py @@ -0,0 +1,177 @@ +from unittest.mock import MagicMock, Mock + +import httpx2 +import pytest +from unihttp.clients.httpx2 import HTTPX2SyncClient +from unihttp.exceptions import NetworkError, RequestTimeoutError +from unihttp.http.request import HTTPRequest + + +@pytest.fixture +def mock_httpx2_client(): + return MagicMock(spec=httpx2.Client) + + +def test_httpx2_sync_make_request(mock_request_dumper, mock_response_loader, mock_httpx2_client): + client = HTTPX2SyncClient( + base_url="http://base", + request_dumper=mock_request_dumper, + response_loader=mock_response_loader, + session=mock_httpx2_client + ) + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {"Content-Type": "application/json"} + mock_response.cookies = {} + mock_response.json.return_value = {"key": "value"} + mock_response.content = b'{"key": "value"}' + mock_response.text = '{"key": "value"}' + + mock_httpx2_client.request.return_value = mock_response + + request = HTTPRequest( + url="/test", + method="POST", + header={"Auth": "123"}, + path={}, + query={"q": "1"}, + body={"data": "abc"}, + file={}, + form={} + ) + + response = client.make_request(request) + + mock_httpx2_client.request.assert_called_once_with( + method="POST", + url="http://base/test", + headers={"Auth": "123", "Content-Type": "application/json"}, + params={"q": "1"}, + data={}, + files=None, + content='{"data": "abc"}' + ) + + assert response.status_code == 200 + assert response.data == {"key": "value"} + + +def test_httpx2_sync_close(mock_request_dumper, mock_response_loader, mock_httpx2_client): + client = HTTPX2SyncClient( + "http://base", + mock_request_dumper, + mock_response_loader, + session=mock_httpx2_client + ) + client.close() + mock_httpx2_client.close.assert_called_once() + + +def test_httpx2_sync_network_error(mock_request_dumper, mock_response_loader, mock_httpx2_client): + client = HTTPX2SyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_httpx2_client) + mock_httpx2_client.request.side_effect = httpx2.NetworkError("failures") + + request = HTTPRequest( + url="/test", + method="GET", + header={}, + path={}, + query={}, + body={}, + file={}, + form={} + ) + + with pytest.raises(NetworkError): + client.make_request(request) + + +def test_httpx2_sync_timeout_error(mock_request_dumper, mock_response_loader, mock_httpx2_client): + client = HTTPX2SyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_httpx2_client) + mock_httpx2_client.request.side_effect = httpx2.TimeoutException("timed out") + + request = HTTPRequest( + url="/test", + method="GET", + header={}, + path={}, + query={}, + body={}, + file={}, + form={} + ) + + with pytest.raises(RequestTimeoutError): + client.make_request(request) + + +def test_httpx2_sync_body_and_form_error(mock_request_dumper, mock_response_loader, mock_httpx2_client): + client = HTTPX2SyncClient( + base_url="http://base", + request_dumper=mock_request_dumper, + response_loader=mock_response_loader, + session=mock_httpx2_client + ) + + request = HTTPRequest( + url="/test", + method="POST", + header={}, + path={}, + query={}, + body={"some": "body"}, + file=None, + form={"some": "form"} + ) + + with pytest.raises(ValueError, match="Cannot use Body with Form or File"): + client.make_request(request) + + +def test_httpx2_sync_file_list_conversion(mock_request_dumper, mock_response_loader, mock_httpx2_client): + from unihttp.http import UploadFile + + client = HTTPX2SyncClient( + base_url="http://base", + request_dumper=mock_request_dumper, + response_loader=mock_response_loader, + session=mock_httpx2_client + ) + + # Mock response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = b'{}' + mock_response.text = '{}' + mock_httpx2_client.request.return_value = mock_response + + request = HTTPRequest( + url="/upload", + method="POST", + header={}, + path={}, + query={}, + body=None, + file={ + "files": [ + UploadFile(b"content1", filename="f1.txt"), + ("f2.txt", b"content2") + ], + "single_upload_file": UploadFile(b"content3", filename="f3.txt"), + "single_tuple": ("f4.txt", b"content4") + }, + form={} + ) + + client.make_request(request) + + mock_httpx2_client.request.assert_called_once() + call_kwargs = mock_httpx2_client.request.call_args[1] + files = call_kwargs["files"] + + # Verify order and content + assert files[0] == ("files", ("f1.txt", b"content1", "application/octet-stream")) + assert files[1] == ("files", ("f2.txt", b"content2")) + assert files[2] == ("single_upload_file", ("f3.txt", b"content3", "application/octet-stream")) + assert files[3] == ("single_tuple", ("f4.txt", b"content4")) diff --git a/tests/test_clients/test_json_load_errors.py b/tests/test_clients/test_json_load_errors.py index aa86313..931395c 100644 --- a/tests/test_clients/test_json_load_errors.py +++ b/tests/test_clients/test_json_load_errors.py @@ -5,6 +5,7 @@ from unihttp.clients.requests import RequestsSyncClient from unihttp.clients.httpx import HTTPXSyncClient, HTTPXAsyncClient +from unihttp.clients.httpx2 import HTTPX2SyncClient, HTTPX2AsyncClient from unihttp.clients.aiohttp import AiohttpAsyncClient from unihttp.clients.niquests import NiquestsSyncClient, NiquestsAsyncClient @@ -54,6 +55,35 @@ async def test_httpx_async_json_error(mock_request, mock_request_dumper, mock_re response = await client.make_request(mock_request) assert response.data == b"not json" +def test_httpx2_sync_json_error(mock_request, mock_request_dumper, mock_response_loader): + mock_session = Mock() + mock_response = Mock() + mock_response.content = b"not json" + mock_response.text = "not json" + mock_response.status_code = 200 + mock_response.headers = {} + mock_response.cookies = {} + mock_session.request.return_value = mock_response + + client = HTTPX2SyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_session) + response = client.make_request(mock_request) + assert response.data == b"not json" + +@pytest.mark.asyncio +async def test_httpx2_async_json_error(mock_request, mock_request_dumper, mock_response_loader): + mock_session = AsyncMock() + mock_response = Mock() + mock_response.content = b"not json" + mock_response.text = "not json" + mock_response.status_code = 200 + mock_response.headers = {} + mock_response.cookies = {} + mock_session.request.return_value = mock_response + + client = HTTPX2AsyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_session) + response = await client.make_request(mock_request) + assert response.data == b"not json" + @pytest.mark.asyncio async def test_aiohttp_json_error(mock_request, mock_request_dumper, mock_response_loader): mock_response = AsyncMock()