Skip to content

Commit b42737a

Browse files
enh: add support for handling paginated odata responses (#31)
1 parent 9824744 commit b42737a

7 files changed

Lines changed: 260 additions & 11 deletions

File tree

README.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@
77

88

99
.. _4Insight.io: https://4insight.io
10-
.. _4Insight REST API: https://4insight.io/#/developer
10+
.. _4Insight REST API: https://4insight-api-prod.4subsea.net/swagger/index.html

docs/index.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
:py:mod:`fourinsight.api` is mainly used by other high-level 4Insight Python packages. However, it can also be used to make low-level calls.
77

88
.. _4Insight.io: https://4insight.io
9-
.. _4Insight REST API: https://4insight.io/#/developer
9+
.. _4Insight REST API: https://4insight-api-prod.4subsea.net/swagger/index.html
1010

1111

1212
.. toctree::

docs/user_guide/api_calls.rst

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,43 @@
11
Make API calls
22
==============
33

4-
Below example demonstrate how to make an API call to `4Insight.io`_
4+
The example below demonstrates how to make an API call to `4Insight.io`_
55
For the complete overview of available API calls see `4Insight REST API`_.
66

77

88
.. code-block:: python
99
10-
response = session.get("https://api.4insight.io/v1.0/Campaigns")
10+
response = session.get("https://api.4insight.io/v1.1/Campaigns")
1111
1212
# or with relative url
1313
14-
response = session.get("/v1.0/Campaigns")
14+
response = session.get("/v1.1/Campaigns")
1515
1616
17-
.. _4Insight REST API: https://4insight.io/#/developer
17+
Some API endpoints support OData and have paging, which returns a limited number of responses per page.
18+
For these endpoints, the ``get_pages`` method can be useful:
19+
20+
.. code-block:: python
21+
22+
response = session.get_pages("https://api.4insight.io/v1.1/Campaigns")
23+
24+
# Iterate through response pages:
25+
for page in response:
26+
print(page)
27+
28+
# Alternatively, get just the next page:
29+
page = next(response)
30+
31+
32+
``get_pages`` returns a generator object, from which the user can iterate through the page, or obtain the pages one by one by calling ``next(response)``.
33+
34+
35+
36+
37+
38+
39+
40+
41+
42+
.. _4Insight REST API: https://4insight-api-prod.4subsea.net/swagger/index.html
1843
.. _4Insight.io: https://4insight.io

docs/user_guide/authentication.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,4 @@ Example:
6262

6363

6464
.. _4Insight.io: https://4insight.io
65-
.. _4Insight REST API: https://4insight.io/#/developer
65+
.. _4Insight REST API: https://4insight-api-prod.4subsea.net/swagger/index.html

fourinsight/api/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44

55
logging.getLogger(__name__).addHandler(logging.NullHandler())
66

7-
__version__ = "0.0.1"
7+
__version__ = "0.0.3"

fourinsight/api/authenticate.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
WebApplicationClient,
1616
)
1717
from requests.adapters import HTTPAdapter
18+
from requests.exceptions import HTTPError
1819
from requests.packages.urllib3 import Retry
1920
from requests_oauthlib import OAuth2Session
2021

@@ -85,7 +86,7 @@ def token(self):
8586

8687

8788
class BaseAuthSession(OAuth2Session, metaclass=ABCMeta):
88-
"""
89+
r"""
8990
Abstract class for authorized sessions.
9091
9192
Parameters
@@ -101,7 +102,7 @@ class BaseAuthSession(OAuth2Session, metaclass=ABCMeta):
101102
The provided dict is stored internally in '_session_params'.
102103
auth_force : bool, optional
103104
Force re-authenticating the session (default is False)
104-
kwargs : keyword arguments
105+
**kwargs : keyword arguments
105106
Keyword arguments passed on to ``requests_oauthlib.OAuth2Session``.
106107
Here, the mandatory parameters for the authentication client shall be
107108
provided.
@@ -210,6 +211,38 @@ def _update_args_kwargs(self, args, kwargs):
210211
kwargs.setdefault(key, self._defaults[key])
211212
return (method, url, *args[2:]), kwargs
212213

