domaintools-api-test-upload 2.6.2__tar.gz → 2.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. {domaintools_api_test_upload-2.6.2/domaintools_api_test_upload.egg-info → domaintools_api_test_upload-2.7.0}/PKG-INFO +6 -2
  2. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/README.md +4 -0
  3. domaintools_api_test_upload-2.7.0/VERSION +1 -0
  4. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools/_version.py +1 -1
  5. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools/api.py +76 -48
  6. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools/base_results.py +4 -3
  7. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools/cli/api.py +1 -2
  8. domaintools_api_test_upload-2.7.0/domaintools/constants.py +63 -0
  9. domaintools_api_test_upload-2.7.0/domaintools/decorators.py +99 -0
  10. domaintools_api_test_upload-2.7.0/domaintools/docstring_patcher.py +345 -0
  11. domaintools_api_test_upload-2.7.0/domaintools/request_validator.py +118 -0
  12. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools/results.py +36 -57
  13. domaintools_api_test_upload-2.7.0/domaintools/specs/iris-openapi.yaml +2741 -0
  14. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools/utils.py +5 -3
  15. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0/domaintools_api_test_upload.egg-info}/PKG-INFO +6 -2
  16. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools_api_test_upload.egg-info/SOURCES.txt +7 -0
  17. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/pyproject.toml +5 -1
  18. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/setup.py +1 -1
  19. domaintools_api_test_upload-2.7.0/tests/test_decorators.py +219 -0
  20. domaintools_api_test_upload-2.7.0/tests/test_docstring_patcher.py +351 -0
  21. domaintools_api_test_upload-2.7.0/tests/test_request_validator.py +234 -0
  22. domaintools_api_test_upload-2.6.2/VERSION +0 -1
  23. domaintools_api_test_upload-2.6.2/domaintools/constants.py +0 -41
  24. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/LICENSE +0 -0
  25. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools/__init__.py +0 -0
  26. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools/cli/__init__.py +0 -0
  27. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools/cli/commands/__init__.py +0 -0
  28. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools/cli/commands/accounts.py +0 -0
  29. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools/cli/commands/detects.py +0 -0
  30. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools/cli/commands/domains.py +0 -0
  31. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools/cli/commands/feeds.py +0 -0
  32. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools/cli/commands/ips.py +0 -0
  33. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools/cli/commands/iris.py +0 -0
  34. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools/cli/constants.py +0 -0
  35. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools/cli/main.py +0 -0
  36. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools/cli/utils.py +0 -0
  37. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools/exceptions.py +0 -0
  38. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools/filters.py +0 -0
  39. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools_api_test_upload.egg-info/dependency_links.txt +0 -0
  40. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools_api_test_upload.egg-info/entry_points.txt +0 -0
  41. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools_api_test_upload.egg-info/requires.txt +0 -0
  42. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools_api_test_upload.egg-info/top_level.txt +0 -0
  43. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/domaintools_async/__init__.py +0 -0
  44. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/setup.cfg +0 -0
  45. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/tests/test_api.py +0 -0
  46. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/tests/test_async.py +0 -0
  47. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/tests/test_cli.py +0 -0
  48. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/tests/test_filters.py +0 -0
  49. {domaintools_api_test_upload-2.6.2 → domaintools_api_test_upload-2.7.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
- Name: domaintools-api-test-upload
3
- Version: 2.6.2
2
+ Name: domaintools_api_test_upload
3
+ Version: 2.7.0
4
4
  Summary: DomainTools Official Python API
5
5
  Author-email: DomainTools <integrations@domaintools.com>
6
6
  License: The MIT License (MIT)
@@ -354,6 +354,10 @@ To add more e2e tests, put these in the `../tests/e2e` folder.
354
354
  export TEST_USER=<user-key>
355
355
  export TEST_KEY=<api-key>
356
356
  ```
357
+ - Run unit tests.
358
+ ```bash
359
+ tox -e
360
+ ```
357
361
 
358
362
  ## Run the end-to-end test script
359
363
  - Before running the test, be sure that docker is running.
@@ -304,6 +304,10 @@ To add more e2e tests, put these in the `../tests/e2e` folder.
304
304
  export TEST_USER=<user-key>
305
305
  export TEST_KEY=<api-key>
306
306
  ```
307
+ - Run unit tests.
308
+ ```bash
309
+ tox -e
310
+ ```
307
311
 
308
312
  ## Run the end-to-end test script
309
313
  - Before running the test, be sure that docker is running.
@@ -0,0 +1 @@
1
+ 2.7.0
@@ -20,4 +20,4 @@ OTHER DEALINGS IN THE SOFTWARE.
20
20
 
21
21
  """
22
22
 
23
- current = "2.6.2"
23
+ current = "2.7.0"
@@ -5,8 +5,16 @@ from typing import Union
5
5
 
6
6
  import re
7
7
  import ssl
8
-
9
- from domaintools.constants import Endpoint, ENDPOINT_TO_SOURCE_MAP, RTTF_PRODUCTS_LIST, OutputFormat
8
+ import yaml
9
+
10
+ from domaintools.constants import (
11
+ Endpoint,
12
+ OutputFormat,
13
+ ENDPOINT_TO_SOURCE_MAP,
14
+ RTTF_PRODUCTS_LIST,
15
+ RTTF_PRODUCTS_CMD_MAPPING,
16
+ SPECS_MAPPING,
17
+ )
10
18
  from domaintools._version import current as version
11
19
  from domaintools.results import (
12
20
  GroupedIterable,
@@ -16,6 +24,7 @@ from domaintools.results import (
16
24
  Results,
17
25
  FeedsResults,
18
26
  )
27
+ from domaintools.decorators import api_endpoint, auto_patch_docstrings
19
28
  from domaintools.filters import (
20
29
  filter_by_riskscore,
21
30
  filter_by_expire_date,
@@ -34,6 +43,7 @@ def delimited(items, character="|"):
34
43
  return character.join(items) if type(items) in (list, tuple, set) else items
35
44
 
36
45
 
46
+ @auto_patch_docstrings
37
47
  class API(object):
38
48
  """Enables interacting with the DomainTools API via Python:
39
49
 
@@ -88,8 +98,10 @@ class API(object):
88
98
  self.key_sign_hash = key_sign_hash
89
99
  self.default_parameters["app_name"] = app_name
90
100
  self.default_parameters["app_version"] = app_version
101
+ self.specs = {}
91
102
 
92
103
  self._build_api_url(api_url, api_port)
104
+ self._initialize_specs()
93
105
 
94
106
  if not https:
95
107
  raise Exception(
@@ -98,8 +110,25 @@ class API(object):
98
110
  if proxy_url and not isinstance(proxy_url, str):
99
111
  raise Exception("Proxy URL must be a string. For example: '127.0.0.1:8888'")
100
112
 
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
+
101
126
  def _get_ssl_default_context(self, verify_ssl: Union[str, bool]):
102
- 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
+ )
103
132
 
104
133
  def _build_api_url(self, api_url=None, api_port=None):
105
134
  """Build the API url based on the given url and port. Defaults to `https://api.domaintools.com`"""
@@ -127,11 +156,18 @@ class API(object):
127
156
  hours = limit_hours and 3600 / float(limit_hours)
128
157
  minutes = limit_minutes and 60 / float(limit_minutes)
129
158
 
130
- 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
+ }
131
162
 
