diff --git a/src/visor/_transport.py b/src/visor/_transport.py new file mode 100644 index 0000000..b785205 --- /dev/null +++ b/src/visor/_transport.py @@ -0,0 +1,113 @@ +from datetime import datetime +from email.utils import parsedate_to_datetime + +import httpx + +from visor.exceptions import ( + AuthError, + ForbiddenError, + NotFoundError, + PaymentRequiredError, + RateLimitError, + ValidationError, + VisorAPIError, + VisorTransportError, +) + +DEFAULT_BASE_URL = "https://api.visor.vin/v1" + + +def _parse_retry_after(value: str | None) -> int | None: + if not value: + return None + try: + return int(value) + except ValueError: + try: + retry_at = parsedate_to_datetime(value) + except (TypeError, ValueError): + return None + return max(0, int((retry_at - datetime.now(retry_at.tzinfo)).total_seconds())) + + +def _handle_response(response: httpx.Response) -> dict: # type: ignore[type-arg] + if response.is_success: + return response.json() # type: ignore[no-any-return] + + try: + body = response.json() + except ValueError: + body = None + + error: object = body.get("error") if isinstance(body, dict) else None + if isinstance(error, dict): + error_code = error.get("code", "unknown_error") or "unknown_error" + message = error.get("message", response.text) or response.text + else: + error_code = "unknown_error" + message = response.text + + match response.status_code: + case 400: + raise ValidationError(400, error_code, message) + case 401: + raise AuthError(401, error_code, message) + case 402: + raise PaymentRequiredError(402, error_code, message) + case 403: + raise ForbiddenError(403, error_code, message) + case 404: + raise NotFoundError(404, error_code, message) + case 429: + retry_after = _parse_retry_after(response.headers.get("Retry-After")) + raise RateLimitError(429, error_code, message, retry_after=retry_after) + case _: + raise VisorAPIError(response.status_code, error_code, message) + + +class AsyncVisorTransport: + def __init__( + self, + api_key: str, + base_url: str = DEFAULT_BASE_URL, + timeout: float = 30.0, + ) -> None: + self._client = httpx.AsyncClient( + base_url=base_url, + headers={"Authorization": f"Bearer {api_key}"}, + timeout=timeout, + ) + + async def get(self, path: str, params: dict[str, str] | None = None) -> dict: # type: ignore[type-arg] + try: + response = await self._client.get(path, params=params or {}) + except httpx.RequestError as e: + raise VisorTransportError(f"Request failed: {e}") from e + return _handle_response(response) + + async def aclose(self) -> None: + await self._client.aclose() + + +class SyncVisorTransport: + def __init__( + self, + api_key: str, + base_url: str = DEFAULT_BASE_URL, + timeout: float = 30.0, + ) -> None: + self._client = httpx.Client( + base_url=base_url, + headers={"Authorization": f"Bearer {api_key}"}, + timeout=timeout, + ) + + def get(self, path: str, params: dict[str, str] | None = None) -> dict: # type: ignore[type-arg] + try: + response = self._client.get(path, params=params or {}) + except httpx.RequestError as e: + raise VisorTransportError(f"Request failed: {e}") from e + return _handle_response(response) + + def close(self) -> None: + self._client.close() diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..69dac7a --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,287 @@ +import httpx +import pytest +import respx + +from visor._transport import DEFAULT_BASE_URL, AsyncVisorTransport, SyncVisorTransport +from visor.exceptions import ( + AuthError, + ForbiddenError, + NotFoundError, + PaymentRequiredError, + RateLimitError, + ValidationError, + VisorAPIError, + VisorTransportError, +) + +API_KEY = "test-key" +SUCCESS_BODY = {"data": {"id": "abc"}} +ERROR_BODY = {"error": {"code": "some_error", "message": "something went wrong"}} + + +# --------------------------------------------------------------------------- +# Async transport +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_async_auth_header_sent(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + route = mock.get("/listings").mock( + return_value=httpx.Response(200, json=SUCCESS_BODY) + ) + transport = AsyncVisorTransport(api_key=API_KEY) + await transport.get("/listings") + await transport.aclose() + + assert route.called + auth = route.calls[0].request.headers["Authorization"] + assert auth == f"Bearer {API_KEY}" + + +@pytest.mark.asyncio +async def test_async_base_url_path_composition(): + custom_base = "https://api.visor.vin/v1" + with respx.mock(base_url=custom_base) as mock: + route = mock.get("/vins/ABC123").mock( + return_value=httpx.Response(200, json=SUCCESS_BODY) + ) + transport = AsyncVisorTransport(api_key=API_KEY, base_url=custom_base) + await transport.get("/vins/ABC123") + await transport.aclose() + + assert route.called + + +@pytest.mark.asyncio +async def test_async_query_params_passed(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + route = mock.get("/listings").mock( + return_value=httpx.Response(200, json=SUCCESS_BODY) + ) + transport = AsyncVisorTransport(api_key=API_KEY) + await transport.get("/listings", params={"make": "Toyota", "state": "TX"}) + await transport.aclose() + + url = str(route.calls[0].request.url) + assert "make=Toyota" in url + assert "state=TX" in url + + +@pytest.mark.asyncio +async def test_async_success_returns_json(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock(return_value=httpx.Response(200, json=SUCCESS_BODY)) + transport = AsyncVisorTransport(api_key=API_KEY) + result = await transport.get("/listings") + await transport.aclose() + + assert result == SUCCESS_BODY + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status_code, exc_class", + [ + (400, ValidationError), + (401, AuthError), + (402, PaymentRequiredError), + (403, ForbiddenError), + (404, NotFoundError), + (429, RateLimitError), + (500, VisorAPIError), + ], +) +async def test_async_error_status_raises(status_code: int, exc_class: type) -> None: + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock( + return_value=httpx.Response(status_code, json=ERROR_BODY) + ) + transport = AsyncVisorTransport(api_key=API_KEY) + with pytest.raises(exc_class): + await transport.get("/listings") + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_rate_limit_carries_retry_after(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock( + return_value=httpx.Response( + 429, + headers={"Retry-After": "60"}, + json=ERROR_BODY, + ) + ) + transport = AsyncVisorTransport(api_key=API_KEY) + with pytest.raises(RateLimitError) as exc_info: + await transport.get("/listings") + await transport.aclose() + + assert exc_info.value.retry_after == 60 + + +@pytest.mark.asyncio +async def test_async_rate_limit_retry_after_none_when_missing(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock(return_value=httpx.Response(429, json=ERROR_BODY)) + transport = AsyncVisorTransport(api_key=API_KEY) + with pytest.raises(RateLimitError) as exc_info: + await transport.get("/listings") + await transport.aclose() + + assert exc_info.value.retry_after is None + + +@pytest.mark.asyncio +async def test_async_non_json_error_body_unknown_error(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock( + return_value=httpx.Response( + 503, + content=b"Service Unavailable", + headers={"Content-Type": "text/html"}, + ) + ) + transport = AsyncVisorTransport(api_key=API_KEY) + with pytest.raises(VisorAPIError) as exc_info: + await transport.get("/listings") + await transport.aclose() + + assert exc_info.value.error_code == "unknown_error" + assert "Service Unavailable" in exc_info.value.message + + +@pytest.mark.asyncio +async def test_async_request_error_raises_transport_error(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock(side_effect=httpx.ConnectError("connection refused")) + transport = AsyncVisorTransport(api_key=API_KEY) + with pytest.raises(VisorTransportError): + await transport.get("/listings") + await transport.aclose() + + +@pytest.mark.asyncio +async def test_async_malformed_error_envelope_unknown_error(): + """{"error": "oops"} — error is a string, not a dict; must not crash.""" + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock( + return_value=httpx.Response(500, json={"error": "oops"}) + ) + transport = AsyncVisorTransport(api_key=API_KEY) + with pytest.raises(VisorAPIError) as exc_info: + await transport.get("/listings") + await transport.aclose() + + assert exc_info.value.error_code == "unknown_error" + + +# --------------------------------------------------------------------------- +# Sync transport +# --------------------------------------------------------------------------- + + +def test_sync_auth_header_sent(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + route = mock.get("/listings").mock( + return_value=httpx.Response(200, json=SUCCESS_BODY) + ) + transport = SyncVisorTransport(api_key=API_KEY) + transport.get("/listings") + transport.close() + + assert route.called + auth = route.calls[0].request.headers["Authorization"] + assert auth == f"Bearer {API_KEY}" + + +def test_sync_query_params_passed(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + route = mock.get("/listings").mock( + return_value=httpx.Response(200, json=SUCCESS_BODY) + ) + transport = SyncVisorTransport(api_key=API_KEY) + transport.get("/listings", params={"make": "Ford", "state": "CA"}) + transport.close() + + url = str(route.calls[0].request.url) + assert "make=Ford" in url + assert "state=CA" in url + + +def test_sync_success_returns_json(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock(return_value=httpx.Response(200, json=SUCCESS_BODY)) + transport = SyncVisorTransport(api_key=API_KEY) + result = transport.get("/listings") + transport.close() + + assert result == SUCCESS_BODY + + +@pytest.mark.parametrize( + "status_code, exc_class", + [ + (400, ValidationError), + (401, AuthError), + (402, PaymentRequiredError), + (403, ForbiddenError), + (404, NotFoundError), + (429, RateLimitError), + (500, VisorAPIError), + ], +) +def test_sync_error_status_raises(status_code: int, exc_class: type) -> None: + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock( + return_value=httpx.Response(status_code, json=ERROR_BODY) + ) + transport = SyncVisorTransport(api_key=API_KEY) + with pytest.raises(exc_class): + transport.get("/listings") + transport.close() + + +def test_sync_rate_limit_carries_retry_after(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock( + return_value=httpx.Response( + 429, + headers={"Retry-After": "45"}, + json=ERROR_BODY, + ) + ) + transport = SyncVisorTransport(api_key=API_KEY) + with pytest.raises(RateLimitError) as exc_info: + transport.get("/listings") + transport.close() + + assert exc_info.value.retry_after == 45 + + +def test_sync_non_json_error_body_unknown_error(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock( + return_value=httpx.Response( + 503, + content=b"Service Unavailable", + headers={"Content-Type": "text/html"}, + ) + ) + transport = SyncVisorTransport(api_key=API_KEY) + with pytest.raises(VisorAPIError) as exc_info: + transport.get("/listings") + transport.close() + + assert exc_info.value.error_code == "unknown_error" + assert "Service Unavailable" in exc_info.value.message + + +def test_sync_request_error_raises_transport_error(): + with respx.mock(base_url=DEFAULT_BASE_URL) as mock: + mock.get("/listings").mock(side_effect=httpx.ConnectError("connection refused")) + transport = SyncVisorTransport(api_key=API_KEY) + with pytest.raises(VisorTransportError): + transport.get("/listings") + transport.close()