Skip to content

Commit e8181d6

Browse files
authored
Merge pull request #183 from DomainTools/IDEV-2204-iris-investigate-api-parity-updates
IDEV-2204: Iris investigate api parity updates
2 parents bed4a67 + d0346ca commit e8181d6

File tree

8 files changed

+3546
-37
lines changed

8 files changed

+3546
-37
lines changed

domaintools/api.py

Lines changed: 49 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55

66
import re
77
import ssl
8+
import yaml
89

910
from domaintools.constants import (
1011
Endpoint,
1112
OutputFormat,
1213
ENDPOINT_TO_SOURCE_MAP,
1314
RTTF_PRODUCTS_LIST,
1415
RTTF_PRODUCTS_CMD_MAPPING,
16+
SPECS_MAPPING,
1517
)
1618
from domaintools._version import current as version
1719
from domaintools.results import (
@@ -22,6 +24,7 @@
2224
Results,
2325
FeedsResults,
2426
)
27+
from domaintools.decorators import api_endpoint, auto_patch_docstrings
2528
from domaintools.filters import (
2629
filter_by_riskscore,
2730
filter_by_expire_date,
@@ -40,6 +43,7 @@ def delimited(items, character="|"):
4043
return character.join(items) if type(items) in (list, tuple, set) else items
4144

4245

46+
@auto_patch_docstrings
4347
class API(object):
4448
"""Enables interacting with the DomainTools API via Python:
4549
@@ -94,8 +98,10 @@ def __init__(
9498
self.key_sign_hash = key_sign_hash
9599
self.default_parameters["app_name"] = app_name
96100
self.default_parameters["app_version"] = app_version
101+
self.specs = {}
97102

98103
self._build_api_url(api_url, api_port)
104+
self._initialize_specs()
99105

100106
if not https:
101107
raise Exception(
@@ -104,8 +110,25 @@ def __init__(
104110
if proxy_url and not isinstance(proxy_url, str):
105111
raise Exception("Proxy URL must be a string. For example: '127.0.0.1:8888'")
106112

113+
def _initialize_specs(self):
114+
for spec_name, file_path in SPECS_MAPPING.items():
115+
try:
116+
with open(file_path, "r", encoding="utf-8") as f:
117+
spec_content = yaml.safe_load(f)
118+
if not spec_content:
119+
raise ValueError("Spec file is empty or invalid.")
120+
121+
self.specs[spec_name] = spec_content
122+
123+
except Exception as e:
124+
print(f"Error loading {file_path}: {e}")
125+
107126
def _get_ssl_default_context(self, verify_ssl: Union[str, bool]):
108-
return ssl.create_default_context(cafile=verify_ssl) if isinstance(verify_ssl, str) else verify_ssl
127+
return (
128+
ssl.create_default_context(cafile=verify_ssl)
129+
if isinstance(verify_ssl, str)
130+
else verify_ssl
131+
)
109132

110133
def _build_api_url(self, api_url=None, api_port=None):
111134
"""Build the API url based on the given url and port. Defaults to `https://api.domaintools.com`"""
@@ -133,11 +156,18 @@ def _rate_limit(self, product):
133156
hours = limit_hours and 3600 / float(limit_hours)
134157
minutes = limit_minutes and 60 / float(limit_minutes)
135158

136-
self.limits[product["id"]] = {"interval": timedelta(seconds=minutes or hours or default)}
159+
self.limits[product["id"]] = {
160+
"interval": timedelta(seconds=minutes or hours or default)
161+
}
137162

138163
def _results(self, product, path, cls=Results, **kwargs):
139164
"""Returns _results for the specified API path with the specified **kwargs parameters"""
140-
if product != "account-information" and self.rate_limit and not self.limits_set and not self.limits:
165+
if (
166+
product != "account-information"
167+
and self.rate_limit
168+
and not self.limits_set
169+
and not self.limits
170+
):
141171
always_sign_api_key_previous_value = self.always_sign_api_key
142172
header_authentication_previous_value = self.header_authentication
143173
self._rate_limit(product)
@@ -181,7 +211,9 @@ def handle_api_key(self, is_rttf_product, path, parameters):
181211
else:
182212
raise ValueError(
183213
"Invalid value '{0}' for 'key_sign_hash'. "
184-
"Values available are {1}".format(self.key_sign_hash, ",".join(AVAILABLE_KEY_SIGN_HASHES))
214+
"Values available are {1}".format(
215+
self.key_sign_hash, ",".join(AVAILABLE_KEY_SIGN_HASHES)
216+
)
185217
)
186218

187219
parameters["timestamp"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -193,7 +225,9 @@ def handle_api_key(self, is_rttf_product, path, parameters):
193225

194226
def account_information(self, **kwargs):
195227
"""Provides a snapshot of your accounts current API usage"""
196-
return self._results("account-information", "/v1/account", items_path=("products",), **kwargs)
228+
return self._results(
229+
"account-information", "/v1/account", items_path=("products",), **kwargs
230+
)
197231

198232
def available_api_calls(self):
199233
"""Provides a list of api calls that you can use based on your account information."""
@@ -396,7 +430,9 @@ def reputation(self, query, include_reasons=False, **kwargs):
396430

397431
def reverse_ip(self, domain=None, limit=None, **kwargs):
398432
"""Pass in a domain name."""
399-
return self._results("reverse-ip", "/v1/{0}/reverse-ip".format(domain), limit=limit, **kwargs)
433+
return self._results(
434+
"reverse-ip", "/v1/{0}/reverse-ip".format(domain), limit=limit, **kwargs
435+
)
400436

401437
def host_domains(self, ip=None, limit=None, **kwargs):
402438
"""Pass in an IP address."""
@@ -570,8 +606,12 @@ def iris_enrich(self, *domains, **kwargs):
570606
younger_than_date = kwargs.pop("younger_than_date", {}) or None
571607
older_than_date = kwargs.pop("older_than_date", {}) or None
572608
updated_after = kwargs.pop("updated_after", {}) or None
573-
include_domains_with_missing_field = kwargs.pop("include_domains_with_missing_field", {}) or None
574-
exclude_domains_with_missing_field = kwargs.pop("exclude_domains_with_missing_field", {}) or None
609+
include_domains_with_missing_field = (
610+
kwargs.pop("include_domains_with_missing_field", {}) or None
611+
)
612+
exclude_domains_with_missing_field = (
613+
kwargs.pop("exclude_domains_with_missing_field", {}) or None
614+
)
575615

576616
filtered_results = DTResultFilter(result_set=results).by(
577617
[
@@ -624,6 +664,7 @@ def iris_enrich_cli(self, domains=None, **kwargs):
624664
**kwargs,
625665
)
626666

667+
@api_endpoint(spec_name="iris", path="/v1/iris-investigate/", methods="post")
627668
def iris_investigate(
628669
self,
629670
domains=None,
@@ -641,29 +682,6 @@ def iris_investigate(
641682
**kwargs,
642683
):
643684
"""Returns back a list of domains based on the provided filters.
644-
The following filters are available beyond what is parameterized as kwargs:
645-
646-
- ip: Search for domains having this IP.
647-
- email: Search for domains with this email in their data.
648-
- email_domain: Search for domains where the email address uses this domain.
649-
- nameserver_host: Search for domains with this nameserver.
650-
- nameserver_domain: Search for domains with a nameserver that has this domain.
651-
- nameserver_ip: Search for domains with a nameserver on this IP.
652-
- registrar: Search for domains with this registrar.
653-
- registrant: Search for domains with this registrant name.
654-
- registrant_org: Search for domains with this registrant organization.
655-
- mailserver_host: Search for domains with this mailserver.
656-
- mailserver_domain: Search for domains with a mailserver that has this domain.
657-
- mailserver_ip: Search for domains with a mailserver on this IP.
658-
- redirect_domain: Search for domains which redirect to this domain.
659-
- ssl_hash: Search for domains which have an SSL certificate with this hash.
660-
- ssl_subject: Search for domains which have an SSL certificate with this subject string.
661-
- ssl_email: Search for domains which have an SSL certificate with this email in it.
662-
- ssl_org: Search for domains which have an SSL certificate with this organization in it.
663-
- google_analytics: Search for domains which have this Google Analytics code.
664-
- adsense: Search for domains which have this AdSense code.
665-
- tld: Filter by TLD. Must be combined with another parameter.
666-
- search_hash: Use search hash from Iris to bring back domains.
667685
668686
You can loop over results of your investigation as if it was a native Python list:
669687

domaintools/base_results.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,7 @@ def _get_session_params_and_headers(self):
9494
headers["accept"] = HEADER_ACCEPT_KEY_CSV_FORMAT
9595

9696
if self.api.header_authentication:
97-
header_key_for_api_key = "X-Api-Key" if is_rttf_product else "X-API-Key"
98-
headers[header_key_for_api_key] = self.api.key
97+
headers["X-Api-Key"] = self.api.key
9998

10099
session_param_and_headers = {"parameters": parameters, "headers": headers}
101100
return session_param_and_headers
@@ -342,7 +341,9 @@ def html(self):
342341
)
343342