132
163
  def _results(self, product, path, cls=Results, **kwargs):
133
164
  """Returns _results for the specified API path with the specified **kwargs parameters"""
134
- 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
+ ):
135
171
  always_sign_api_key_previous_value = self.always_sign_api_key
136
172
  header_authentication_previous_value = self.header_authentication
137
173
  self._rate_limit(product)
@@ -175,7 +211,9 @@ class API(object):
175
211
  else:
176
212
  raise ValueError(
177
213
  "Invalid value '{0}' for 'key_sign_hash'. "
178
- "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
+ )
179
217
  )
180
218
 
181
219
  parameters["timestamp"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -187,7 +225,9 @@ class API(object):
187
225
 
188
226
  def account_information(self, **kwargs):
189
227
  """Provides a snapshot of your accounts current API usage"""
190
- 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
+ )
191
231
 
192
232
  def available_api_calls(self):
193
233
  """Provides a list of api calls that you can use based on your account information."""
@@ -209,13 +249,23 @@ class API(object):
209
249
  if not api_call.startswith("_") and callable(getattr(API, api_call, None))
210
250
  )
211
251
  )
212
- return sorted(
213
- [
214
- snakecase(p["id"])
215
- for p in self.account_information()["products"]
216
- if snakecase(p["id"]) in api_calls
217
- ]
218
- )
252
+
253
+ account_information = self.account_information()
254
+
255
+ available_calls = set()
256
+ for product in self.account_information():
257
+ product_id = product["id"]
258
+ # for RTUF endpoints as we use different func name in our wrapper
259
+ if product_id in RTTF_PRODUCTS_LIST:
260
+ if rttf_api_command := RTTF_PRODUCTS_CMD_MAPPING.get(product_id):
261
+ available_calls.add(rttf_api_command)
262
+
263
+ # for IRIS endpoints
264
+ snakecase_pid = snakecase(product_id)
265
+ if snakecase_pid in api_calls:
266
+ available_calls.add(snakecase_pid)
267
+
268
+ return sorted(available_calls)
219
269
 
