diff --git a/google/auth/aio/transport/__init__.py b/google/auth/aio/transport/__init__.py index 28223b843..86f102ab1 100644 --- a/google/auth/aio/transport/__init__.py +++ b/google/auth/aio/transport/__init__.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Transport - Async HTTP client library support. +"""Transport - Asynchronous HTTP client library support. :mod:`google.auth.aio` is designed to work with various asynchronous client libraries such as aiohttp. In order to work across these libraries with different @@ -23,3 +23,49 @@ :mod:`google.auth` to make asynchronous requests. :class:`Response` defines the interface for the return value of :class:`Request`. """ + +import abc + + +class Request(metaclass=abc.ABCMeta): + """Interface for a callable that makes HTTP requests. + + Specific transport implementations should provide an implementation of + this that adapts their specific request / response API. + + .. automethod:: __call__ + """ + + @abc.abstractmethod + async def __call__( + self, url, method="GET", body=None, headers=None, timeout=None, **kwargs + ): + """Make an HTTP request. + + Args: + url (str): The URI to be requested. + method (str): The HTTP method to use for the request. Defaults + to 'GET'. + body (bytes): The payload / body in HTTP request. + headers (Mapping[str, str]): Request headers. + timeout (Optional[int]): The number of seconds to wait for a + response from the server. If not specified or if None, the + transport-specific default timeout will be used. + kwargs: Additionally arguments passed on to the transport's + request method. + + Returns: + Response: The HTTP response. + + Raises: + google.auth.exceptions.TransportError: If any exception occurred. + """ + # pylint: disable=redundant-returns-doc, missing-raises-doc + # (pylint doesn't play well with abstract docstrings.) + raise NotImplementedError("__call__ must be implemented.") + + async def close(self): + """ + Close the underlying session. + """ + raise NotImplementedError("close must be implemented.") diff --git a/google/auth/aio/transport/aiohttp.py b/google/auth/aio/transport/aiohttp.py index 3ff5644f1..d8c8719d3 100644 --- a/google/auth/aio/transport/aiohttp.py +++ b/google/auth/aio/transport/aiohttp.py @@ -14,3 +14,103 @@ """Transport adapter for Asynchronous HTTP Requests. """ + +import asyncio + +try: + import aiohttp +except ImportError as caught_exc: # pragma: NO COVER + raise ImportError( + "The aiohttp library is not installed from please install the aiohttp package to use the aiohttp transport." + ) from caught_exc +from google.auth import exceptions + +_DEFAULT_TIMEOUT = 180 # in seconds + + +class Request: + """Asynchronous Requests request adapter. + + This class is used internally for making requests using asyncio transports + in a consistent way. If you use :class:`AuthorizedSession` you do not need + to construct or use this class directly. + + This class can be useful if you want to manually refresh a + :class:`~google.auth.aio.credentials.Credentials` instance:: + + import google.auth.aio.transport.aiohttp + + request = google.auth.aio.transport.aiohttp.Request() + + await credentials.refresh(request) + + Args: + session (aiohttp.ClientSession): An instance :class:`aiohttp.ClientSession` used + to make HTTP requests. If not specified, a session will be created. + + .. automethod:: __call__ + """ + + def __init__(self, session=None): + # TODO(ohmayr): Evaluate if we want auto_decompress=False. + # and if we want to update it in the passed in session. + self.session = session or aiohttp.ClientSession(auto_decompress=False) + + async def __call__( + self, + url, + method="GET", + body=None, + headers=None, + timeout=_DEFAULT_TIMEOUT, + **kwargs, + ): + """ + Make an Asynchronous HTTP request using aiohttp. + + Args: + url (str): The URL to be requested. + method (Optional[str]): + The HTTP method to use for the request. Defaults to 'GET'. + body (Optional[bytes]): + The payload or body in HTTP request. + headers (Optional[Mapping[str, str]]): + Request headers. + timeout (Optional[int]): The number of seconds to wait for a + response from the server. If not specified or if None, the + requests default timeout will be used. + kwargs: Additional arguments passed through to the underlying + aiohttp :meth:`aiohttp.Session.request` method. + + Returns: + google.auth.aio.transport.aiohttp.Response: The HTTP response. + + Raises: + google.auth.exceptions.TransportError: If any exception occurred. + """ + + try: + # TODO (ohmayr): verify the timeout type. We may need to pass + # in aiohttp.ClientTimeout. Alternatively, we can incorporate + # per request timeout within the timeout_guard context manager. + response = await self.session.request( + method, url, data=body, headers=headers, timeout=timeout, **kwargs + ) + # TODO(ohmayr): Wrap this with Custom Response. + return response + + except aiohttp.ClientError as caught_exc: + new_exc = exceptions.TransportError(caught_exc) + raise new_exc from caught_exc + + except asyncio.TimeoutError as caught_exc: + + # TODO(ohmayr): Raise Custom Timeout Error instead + new_exc = exceptions.TransportError(caught_exc) + raise new_exc from caught_exc + + async def close(self): + """ + Close the underlying aiohttp session. + """ + await self.session.close() diff --git a/tests/transport/aio/test_aiohttp.py b/tests/transport/aio/test_aiohttp.py index 320eca4f2..555960e0b 100644 --- a/tests/transport/aio/test_aiohttp.py +++ b/tests/transport/aio/test_aiohttp.py @@ -13,4 +13,48 @@ # limitations under the License. import google.auth.aio.transport.aiohttp as auth_aiohttp -import pytest # type: ignore +import pytest # type: ignore +import pytest_asyncio +import asyncio +from google.auth import exceptions +from aioresponses import aioresponses + +# TODO (ohmayr): Verify if we want to optionally run these test cases +# if aohttp is installed instead of raising an exception. +try: + import aiohttp +except ImportError as caught_exc: # pragma: NO COVER + raise ImportError( + "The aiohttp library is not installed from please install the aiohttp package to use the aiohttp transport." + ) from caught_exc + + +# TODO (ohmayr): Update tests to incorporate custom response. +@pytest.mark.asyncio +class TestRequest: + @pytest_asyncio.fixture + async def aiohttp_request(self): + request = auth_aiohttp.Request() + yield request + await request.close() + + async def test_request_call_success(self, aiohttp_request): + with aioresponses() as m: + m.get("http://example.com", status=400, body="test") + response = await aiohttp_request("http://example.com") + assert response.status == 400 + + async def test_request_call_raises_client_error(self, aiohttp_request): + with aioresponses() as m: + m.get("http://example.com", exception=aiohttp.ClientError) + + with pytest.raises(exceptions.TransportError): + response = await aiohttp_request("http://example.com/api") + + async def test_request_call_raises_timeout_error(self, aiohttp_request): + with aioresponses() as m: + m.get("http://example.com", exception=asyncio.TimeoutError) + + # TODO(ohmayr): Update this test case to raise custom error + with pytest.raises(exceptions.TransportError): + response = await aiohttp_request("http://example.com")