214+
def get_pages(self, url, **kwargs):
215+
r"""
216+
Sends GET requests, and returns a generator that lets the user iterate over
217+
paginated responses. Note that the endpoint must support OData; the json
218+
response should include the a parameter '@odata.nextLink', providing the
219+
URL for the next page.
220+
221+
Parameters
222+
----------
223+
url : str
224+
API endpoint. To return pages, the endpoint must support OData and contain
225+
the parameter '@odata.nextLink'.
226+
**kwargs :
227+
Optional keyword arguments. Will be passed on to the ``get`` method.
228+
229+
Yields
230+
------
231+
response : obj
232+
The response as a :class:`Response` object.
233+
"""
234+
235+
while url:
236+
response = self.get(url, **kwargs)
237+
238+
try:
239+
response.raise_for_status()
240+
except HTTPError:
241+
url = None
242+
else:
243+
url = response.json().get("@odata.nextLink")
244+
yield response
245+
213246

214247
class UserSession(BaseAuthSession):
215248
"""

tests/test_authenticator.py

Lines changed: 192 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import inspect
12
import json
23
import logging
34
from io import StringIO
4-
from unittest.mock import MagicMock, patch
5+
from unittest.mock import MagicMock, Mock, call, patch
56

67
import pytest
8+
from requests.exceptions import HTTPError
79

810
import fourinsight.api as fapi
911
from fourinsight.api import authenticate
@@ -523,6 +525,195 @@ def test_request_logger(self, mock_request, mock_fetch, mock_refresh):
523525
log_out = stream.read()
524526
assert log_out.startswith("request initiated")
525527

528+
def test_get_pages(self, mock_fetch, mock_refresh):
529+
530+
JSON_DATA = [
531+
{
532+
"value": {"a": 0, "b": "foo", "c": "bar"},
533+
"@odata.nextLink": "first/nextlink",
534+
},
535+
{
536+
"value": [
537+
{"a": 123, "b": "baz", "c": "bar"},
538+
{"a": 456, "b": "foo", "c": "bar"},
539+
],
540+
"@odata.nextLink": "second/nextlink",
541+
},
542+
{
543+
"value": None,
544+
"@odata.nextLink": "third/nextlink",
545+
},
546+
{
547+
"value": {"a": 0, "b": "foo", "c": "bar"},
548+
},
549+
]
550+
551+
mock_response = Mock()
552+
mock_response._n_calls = 0
553+
mock_response.json.side_effect = lambda *args, **kwargs: JSON_DATA[
554+
mock_response._n_calls - 1
555+
]
556+
557+
def get_side_effect(*args, **kwargs):
558+
mock_response._n_calls += 1
559+
return mock_response
560+
561+
with patch.object(
562+
authenticate.ClientSession, "get", side_effect=get_side_effect
563+
) as mock_get:
564+
session = authenticate.ClientSession("my_client_id", "my_client_secret")
565+
566+
pages = session.get_pages("foo/bar/baz", baz="foobar")
567+
568+
for i, page_i in enumerate(pages):
569+
assert page_i.json() == JSON_DATA[i]
570+
assert page_i is mock_response
571+
572+
mock_get.assert_has_calls(
573+
[
574+
call("foo/bar/baz", baz="foobar"),
575+
call("first/nextlink", baz="foobar"),
576+
call("second/nextlink", baz="foobar"),
577+
call("third/nextlink", baz="foobar"),
578+
]
579+
)
580+
581+
def test_get_pages_last_nextlink_none(self, mock_fetch, mock_refresh):
582+
583+
JSON_DATA = [
584+
{
585+
"value": {"a": 0, "b": "foo", "c": "bar"},
586+
"@odata.nextLink": "first/nextlink",
587+
},
588+
{
589+
"value": [
590+
{"a": 123, "b": "baz", "c": "bar"},
591+
{"a": 456, "b": "foo", "c": "bar"},
592+
],
593+
"@odata.nextLink": "second/nextlink",
594+
},
595+
{
596+
"value": None,
597+
"@odata.nextLink": "third/nextlink",
598+
},
599+
{
600+
"value": {"a": 0, "b": "foo", "c": "bar"},
601+
"@odata.nextLink": None,
602+
},
603+
]
604+
605+
mock_response = Mock()
606+
mock_response._n_calls = 0
607+
mock_response.json.side_effect = lambda *args, **kwargs: JSON_DATA[
608+
mock_response._n_calls - 1
609+
]
610+
611+
def get_side_effect(*args, **kwargs):
612+
mock_response._n_calls += 1
613+
return mock_response
614+
615+
with patch.object(
616+
authenticate.ClientSession, "get", side_effect=get_side_effect
617+
) as mock_get:
618+
session = authenticate.ClientSession("my_client_id", "my_client_secret")
619+
620+
pages = session.get_pages("foo/bar/baz", baz="foobar")
621+
622+
for i, page_i in enumerate(pages):
623+
assert page_i.json() == JSON_DATA[i]
624+
assert page_i is mock_response
625+
626+
mock_get.assert_has_calls(
627+
[
628+
call("foo/bar/baz", baz="foobar"),
629+
call("first/nextlink", baz="foobar"),
630+
call("second/nextlink", baz="foobar"),
631+
call("third/nextlink", baz="foobar"),
632+
]
633+
)
634+
635+
def test_get_pages_raises_json_list(self, mock_fetch, mock_refresh):
636+
637+
JSON_DATA = [
638+
{
639+
"a": "foo",
640+
"b": "bar",
641+
"c": "baz",
642+
},
643+
{
644+
"a": "foobar",
645+
"b": "baz",
646+
"c": "test",
647+
},
648+
]
649+
650+
mock_response = Mock()
651+
mock_response.json.return_value = JSON_DATA
652+
653+
with patch.object(
654+
authenticate.ClientSession, "get", return_value=mock_response
655+
):
656+
session = authenticate.ClientSession("my_client_id", "my_client_secret")
657+
658+
pages = session.get_pages("foo/bar/baz", baz="foobar")
659+
660+
with pytest.raises(AttributeError):
661+
next(pages)
662+
663+
def test_get_pages_raise_for_status(self, mock_fetch, mock_refresh):
664+
665+
JSON_DATA = [
666+
{
667+
"value": {"a": 0, "b": "foo", "c": "bar"},
668+
"@odata.nextLink": "first/nextlink",
669+
},
670+
{
671+
"value": [
672+
{"a": 123, "b": "baz", "c": "bar"},
673+
{"a": 456, "b": "foo", "c": "bar"},
674+
],
675+
"@odata.nextLink": "second/nextlink",
676+
},
677+
{
678+
"value": None,
679+
"@odata.nextLink": "third/nextlink",
680+
},
681+
{
682+
"value": {"a": 0, "b": "foo", "c": "bar"},
683+
"@odata.nextLink": None,
684+
},
685+
]
686+
687+
mock_response = Mock()
688+
mock_response._n_calls = 0
689+
mock_response.json.side_effect = lambda *args, **kwargs: JSON_DATA[
690+
mock_response._n_calls - 1
691+
]
692+
693+
def raise_httperror(*args, **kwargs):
694+
raise HTTPError()
695+
696+
mock_response.raise_for_status.side_effect = raise_httperror
697+
698+
def get_side_effect(*args, **kwargs):
699+
mock_response._n_calls += 1
700+
return mock_response
701+
702+
with patch.object(
703+
authenticate.ClientSession, "get", side_effect=get_side_effect
704+
) as mock_get:
705+
session = authenticate.ClientSession("my_client_id", "my_client_secret")
706+
707+
pages = session.get_pages("foo/bar/baz", baz="foobar")
708+
709+
for i, page_i in enumerate(pages):
710+
assert page_i.json() == JSON_DATA[i]
711+
assert page_i is mock_response
712+
713+
mock_get.assert_called_once_with("foo/bar/baz", baz="foobar")
714+
715+
mock_response.raise_for_status.assert_called_once()
716+
526717

527718
if __name__ == "__main__":
528719
pytest.main()

0 commit comments

Comments
 (0)