Skip to content

Commit 8bba763

Browse files
authored
pagination support (#56)
Follows pagination for supported endpoints. * helpers.py: get_next_page parses headers for "next" url * rest/transport: send methods take an optional pagination_handler, if present and a next page is found, accumulate the results via the handler function * rest/zones.py: add pagination_handlers for list and retrieve, pass them to make_request * try to write some actually useful tests exercising twisted transport. basic and ugly but don't rely on twisted test frameworks to run.
1 parent 15cf058 commit 8bba763

14 files changed

Lines changed: 483 additions & 95 deletions

File tree

README.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ Contributions
7070
Pull Requests and issues are welcome. See the [NS1 Contribution Guidelines](https://github.com/ns1/community) for more information.
7171

7272
Our CI process will lint and check for formatting issues with `flake8` and
73-
`black`.
73+
`black`.
7474
It is suggested to run these checks prior to submitting a pull request and fix
7575
any issues:
7676
```

examples/zones.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@
1515
# to load an alternate configuration file:
1616
# api = NS1(configFile='/etc/ns1/api.json')
1717

18+
# turn on "follow pagination". This will handle paginated responses for
19+
# zone list and the records for a zone retrieve. It's off by default to
20+
# avoid a breaking change
21+
config = api.config
22+
config["follow_pagination"] = True
23+
1824
######################
1925
# LOAD / CREATE ZONE #
2026
######################

ns1/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ def _doDefaults(self):
6565
if "ddi" not in self._data:
6666
self._data["ddi"] = False
6767

68+
if "follow_pagination" not in self._data:
69+
self._data["follow_pagination"] = False
70+
6871
def createFromAPIKey(self, apikey, maybeWriteDefault=False):
6972
"""
7073
Create a basic config from a single API key

ns1/helpers.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import re
2+
13
from threading import Lock
24

35

@@ -13,3 +15,35 @@ def __new__(cls, *args, **kwargs):
1315
if cls._instance is None:
1416
cls._instance = object.__new__(cls, *args, **kwargs)
1517
return cls._instance
18+
19+
20+
def get_next_page(headers):
21+
headers = {k.lower(): v for k, v in headers.items()}
22+
links = _parse_header_links(headers.get("link", ""))
23+
for link in links:
24+
if link.get("rel") == "next":
25+
return link.get("url").replace("http://", "https://")
26+
27+
28+
# cribbed from requests, since we don't want to require it as a dependency
29+
def _parse_header_links(value):
30+
"""Return a dict of parsed link headers proxies.
31+
i.e. Link: <http:/.../front.jpeg>; rel=front; type="image/jpeg",<http://.../back.jpeg>; rel=back;type="image/jpeg"
32+
"""
33+
links = []
34+
replace_chars = " '\""
35+
for val in re.split(", *<", value):
36+
try:
37+
url, params = val.split(";", 1)
38+
except ValueError:
39+
url, params = val, ""
40+
link = {}
41+
link["url"] = url.strip("<> '\"")
42+
for param in params.split(";"):
43+
try:
44+
key, value = param.split("=")
45+
except ValueError:
46+
break
47+
link[key.strip(replace_chars)] = value.strip(replace_chars)
48+
links.append(link)
49+
return links

ns1/rest/transport/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def __init__(self, config, module):
1818
"ignore-ssl-errors", self._config.get("ignore-ssl-errors", False)
1919
)
2020
self._rate_limit_func = self._config.getRateLimitingFunc()
21+
self._follow_pagination = self._config.get("follow_pagination", False)
2122

2223
def _logHeaders(self, headers):
2324
if self._config["verbosity"] > 0:

ns1/rest/transport/basic.py

Lines changed: 42 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
#
66
from __future__ import absolute_import
77

8+
from ns1.helpers import get_next_page
89
from ns1.rest.transport.base import TransportBase
910
from ns1.rest.errors import (
1011
ResourceException,
@@ -54,30 +55,7 @@ def _set_opener(self):
5455
return build_opener(HTTPSHandler(context=context))
5556
return build_opener(HTTPSHandler)
5657

57-
def send(
58-
self,
59-
method,
60-
url,
61-
headers=None,
62-
data=None,
63-
files=None,
64-
params=None,
65-
callback=None,
66-
errback=None,
67-
):
68-
if headers is None:
69-
headers = {}
70-
if files is not None:
71-
# XXX
72-
raise Exception("file uploads not supported in BasicTransport yet")
73-
self._logHeaders(headers)
74-
self._log.debug("%s %s %s" % (method, url, data))
75-
76-
if sys.version_info.major >= 3 and isinstance(data, str):
77-
data = data.encode("utf-8")
78-
request = Request(url, headers=headers, data=data)
79-
request.get_method = lambda: method
80-
58+
def _send(self, url, headers, data, method, errback):
8159
def handleProblem(code, resp, msg):
8260
if errback:
8361
errback((resp, msg))
@@ -103,6 +81,9 @@ def handleProblem(code, resp, msg):
10381
body=msg,
10482
)
10583

84+
request = Request(url, headers=headers, data=data)
85+
request.get_method = lambda: method
86+
10687
# Handle error and responses the same so we can
10788
# always pass the body to the handleProblem function
10889
try:
@@ -127,22 +108,54 @@ def handleProblem(code, resp, msg):
127108
except AttributeError:
128109
pass
129110
try:
130-
jsonOut = json.loads(body)
111+
return headers, json.loads(body)
131112
except ValueError:
132113
if errback:
133114
errback(resp)
134-
return
135115
else:
136116
raise ResourceException(
137117
"invalid json in response", resp, body
138118
)
139119
else:
140-
jsonOut = None
120+
return headers, None
121+
122+
def send(
123+
self,
124+
method,
125+
url,
126+
headers=None,
127+
data=None,
128+
files=None,
129+
params=None,
130+
callback=None,
131+
errback=None,
132+
pagination_handler=None,
133+
):
134+
if headers is None:
135+
headers = {}
136+
if files is not None:
137+
# XXX
138+
raise Exception("file uploads not supported in BasicTransport yet")
139+
self._logHeaders(headers)
140+
self._log.debug("%s %s %s" % (method, url, data))
141+
142+
if sys.version_info.major >= 3 and isinstance(data, str):
143+
data = data.encode("utf-8")
144+
145+
resp_headers, jsonOut = self._send(url, headers, data, method, errback)
146+
if self._follow_pagination and pagination_handler is not None:
147+
next_page = get_next_page(resp_headers)
148+
while next_page is not None:
149+
self._log.debug("following pagination to: %s" % (next_page))
150+
next_headers, next_json = self._send(
151+
next_page, headers, data, method, errback
152+
)
153+
jsonOut = pagination_handler(jsonOut, next_json)
154+
next_page = get_next_page(next_headers)
141155

142156
if callback:
143157
return callback(jsonOut)
144-
else:
145-
return jsonOut
158+
return jsonOut
146159

147160
def _get_headers(self, response):
148161
# works for 2 and 3

ns1/rest/transport/requests.py

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
#
66
from __future__ import absolute_import
77

8+
from ns1.helpers import get_next_page
89
from ns1.rest.transport.base import TransportBase
910
from ns1.rest.errors import (
1011
ResourceException,
@@ -43,18 +44,7 @@ def _rateLimitHeaders(self, headers):
4344
"remaining": int(headers.get("X-RateLimit-Remaining", 100)),
4445
}
4546

46-
def send(
47-
self,
48-
method,
49-
url,
50-
headers=None,
51-
data=None,
52-
params=None,
53-
files=None,
54-
callback=None,
55-
errback=None,
56-
):
57-
self._logHeaders(headers)
47+
def _send(self, method, url, headers, data, files, params, errback):
5848
resp = self.REQ_MAP[method](
5949
url,
6050
headers=headers,
@@ -92,7 +82,7 @@ def send(
9282
# TODO make sure json is valid if a body is returned
9383
if resp.text:
9484
try:
95-
jsonOut = resp.json()
85+
return response_headers, resp.json()
9686
except ValueError:
9787
if errback:
9888
errback(resp)
@@ -102,12 +92,38 @@ def send(
10292
"invalid json in response", resp, resp.text
10393
)
10494
else:
105-
jsonOut = None
95+
return response_headers, None
96+
97+
def send(
98+
self,
99+
method,
100+
url,
101+
headers=None,
102+
data=None,
103+
params=None,
104+
files=None,
105+
callback=None,
106+
errback=None,
107+
pagination_handler=None,
108+
):
109+
self._logHeaders(headers)
110+
111+
resp_headers, jsonOut = self._send(
112+
method, url, headers, data, files, params, errback
113+
)
114+
if self._follow_pagination and pagination_handler is not None:
115+
next_page = get_next_page(resp_headers)
116+
while next_page is not None:
117+
self._log.debug("following pagination to: %s" % next_page)
118+
next_headers, next_json = self._send(
119+
method, next_page, headers, data, files, params, errback
120+
)
121+
jsonOut = pagination_handler(jsonOut, next_json)
122+
next_page = get_next_page(next_headers)
106123

107124
if callback:
108125
return callback(jsonOut)
109-
else:
110-
return jsonOut
126+
return jsonOut
111127

112128

113129
TransportBase.REGISTRY["requests"] = RequestsTransport

0 commit comments

Comments
 (0)