220
270
  def brand_monitor(self, query, exclude=None, domain_status=None, days_back=None, **kwargs):
221
271
  """Pass in one or more terms as a list or separated by the pipe character ( | )"""
@@ -380,7 +430,9 @@ class API(object):
380
430
 
381
431
  def reverse_ip(self, domain=None, limit=None, **kwargs):
382
432
  """Pass in a domain name."""
383
- 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
+ )
384
436
 
385
437
  def host_domains(self, ip=None, limit=None, **kwargs):
386
438
  """Pass in an IP address."""
@@ -554,8 +606,12 @@ class API(object):
554
606
  younger_than_date = kwargs.pop("younger_than_date", {}) or None
555
607
  older_than_date = kwargs.pop("older_than_date", {}) or None
556
608
  updated_after = kwargs.pop("updated_after", {}) or None
557
- include_domains_with_missing_field = kwargs.pop("include_domains_with_missing_field", {}) or None
558
- 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
+ )
559
615
 
560
616
  filtered_results = DTResultFilter(result_set=results).by(
561
617
  [
@@ -608,13 +664,13 @@ class API(object):
608
664
  **kwargs,
609
665
  )
610
666
 
667
+ @api_endpoint(spec_name="iris", path="/v1/iris-investigate/", methods="post")
611
668
  def iris_investigate(
612
669
  self,
613
670
  domains=None,
614
671
  data_updated_after=None,
615
672
  expiration_date=None,
616
673
  create_date=None,
617
- active=None,
618
674
  search_hash=None,
619
675
  risk_score=None,
620
676
  younger_than_date=None,
@@ -625,29 +681,6 @@ class API(object):
625
681
  **kwargs,
626
682
  ):
627
683
  """Returns back a list of domains based on the provided filters.
628
- The following filters are available beyond what is parameterized as kwargs:
629
-
630
- - ip: Search for domains having this IP.
631
- - email: Search for domains with this email in their data.
632
- - email_domain: Search for domains where the email address uses this domain.
633
- - nameserver_host: Search for domains with this nameserver.
634
- - nameserver_domain: Search for domains with a nameserver that has this domain.
635
- - nameserver_ip: Search for domains with a nameserver on this IP.
636
- - registrar: Search for domains with this registrar.
637
- - registrant: Search for domains with this registrant name.
638
- - registrant_org: Search for domains with this registrant organization.
639
- - mailserver_host: Search for domains with this mailserver.
640
- - mailserver_domain: Search for domains with a mailserver that has this domain.
641
- - mailserver_ip: Search for domains with a mailserver on this IP.
642
- - redirect_domain: Search for domains which redirect to this domain.
643
- - ssl_hash: Search for domains which have an SSL certificate with this hash.
644
- - ssl_subject: Search for domains which have an SSL certificate with this subject string.
645
- - ssl_email: Search for domains which have an SSL certificate with this email in it.
646
- - ssl_org: Search for domains which have an SSL certificate with this organization in it.
647
- - google_analytics: Search for domains which have this Google Analytics code.
648
- - adsense: Search for domains which have this AdSense code.
649
- - tld: Filter by TLD. Must be combined with another parameter.
650
- - search_hash: Use search hash from Iris to bring back domains.
651
684
 
652
685
  You can loop over results of your investigation as if it was a native Python list:
653
686
 
@@ -667,9 +700,6 @@ class API(object):
667
700
  if search_hash:
668
701
  kwargs["search_hash"] = search_hash
669
702
 
670
- if not (kwargs or domains):
671
- raise ValueError("Need to define investigation using kwarg filters or domains")
672
-
673
703
  if isinstance(domains, (list, tuple)):
674
704
  domains = ",".join(domains)
675
705
  if hasattr(data_updated_after, "strftime"):
@@ -678,8 +708,6 @@ class API(object):
678
708
  expiration_date = expiration_date.strftime("%Y-%m-%d")
679
709
  if hasattr(create_date, "strftime"):
680
710
  create_date = create_date.strftime("%Y-%m-%d")
681
- if isinstance(active, bool):
682
- kwargs["active"] = str(active).lower()
683
711
 
684
712
  results = self._results(
685
713
  "iris-investigate",
@@ -1279,7 +1307,7 @@ class API(object):
1279
1307
  kwargs.pop("headers", None)
1280
1308
 
1281
1309
  return self._results(
1282
- f"domain-risk-feed-({source})",
1310
+ f"real-time-domain-risk-({source})",
1283
1311
  f"v1/{endpoint}/domainrisk/",
1284
1312
  response_path=(),
1285
1313
  cls=FeedsResults,
@@ -1313,7 +1341,7 @@ class API(object):
1313
1341
  kwargs.pop("headers", None)
1314
1342
 
1315
1343
  return self._results(
1316
- f"domain-hotlist-feed-({source})",
1344
+ f"real-time-domain-hotlist-({source})",
1317
1345
  f"v1/{endpoint}/domainhotlist/",
1318
1346
  response_path=(),
1319
1347
  cls=FeedsResults,
@@ -94,8 +94,7 @@ class Results(MutableMapping, MutableSequence):
94
94
  headers["accept"] = HEADER_ACCEPT_KEY_CSV_FORMAT
95
95
 
96
96
  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
99
98
 
100
99
  session_param_and_headers = {"parameters": parameters, "headers": headers}
101
100
  return session_param_and_headers
@@ -342,7 +341,9 @@ class Results(MutableMapping, MutableSequence):
342
341
  )
343
342
 
344
343
  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
+ )
346
347
 
347
348
  def __str__(self):
348
349
  return str(
@@ -222,7 +222,6 @@ class DTCLICommand:
222
222
  header_authentication=header_authentication,
223
223
  )
224
224
  dt_api_func = getattr(dt_api, name)
225
-
226
225
  params = params | kwargs
227
226
 
228
227
  response = dt_api_func(**params)
@@ -241,7 +240,7 @@ class DTCLICommand:
241
240
  description=f"Printing the results with format of {response_format}...",
242
241
  )
243
242
  # use rich `print` command to prettify the ouput in sys.stdout
244
- if response.product in RTTF_PRODUCTS_LIST:
243
+ if name not in ("available_api_calls",) and response.product in RTTF_PRODUCTS_LIST:
245
244
  for feeds in response.response():
246
245
  print(feeds)
247
246
  else:
@@ -0,0 +1,63 @@
1
+ from enum import Enum
2
+
3
+
4
+ class Endpoint(Enum):
5
+ FEED = "feed"
6
+ DOWNLOAD = "download"
7
+
8
+
9
+ class Source(Enum):
10
+ API = "api"
11
+ S3 = "s3"
12
+
13
+
14
+ class OutputFormat(Enum):
15
+ JSONL = "jsonl"
16
+ CSV = "csv"
17
+
18
+
19
+ HEADER_ACCEPT_KEY_CSV_FORMAT = "text/csv"
20
+
21
+ ENDPOINT_TO_SOURCE_MAP = {
22
+ Endpoint.FEED.value: Source.API,
23
+ Endpoint.DOWNLOAD.value: Source.S3,
24
+ }
25
+
26
+ RTTF_PRODUCTS_LIST = [
27
+ "newly-active-domains-feed-(api)",
28
+ "newly-active-domains-feed-(s3)",
29
+ "newly-observed-domains-feed-(api)",
30
+ "newly-observed-domains-feed-(s3)",
31
+ "newly-observed-hosts-feed-(api)",
32
+ "newly-observed-hosts-feed-(s3)",
33
+ "real-time-domain-hotlist-(api)",
34
+ "real-time-domain-hotlist-(s3)",
35
+ "domain-registration-data-access-protocol-feed-(api)",
36
+ "domain-registration-data-access-protocol-feed-(s3)",
37
+ "real-time-domain-risk-(api)",
38
+ "real-time-domain-risk-(s3)",
39
+ "real-time-domain-discovery-feed-(api)",
40
+ "real-time-domain-discovery-feed-(s3)",
41
+ ]
42
+
43
+ RTTF_PRODUCTS_CMD_MAPPING = {
44
+ "newly-active-domains-feed-(api)": "nad",
45
+ "newly-active-domains-feed-(s3)": "nad",
46
+ "newly-observed-domains-feed-(api)": "nod",
47
+ "newly-observed-domains-feed-(s3)": "nod",
48
+ "newly-observed-hosts-feed-(api)": "noh",
49
+ "newly-observed-hosts-feed-(s3)": "noh",
50
+ "real-time-domain-hotlist-(api)": "domainhotlist",
51
+ "real-time-domain-hotlist-(s3)": "domainhotlist",
52
+ "domain-registration-data-access-protocol-feed-(api)": "domainrdap",
53
+ "domain-registration-data-access-protocol-feed-(s3)": "domainrdap",
54
+ "real-time-domain-risk-(api)": "realtime_domain_risk",
55
+ "real-time-domain-risk-(s3)": "realtime_domain_risk",
56
+ "real-time-domain-discovery-feed-(api)": "domaindiscovery",
57
+ "real-time-domain-discovery-feed-(s3)": "domaindiscovery",
58
+ }
59
+
60
+ SPECS_MAPPING = {
61
+ "iris": "domaintools/specs/iris-openapi.yaml",
62
+ # "rttf": "domaintools/specs/feeds-openapi.yaml",
63
+ }
@@ -0,0 +1,99 @@
1
+ import functools
2
+ import inspect
3
+
4
+ from typing import List, Union
5
+
6
+ from domaintools.docstring_patcher import DocstringPatcher
7
+ from domaintools.request_validator import RequestValidator
8
+
9
+
10
+ def api_endpoint(spec_name: str, path: str, methods: Union[str, List[str]]):
11
+ """
12
+ Decorator to tag a method as an API endpoint AND validate inputs.
13
+
14
+ Args:
15
+ spec_name: The key for the spec in api_instance.specs
16
+ path: The API path (e.g., "/users")
17
+ methods: A single method ("get") or list of methods (["get", "post"])
18
+ """
19
+
20
+ def decorator(func):
21
+ func._api_spec_name = spec_name
22
+ func._api_path = path
23
+
24
+ # Normalize methods to a list
25
+ normalized_methods = [methods] if isinstance(methods, str) else methods
26
+ func._api_methods = normalized_methods
27
+
28
+ # Get the signature of the original function ONCE
29
+ sig = inspect.signature(func)
30
+
31
+ @functools.wraps(func)
32
+ def wrapper(self, *args, **kwargs):
33
+
34
+ try:
35
+ bound_args = sig.bind(*args, **kwargs)
36
+ except TypeError:
37
+ # If arguments don't match signature, let the actual func raise the error
38
+ return func(*args, **kwargs)
39
+
40
+ arguments = bound_args.arguments
41
+
42
+ # Robustly find 'self' (it's usually the first argument in bound_args)
43
+ # We look for the first value in arguments, or try to get 'self' explicitly.
44
+ instance = arguments.pop("self", None)
45
+ if not instance and args:
46
+ instance = args[0]
47
+
48
+ # Retrieve the Spec from the instance
49
+ # We assume 'self' has a .specs attribute (like DocstringPatcher expects)
50
+ spec = getattr(self, "specs", {}).get(spec_name)
51
+
52
+ if spec:
53
+ # Determine which HTTP method is currently being executed.
54
+ # If the function allows dynamic methods (e.g. method="POST"), use that.
55
+ # Otherwise, default to the first method defined in the decorator.
56
+ current_method = kwargs.get("method", normalized_methods[0])
57
+
58
+ # Run Validation
59
+ # This will raise a ValueError and stop execution if validation fails.
60
+ try:
61
+ RequestValidator.validate(
62
+ spec=spec,
63
+ path=path,
64
+ method=current_method,
65
+ parameters=arguments,
66
+ )
67
+ except ValueError as e:
68
+ print(f"[Validation Error] {e}")
69
+ raise e
70
+
71
+ # Proceed with the original function call
72
+ return func(*args, **kwargs)
73
+
74
+ # Copy tags to wrapper for the DocstringPatcher to find
75
+ wrapper._api_spec_name = func._api_spec_name
76
+ wrapper._api_path = func._api_path
77
+ wrapper._api_methods = func._api_methods
78
+
79
+ return wrapper
80
+
81
+ return decorator
82
+
83
+
84
+ def auto_patch_docstrings(cls):
85
+ original_init = cls.__init__
86
+
87
+ @functools.wraps(original_init)
88
+ def new_init(self, *args, **kwargs):
89
+ original_init(self, *args, **kwargs)
90
+ try:
91
+ # We instantiate our patcher and run it
92
+ patcher = DocstringPatcher()
93
+ patcher.patch(self)
94
+ except Exception as e:
95
+ print(f"Auto-patching failed: {e}")
96
+
97
+ cls.__init__ = new_init
98
+
99
+ return cls