344343
def as_list(self):
345-
return "\n".join([json.dumps(item, indent=4, separators=(",", ": ")) for item in self._items()])
344+
return "\n".join(
345+
[json.dumps(item, indent=4, separators=(",", ": ")) for item in self._items()]
346+
)
346347

347348
def __str__(self):
348349
return str(

domaintools/constants.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,8 @@ class OutputFormat(Enum):
5656
"real-time-domain-discovery-feed-(api)": "domaindiscovery",
5757
"real-time-domain-discovery-feed-(s3)": "domaindiscovery",
5858
}
59+
60+
SPECS_MAPPING = {
61+
"iris": "domaintools/specs/iris-openapi.yaml",
62+
# "rttf": "domaintools/specs/feeds-openapi.yaml",
63+
}

domaintools/decorators.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import functools
2+
3+
from typing import List, Union
4+
5+
from domaintools.docstring_patcher import DocstringPatcher
6+
7+
8+
def api_endpoint(spec_name: str, path: str, methods: Union[str, List[str]]):
9+
"""
10+
Decorator to tag a method as an API endpoint.
11+
12+
Args:
13+
spec_name: The key for the spec in api_instance.specs
14+
path: The API path (e.g., "/users")
15+
methods: A single method ("get") or list of methods (["get", "post"])
16+
that this function handles.
17+
"""
18+
19+
def decorator(func):
20+
func._api_spec_name = spec_name
21+
func._api_path = path
22+
23+
# Always store the methods as a list
24+
if isinstance(methods, str):
25+
func._api_methods = [methods]
26+
else:
27+
func._api_methods = methods
28+
29+
@functools.wraps(func)
30+
def wrapper(self, *args, **kwargs):
31+
return func(*args, **kwargs)
32+
33+
# Copy all tags to the wrapper
34+
wrapper._api_spec_name = func._api_spec_name
35+
wrapper._api_path = func._api_path
36+
wrapper._api_methods = func._api_methods
37+
return wrapper
38+
39+
return decorator
40+
41+
42+
def auto_patch_docstrings(cls):
43+
original_init = cls.__init__
44+
45+
@functools.wraps(original_init)
46+
def new_init(self, *args, **kwargs):
47+
original_init(self, *args, **kwargs)
48+
try:
49+
# We instantiate our patcher and run it
50+
patcher = DocstringPatcher()
51+
patcher.patch(self)
52+
except Exception as e:
53+
print(f"Auto-patching failed: {e}")
54+
55+
cls.__init__ = new_init
56+
57+
return cls

0 commit comments

Comments
 (0)