Skip to content

Commit 8c47981

Browse files
authored
feat: Implement asynchronous AuthorizedSession api response class (#1575)
* feat: implement asynchronous response class for AuthorizedSessions API * check if aiohttp is installed and avoid tests dependency * update content to be async * update docstring to be specific to aiohttp * add type checking and avoid leaking underlying API responses * add test case for iterating chunks * add read method to response interface * address PR comments * fix lint issues
1 parent 681f6ea commit 8c47981

File tree

3 files changed

+166
-3
lines changed

3 files changed

+166
-3
lines changed

google/auth/aio/transport/__init__.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,59 @@
2323
:mod:`google.auth` to make asynchronous requests. :class:`Response` defines the interface
2424
for the return value of :class:`Request`.
2525
"""
26+
27+
import abc
28+
from typing import AsyncGenerator, Dict
29+
30+
31+
class Response(metaclass=abc.ABCMeta):
32+
"""Asynchronous HTTP Response Interface."""
33+
34+
@property
35+
@abc.abstractmethod
36+
def status_code(self) -> int:
37+
"""
38+
The HTTP response status code..
39+
40+
Returns:
41+
int: The HTTP response status code.
42+
43+
"""
44+
raise NotImplementedError("status_code must be implemented.")
45+
46+
@property
47+
@abc.abstractmethod
48+
def headers(self) -> Dict[str, str]:
49+
"""The HTTP response headers.
50+
51+
Returns:
52+
Dict[str, str]: The HTTP response headers.
53+
"""
54+
raise NotImplementedError("headers must be implemented.")
55+
56+
@abc.abstractmethod
57+
async def content(self, chunk_size: int = 1024) -> AsyncGenerator[bytes, None]:
58+
"""The raw response content.
59+
60+
Args:
61+
chunk_size (int): The size of each chunk. Defaults to 1024.
62+
63+
Yields:
64+
AsyncGenerator[bytes, None]: An asynchronous generator yielding
65+
response chunks as bytes.
66+
"""
67+
raise NotImplementedError("content must be implemented.")
68+
69+
@abc.abstractmethod
70+
async def read(self) -> bytes:
71+
"""Read the entire response content as bytes.
72+
73+
Returns:
74+
bytes: The entire response content.
75+
"""
76+
raise NotImplementedError("read must be implemented.")
77+
78+
@abc.abstractmethod
79+
async def close(self):
80+
"""Close the response after it is fully consumed to resource."""
81+
raise NotImplementedError("close must be implemented.")

google/auth/aio/transport/aiohttp.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,19 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
"""Transport adapter for Asynchronous HTTP Requests.
15+
"""Transport adapter for AIOHTTP Requests.
1616
"""
1717

18+
try:
19+
import aiohttp
20+
except ImportError as caught_exc: # pragma: NO COVER
21+
raise ImportError(
22+
"The aiohttp library is not installed from please install the aiohttp package to use the aiohttp transport."
23+
) from caught_exc
24+
from typing import AsyncGenerator, Dict
1825

26+
from google.auth import _helpers
27+
from google.auth.aio import transport
1928
from google.auth.exceptions import TimeoutError
2029

2130
import asyncio
@@ -65,3 +74,43 @@ async def with_timeout(coro):
6574

6675
finally:
6776
_remaining_time()
77+
78+
79+
class Response(transport.Response):
80+
81+
"""
82+
Represents an HTTP response and its data. It is returned by ``google.auth.aio.transport.sessions.AuthorizedSession``.
83+
84+
Args:
85+
response (aiohttp.ClientResponse): An instance of aiohttp.ClientResponse.
86+
87+
Attributes:
88+
status_code (int): The HTTP status code of the response.
89+
headers (Dict[str, str]): A case-insensitive multidict proxy wiht HTTP headers of response.
90+
"""
91+
92+
def __init__(self, response: aiohttp.ClientResponse):
93+
self._response = response
94+
95+
@property
96+
@_helpers.copy_docstring(transport.Response)
97+
def status_code(self) -> int:
98+
return self._response.status
99+
100+
@property
101+
@_helpers.copy_docstring(transport.Response)
102+
def headers(self) -> Dict[str, str]:
103+
return {key: value for key, value in self._response.headers.items()}
104+
105+
@_helpers.copy_docstring(transport.Response)
106+
async def content(self, chunk_size: int = 1024) -> AsyncGenerator[bytes, None]:
107+
async for chunk in self._response.content.iter_chunked(chunk_size):
108+
yield chunk
109+
110+
@_helpers.copy_docstring(transport.Response)
111+
async def read(self) -> bytes:
112+
return await self._response.read()
113+
114+
@_helpers.copy_docstring(transport.Response)
115+
async def close(self):
116+
return await self._response.close()

tests/transport/aio/test_aiohttp.py

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,76 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
import google.auth.aio.transport.aiohttp as auth_aiohttp
15+
from unittest.mock import AsyncMock, Mock, patch
16+
1617
import pytest # type: ignore
18+
1719
import asyncio
20+
21+
import google.auth.aio.transport.aiohttp as auth_aiohttp
22+
1823
from google.auth.exceptions import TimeoutError
19-
from unittest.mock import patch
2024

2125

2226
@pytest.fixture
2327
async def simple_async_task():
2428
return True
2529

2630

31+
@pytest.fixture
32+
def mock_response():
33+
response = Mock()
34+
response.status = 200
35+
response.headers = {"Content-Type": "application/json", "Content-Length": "100"}
36+
mock_iterator = AsyncMock()
37+
mock_iterator.__aiter__.return_value = iter(
38+
[b"Cavefish ", b"have ", b"no ", b"sight."]
39+
)
40+
response.content.iter_chunked = lambda chunk_size: mock_iterator
41+
response.read = AsyncMock(return_value=b"Cavefish have no sight.")
42+
response.close = AsyncMock()
43+
44+
return auth_aiohttp.Response(response)
45+
46+
47+
class TestResponse(object):
48+
@pytest.mark.asyncio
49+
async def test_response_status_code(self, mock_response):
50+
assert mock_response.status_code == 200
51+
52+
@pytest.mark.asyncio
53+
async def test_response_headers(self, mock_response):
54+
assert mock_response.headers["Content-Type"] == "application/json"
55+
assert mock_response.headers["Content-Length"] == "100"
56+
57+
@pytest.mark.asyncio
58+
async def test_response_content(self, mock_response):
59+
content = b"".join([chunk async for chunk in mock_response.content()])
60+
assert content == b"Cavefish have no sight."
61+
62+
@pytest.mark.asyncio
63+
async def test_response_read(self, mock_response):
64+
content = await mock_response.read()
65+
assert content == b"Cavefish have no sight."
66+
67+
@pytest.mark.asyncio
68+
async def test_response_close(self, mock_response):
69+
await mock_response.close()
70+
mock_response._response.close.assert_called_once()
71+
72+
@pytest.mark.asyncio
73+
async def test_response_content_stream(self, mock_response):
74+
itr = mock_response.content().__aiter__()
75+
content = []
76+
try:
77+
while True:
78+
chunk = await itr.__anext__()
79+
content.append(chunk)
80+
except StopAsyncIteration:
81+
pass
82+
assert b"".join(content) == b"Cavefish have no sight."
83+
84+
2785
class TestTimeoutGuard(object):
2886
default_timeout = 1
2987

0 commit comments

Comments
 (0)