Skip to content

Commit 1da438f

Browse files
authored
Merge pull request #206 from DomainTools/IDEV-2584
[IDEV-2584] RTTF Support for header authentication & CLI Improvements for download endpoint
2 parents 8c01ff8 + a42387c commit 1da438f

9 files changed

Lines changed: 382 additions & 90 deletions

File tree

PYTHON_SUPPORT.md

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,20 @@
22

33
## Policy
44

5-
The DomainTools API library will support all versions of Python that are actively maintained by the Python
5+
The DomainTools API library will support all versions of Python that are actively maintained by the Python
66
Software Foundation. When a version of Python enters End of Life (EOL), the API library will also end support
7-
for that version of Python.
7+
for that version of Python.
88

9-
When a version's End of Life date is reached, DomainTools will ensure that a release of the API library that
9+
When a version's End of Life date is reached, DomainTools will ensure that a release of the API library that
1010
contains all changes up to that point in time is available. If a release already exists that has all
11-
changes at the point of a version's EOL date, no new one will be made. Any changes (features, bugfixes, etc)
11+
changes at the point of a version's EOL date, no new one will be made. Any changes (features, bugfixes, etc)
1212
released after an EOL date will not be tested on the now-unsupported version.
1313

14-
Versions of Python from other organizations (e.g. cython, pypy, jython) will not be actively supported. DomainTools
15-
will not develop specifically for those versions of Python, but we welcome community assistance (such as pull
14+
Versions of Python from other organizations (e.g. cython, pypy, jython) will not be actively supported. DomainTools
15+
will not develop specifically for those versions of Python, but we welcome community assistance (such as pull
1616
requests) to support them.
1717

18-
### Python 2
1918

20-
DomainTools API library support for Python 2.7 (and all Python 2) will end on November 30, 2020.
19+
### Python >=3.9
2120

22-
### Python 3.5
23-
24-
DomainTools will continue to support Python 3.5 until November 30, 2020.
25-
26-
## Upcoming Timeline:
27-
28-
- Support for Python 2.7 will end on Nov 30, 2020
29-
- Support for Python 3.5 will end on Nov 30, 2020
21+
DomainTools currently supports Python 3.9 and above.

README.md

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ Custom parameters aside from the common `GET` Request parameters:
264264
api = API(USERNAME, KEY)
265265
api.nod(endpoint="feed", **kwargs)
266266
```
267-
- `header_authentication`: by default, we're using API Header Authentication. Set this False if you want to use API Key and Secret Authentication. Apparently, you can't use API Header Authentication for `download` endpoints so this will be defaulted to `False` even without explicitly setting it.
267+
- `header_authentication`: by default, all RTTF endpoints (both `feed` and `download`) use API Header Authentication, sending the API key via the `X-Api-Key` header. Set this to `False` to pass the API key as a query parameter instead.
268268
```python
269269
api = API(USERNAME, KEY, header_authentication=False)
270270
api.nod(**kwargs)
@@ -275,7 +275,7 @@ Custom parameters aside from the common `GET` Request parameters:
275275
api.nod(output_format="csv", **kwargs)
276276
```
277277

278-
The Feed API standard access pattern is to periodically request the most recent feed data, as often as every 60 seconds. Specify the range of data you receive in one of two ways:
278+
The `feed` endpoint streams live NDJSON data. The standard access pattern is to poll as often as every 60 seconds. Specify the range of data you receive in one of two ways:
279279

280280
1. With `sessionID`: Make a call and provide a new `sessionID` parameter of your choosing. The API will return the last hour of data by default.
281281
- Each subsequent call to the API using your `sessionID` will return all data since the last.
@@ -284,6 +284,16 @@ The Feed API standard access pattern is to periodically request the most recent
284284
- Either an `after=-60` query parameter, where (in this example) -60 indicates the previous 60 seconds.
285285
- Or `after` and `before` query parameters for a time range, with each parameter accepting an ISO-8601 UTC formatted timestamp (a UTC date and time of the format YYYY-MM-DDThh:mm:ssZ)
286286

287+
The `download` endpoint returns a standard JSON response (not a stream) listing available S3 batch files. Time parameters (`sessionID`, `after`, `before`) are **not** required for download calls.
288+
289+
```python
290+
api = API(USERNAME, KEY)
291+
result = api.nod(endpoint="download", limit=5)
292+
print(result["download_name"])
293+
for f in result["files"]:
294+
print(f["name"], f["url"])
295+
```
296+
287297
### Feed parameters
288298

289299
The feed methods accept the following parameters, grouped by purpose. Availability depends on the feed (see the notes below the table).
@@ -325,11 +335,24 @@ The feed methods accept the following parameters, grouped by purpose. Availabili
325335

326336
- `output_format`: `csv` or `jsonl` (default `jsonl`). Not available on the `domainrdap` feed. `csv` is not available for `download` endpoints.
327337
- `headers`: When `csv` output is used, adds a header row to the first line of the response.
328-
- `top`: Positive integer from `1` to `1,000,000,000` limiting the number of results in the response payload.
338+
- `top`: Positive integer from `1` to `1,000,000,000` limiting the number of results in the response payload. Ignored for the `download` endpoint.
339+
340+
#### Download-only parameters
341+
342+
These parameters are only accepted when `endpoint="download"`. They are ignored for the `feed` endpoint.
343+
344+
- `limit`: Maximum number of files to return in the response.
345+
- `page`: Zero-indexed page of results to return. Available on `realtime_domain_risk`, `domainhotlist`, `iphotlist`, and `iprisk`.
346+
- `prefix`: Filter files by date prefix (e.g. `"2026-08-"`). Available on `realtime_domain_risk`, `domainhotlist`, `iphotlist`, and `iprisk`.
347+
348+
```python
349+
api = API(USERNAME, KEY)
350+
api.iphotlist(endpoint="download", limit=10, page=0, prefix="2026-08-")
351+
```
329352

330-
## Handling iterative response from RTUF endpoints:
353+
## Handling iterative response from RTTF endpoints:
331354

332-
Since we may dealing with large feeds datasets, the python wrapper uses `generator` for efficient memory handling. Therefore, we need to iterate through the `generator` if we're accessing the partial results of the feeds data.
355+
Since we may be dealing with large feeds datasets, the python wrapper uses `generator` for efficient memory handling. Therefore, we need to iterate through the `generator` if we're accessing the partial results of the feeds data.
333356

334357
### Single request because the requested data is within the maximum result:
335358
```python

domaintools/api.py

Lines changed: 98 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,8 @@ def _handle_api_key_parameters(self, is_rttf_product):
199199
self.header_authentication = is_rttf_product
200200

201201
def handle_api_key(self, is_rttf_product, path, parameters):
202+
if self.header_authentication and not self.always_sign_api_key:
203+
return
202204
if self.https and not self.always_sign_api_key:
203205
parameters["api_key"] = self.key
204206
else:
@@ -1204,11 +1206,16 @@ def nod(self, **kwargs) -> FeedsResults:
12041206
validate_feeds_parameters(kwargs)
12051207
endpoint = kwargs.pop("endpoint", Endpoint.FEED.value)
12061208
source = ENDPOINT_TO_SOURCE_MAP.get(endpoint)
1207-
if (
1208-
endpoint == Endpoint.DOWNLOAD.value
1209-
or kwargs.get("output_format", OutputFormat.JSONL.value) != OutputFormat.CSV.value
1210-
):
1211-
# headers param is allowed only in Feed API and CSV format
1209+
1210+
if endpoint == Endpoint.DOWNLOAD.value:
1211+
return self._results(
1212+
f"newly-observed-domains-feed-({source.value})",
1213+
f"v1/{endpoint}/nod/",
1214+
response_path=("response",),
1215+
limit=kwargs.get("limit"),
1216+
)
1217+
1218+
if kwargs.get("output_format", OutputFormat.JSONL.value) != OutputFormat.CSV.value:
12121219
kwargs.pop("headers", None)
12131220

12141221
return self._results(
@@ -1247,11 +1254,16 @@ def nad(self, **kwargs) -> FeedsResults:
12471254
validate_feeds_parameters(kwargs)
12481255
endpoint = kwargs.pop("endpoint", Endpoint.FEED.value)
12491256
source = ENDPOINT_TO_SOURCE_MAP.get(endpoint).value
1250-
if (
1251-
endpoint == Endpoint.DOWNLOAD.value
1252-
or kwargs.get("output_format", OutputFormat.JSONL.value) != OutputFormat.CSV.value
1253-
):
1254-
# headers param is allowed only in Feed API and CSV format
1257+
1258+
if endpoint == Endpoint.DOWNLOAD.value:
1259+
return self._results(
1260+
f"newly-active-domains-feed-({source})",
1261+
f"v1/{endpoint}/nad/",
1262+
response_path=("response",),
1263+
limit=kwargs.get("limit"),
1264+
)
1265+
1266+
if kwargs.get("output_format", OutputFormat.JSONL.value) != OutputFormat.CSV.value:
12551267
kwargs.pop("headers", None)
12561268

12571269
return self._results(
@@ -1291,6 +1303,14 @@ def domainrdap(self, **kwargs) -> FeedsResults:
12911303
endpoint = kwargs.pop("endpoint", Endpoint.FEED.value)
12921304
source = ENDPOINT_TO_SOURCE_MAP.get(endpoint).value
12931305

1306+
if endpoint == Endpoint.DOWNLOAD.value:
1307+
return self._results(
1308+
f"domain-registration-data-access-protocol-feed-({source})",
1309+
f"v1/{endpoint}/domainrdap/",
1310+
response_path=("response",),
1311+
limit=kwargs.get("limit"),
1312+
)
1313+
12941314
return self._results(
12951315
f"domain-registration-data-access-protocol-feed-({source})",
12961316
f"v1/{endpoint}/domainrdap/",
@@ -1327,11 +1347,16 @@ def domaindiscovery(self, **kwargs) -> FeedsResults:
13271347
validate_feeds_parameters(kwargs)
13281348
endpoint = kwargs.pop("endpoint", Endpoint.FEED.value)
13291349
source = ENDPOINT_TO_SOURCE_MAP.get(endpoint).value
1330-
if (
1331-
endpoint == Endpoint.DOWNLOAD.value
1332-
or kwargs.get("output_format", OutputFormat.JSONL.value) != OutputFormat.CSV.value
1333-
):
1334-
# headers param is allowed only in Feed API and CSV format
1350+
1351+
if endpoint == Endpoint.DOWNLOAD.value:
1352+
return self._results(
1353+
f"real-time-domain-discovery-feed-({source})",
1354+
f"v1/{endpoint}/domaindiscovery/",
1355+
response_path=("response",),
1356+
limit=kwargs.get("limit"),
1357+
)
1358+
1359+
if kwargs.get("output_format", OutputFormat.JSONL.value) != OutputFormat.CSV.value:
13351360
kwargs.pop("headers", None)
13361361

13371362
return self._results(
@@ -1370,11 +1395,16 @@ def noh(self, **kwargs) -> FeedsResults:
13701395
validate_feeds_parameters(kwargs)
13711396
endpoint = kwargs.pop("endpoint", Endpoint.FEED.value)
13721397
source = ENDPOINT_TO_SOURCE_MAP.get(endpoint).value
1373-
if (
1374-
endpoint == Endpoint.DOWNLOAD.value
1375-
or kwargs.get("output_format", OutputFormat.JSONL.value) != OutputFormat.CSV.value
1376-
):
1377-
# headers param is allowed only in Feed API and CSV format
1398+
1399+
if endpoint == Endpoint.DOWNLOAD.value:
1400+
return self._results(
1401+
f"newly-observed-hosts-feed-({source})",
1402+
f"v1/{endpoint}/noh/",
1403+
response_path=("response",),
1404+
limit=kwargs.get("limit"),
1405+
)
1406+
1407+
if kwargs.get("output_format", OutputFormat.JSONL.value) != OutputFormat.CSV.value:
13781408
kwargs.pop("headers", None)
13791409

13801410
return self._results(
@@ -1422,11 +1452,18 @@ def realtime_domain_risk(self, **kwargs) -> FeedsResults:
14221452
validate_feeds_parameters(kwargs)
14231453
endpoint = kwargs.pop("endpoint", Endpoint.FEED.value)
14241454
source = ENDPOINT_TO_SOURCE_MAP.get(endpoint).value
1425-
if (
1426-
endpoint == Endpoint.DOWNLOAD.value
1427-
or kwargs.get("output_format", OutputFormat.JSONL.value) != OutputFormat.CSV.value
1428-
):
1429-
# headers param is allowed only in Feed API and CSV format
1455+
1456+
if endpoint == Endpoint.DOWNLOAD.value:
1457+
return self._results(
1458+
f"real-time-domain-risk-({source})",
1459+
f"v1/{endpoint}/domainrisk/",
1460+
response_path=("response",),
1461+
limit=kwargs.get("limit"),
1462+
page=kwargs.get("page"),
1463+
prefix=kwargs.get("prefix"),
1464+
)
1465+
1466+
if kwargs.get("output_format", OutputFormat.JSONL.value) != OutputFormat.CSV.value:
14301467
kwargs.pop("headers", None)
14311468

14321469
return self._results(
@@ -1474,11 +1511,18 @@ def domainhotlist(self, **kwargs) -> FeedsResults:
14741511
validate_feeds_parameters(kwargs)
14751512
endpoint = kwargs.pop("endpoint", Endpoint.FEED.value)
14761513
source = ENDPOINT_TO_SOURCE_MAP.get(endpoint).value
1477-
if (
1478-
endpoint == Endpoint.DOWNLOAD.value
1479-
or kwargs.get("output_format", OutputFormat.JSONL.value) != OutputFormat.CSV.value
1480-
):
1481-
# headers param is allowed only in Feed API and CSV format
1514+
1515+
if endpoint == Endpoint.DOWNLOAD.value:
1516+
return self._results(
1517+
f"real-time-domain-hotlist-({source})",
1518+
f"v1/{endpoint}/domainhotlist/",
1519+
response_path=("response",),
1520+
limit=kwargs.get("limit"),
1521+
page=kwargs.get("page"),
1522+
prefix=kwargs.get("prefix"),
1523+
)
1524+
1525+
if kwargs.get("output_format", OutputFormat.JSONL.value) != OutputFormat.CSV.value:
14821526
kwargs.pop("headers", None)
14831527

14841528
return self._results(
@@ -1544,11 +1588,18 @@ def iphotlist(self, **kwargs) -> FeedsResults:
15441588
validate_feeds_parameters(kwargs)
15451589
endpoint = kwargs.pop("endpoint", Endpoint.FEED.value)
15461590
source = ENDPOINT_TO_SOURCE_MAP.get(endpoint).value
1547-
if (
1548-
endpoint == Endpoint.DOWNLOAD.value
1549-
or kwargs.get("output_format", OutputFormat.JSONL.value) != OutputFormat.CSV.value
1550-
):
1551-
# headers param is allowed only in Feed API and CSV format
1591+
1592+
if endpoint == Endpoint.DOWNLOAD.value:
1593+
return self._results(
1594+
f"real-time-ip-hotlist-({source})",
1595+
f"v1/{endpoint}/iphotlist/",
1596+
response_path=("response",),
1597+
limit=kwargs.get("limit"),
1598+
page=kwargs.get("page"),
1599+
prefix=kwargs.get("prefix"),
1600+
)
1601+
1602+
if kwargs.get("output_format", OutputFormat.JSONL.value) != OutputFormat.CSV.value:
15521603
kwargs.pop("headers", None)
15531604

15541605
return self._results(
@@ -1614,11 +1665,18 @@ def iprisk(self, **kwargs) -> FeedsResults:
16141665
validate_feeds_parameters(kwargs)
16151666
endpoint = kwargs.pop("endpoint", Endpoint.FEED.value)
16161667
source = ENDPOINT_TO_SOURCE_MAP.get(endpoint).value
1617-
if (
1618-
endpoint == Endpoint.DOWNLOAD.value
1619-
or kwargs.get("output_format", OutputFormat.JSONL.value) != OutputFormat.CSV.value
1620-
):
1621-
# headers param is allowed only in Feed API and CSV format
1668+
1669+
if endpoint == Endpoint.DOWNLOAD.value:
1670+
return self._results(
1671+
f"real-time-ip-risk-({source})",
1672+
f"v1/{endpoint}/iprisk/",
1673+
response_path=("response",),
1674+
limit=kwargs.get("limit"),
1675+
page=kwargs.get("page"),
1676+
prefix=kwargs.get("prefix"),
1677+
)
1678+
1679+
if kwargs.get("output_format", OutputFormat.JSONL.value) != OutputFormat.CSV.value:
16221680
kwargs.pop("headers", None)
16231681

16241682
return self._results(

domaintools/cli/api.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from domaintools.api import API
1212
from domaintools.constants import Endpoint, RTTF_PRODUCTS_LIST, OutputFormat
13+
from domaintools.results import FeedsResults
1314
from domaintools.cli.utils import get_file_extension
1415
from domaintools.exceptions import ServiceException
1516
from domaintools._version import current as version
@@ -114,8 +115,10 @@ def args_to_dict(*args) -> Dict:
114115
def _get_formatted_output(cls, cmd_name: str, response, out_format: str = "json"):
115116
if cmd_name in ("available_api_calls",):
116117
return "\n".join(response)
117-
if response.product in RTTF_PRODUCTS_LIST:
118-
pass # do nothing
118+
if isinstance(response, FeedsResults):
119+
pass # do nothing — streaming output handled in run()
120+
elif out_format not in ("json", "xml", "html", "list"):
121+
out_format = "json" # download endpoint returns standard JSON
119122
return str(getattr(response, out_format) if out_format != "list" else response.as_list())
120123

121124
@classmethod
@@ -225,20 +228,22 @@ def run(cls, name: str, params: Optional[Dict] = {}, **kwargs):
225228
params = params | kwargs
226229

227230
response = dt_api_func(**params)
231+
if not isinstance(response, FeedsResults):
232+
response_format = "json"
228233
progress.update(
229234
task_id,
230235
description=f"Preparing results with format of {response_format}...",
231236
)
232237

233-
if name not in ("available_api_calls",) and not getattr(response, "product", None) in RTTF_PRODUCTS_LIST:
238+
if name not in ("available_api_calls",) and not isinstance(response, FeedsResults):
234239
response.data()
235240

236241
output = cls._get_formatted_output(
237242
cmd_name=name, response=response, out_format=response_format
238243
)
239244

240245
if isinstance(out_file, _io.TextIOWrapper):
241-
if name not in ("available_api_calls",) and response.product in RTTF_PRODUCTS_LIST:
246+
if name not in ("available_api_calls",) and isinstance(response, FeedsResults):
242247
for feeds in response.response():
243248
print(feeds)
244249
else:

0 commit comments

Comments
 (0)