From 659d565350055cbd408d22877a81b39b56496612 Mon Sep 17 00:00:00 2001 From: JD Babac Date: Wed, 5 Nov 2025 22:54:18 +0800 Subject: [PATCH 1/8] IDEV-2204: Add DocstringPatcher to patch the specs from DT official techdocs. --- domaintools/api.py | 85 +- domaintools/constants.py | 5 + domaintools/docstring_patcher.py | 164 ++ domaintools/specs/iris-openapi.yaml | 2741 +++++++++++++++++++++++++++ domaintools/utils.py | 46 +- 5 files changed, 3006 insertions(+), 35 deletions(-) create mode 100644 domaintools/docstring_patcher.py create mode 100644 domaintools/specs/iris-openapi.yaml diff --git a/domaintools/api.py b/domaintools/api.py index 51acd5c..aa2e15a 100644 --- a/domaintools/api.py +++ b/domaintools/api.py @@ -5,6 +5,7 @@ import re import ssl +import yaml from domaintools.constants import ( Endpoint, @@ -12,6 +13,7 @@ ENDPOINT_TO_SOURCE_MAP, RTTF_PRODUCTS_LIST, RTTF_PRODUCTS_CMD_MAPPING, + SPECS_MAPPING, ) from domaintools._version import current as version from domaintools.results import ( @@ -29,7 +31,11 @@ filter_by_field, DTResultFilter, ) -from domaintools.utils import validate_feeds_parameters +from domaintools.utils import ( + api_endpoint, + auto_patch_docstrings, + validate_feeds_parameters, +) AVAILABLE_KEY_SIGN_HASHES = ["sha1", "sha256"] @@ -40,6 +46,7 @@ def delimited(items, character="|"): return character.join(items) if type(items) in (list, tuple, set) else items +@auto_patch_docstrings class API(object): """Enables interacting with the DomainTools API via Python: @@ -94,8 +101,10 @@ def __init__( self.key_sign_hash = key_sign_hash self.default_parameters["app_name"] = app_name self.default_parameters["app_version"] = app_version + self.specs = {} self._build_api_url(api_url, api_port) + self._initialize_specs() if not https: raise Exception( @@ -104,8 +113,25 @@ def __init__( if proxy_url and not isinstance(proxy_url, str): raise Exception("Proxy URL must be a string. For example: '127.0.0.1:8888'") + def _initialize_specs(self): + for spec_name, file_path in SPECS_MAPPING.items(): + try: + with open(file_path, "r", encoding="utf-8") as f: + spec_content = yaml.safe_load(f) + if not spec_content: + raise ValueError("Spec file is empty or invalid.") + + self.specs[spec_name] = spec_content + + except Exception as e: + print(f"Error loading {file_path}: {e}") + def _get_ssl_default_context(self, verify_ssl: Union[str, bool]): - return ssl.create_default_context(cafile=verify_ssl) if isinstance(verify_ssl, str) else verify_ssl + return ( + ssl.create_default_context(cafile=verify_ssl) + if isinstance(verify_ssl, str) + else verify_ssl + ) def _build_api_url(self, api_url=None, api_port=None): """Build the API url based on the given url and port. Defaults to `https://api.domaintools.com`""" @@ -133,11 +159,18 @@ def _rate_limit(self, product): hours = limit_hours and 3600 / float(limit_hours) minutes = limit_minutes and 60 / float(limit_minutes) - self.limits[product["id"]] = {"interval": timedelta(seconds=minutes or hours or default)} + self.limits[product["id"]] = { + "interval": timedelta(seconds=minutes or hours or default) + } def _results(self, product, path, cls=Results, **kwargs): """Returns _results for the specified API path with the specified **kwargs parameters""" - if product != "account-information" and self.rate_limit and not self.limits_set and not self.limits: + if ( + product != "account-information" + and self.rate_limit + and not self.limits_set + and not self.limits + ): always_sign_api_key_previous_value = self.always_sign_api_key header_authentication_previous_value = self.header_authentication self._rate_limit(product) @@ -181,7 +214,9 @@ def handle_api_key(self, is_rttf_product, path, parameters): else: raise ValueError( "Invalid value '{0}' for 'key_sign_hash'. " - "Values available are {1}".format(self.key_sign_hash, ",".join(AVAILABLE_KEY_SIGN_HASHES)) + "Values available are {1}".format( + self.key_sign_hash, ",".join(AVAILABLE_KEY_SIGN_HASHES) + ) ) parameters["timestamp"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") @@ -193,7 +228,9 @@ def handle_api_key(self, is_rttf_product, path, parameters): def account_information(self, **kwargs): """Provides a snapshot of your accounts current API usage""" - return self._results("account-information", "/v1/account", items_path=("products",), **kwargs) + return self._results( + "account-information", "/v1/account", items_path=("products",), **kwargs + ) def available_api_calls(self): """Provides a list of api calls that you can use based on your account information.""" @@ -396,7 +433,9 @@ def reputation(self, query, include_reasons=False, **kwargs): def reverse_ip(self, domain=None, limit=None, **kwargs): """Pass in a domain name.""" - return self._results("reverse-ip", "/v1/{0}/reverse-ip".format(domain), limit=limit, **kwargs) + return self._results( + "reverse-ip", "/v1/{0}/reverse-ip".format(domain), limit=limit, **kwargs + ) def host_domains(self, ip=None, limit=None, **kwargs): """Pass in an IP address.""" @@ -570,8 +609,12 @@ def iris_enrich(self, *domains, **kwargs): younger_than_date = kwargs.pop("younger_than_date", {}) or None older_than_date = kwargs.pop("older_than_date", {}) or None updated_after = kwargs.pop("updated_after", {}) or None - include_domains_with_missing_field = kwargs.pop("include_domains_with_missing_field", {}) or None - exclude_domains_with_missing_field = kwargs.pop("exclude_domains_with_missing_field", {}) or None + include_domains_with_missing_field = ( + kwargs.pop("include_domains_with_missing_field", {}) or None + ) + exclude_domains_with_missing_field = ( + kwargs.pop("exclude_domains_with_missing_field", {}) or None + ) filtered_results = DTResultFilter(result_set=results).by( [ @@ -624,6 +667,7 @@ def iris_enrich_cli(self, domains=None, **kwargs): **kwargs, ) + @api_endpoint(spec_name="iris", path="/v1/iris-investigate/") def iris_investigate( self, domains=None, @@ -641,29 +685,6 @@ def iris_investigate( **kwargs, ): """Returns back a list of domains based on the provided filters. - The following filters are available beyond what is parameterized as kwargs: - - - ip: Search for domains having this IP. - - email: Search for domains with this email in their data. - - email_domain: Search for domains where the email address uses this domain. - - nameserver_host: Search for domains with this nameserver. - - nameserver_domain: Search for domains with a nameserver that has this domain. - - nameserver_ip: Search for domains with a nameserver on this IP. - - registrar: Search for domains with this registrar. - - registrant: Search for domains with this registrant name. - - registrant_org: Search for domains with this registrant organization. - - mailserver_host: Search for domains with this mailserver. - - mailserver_domain: Search for domains with a mailserver that has this domain. - - mailserver_ip: Search for domains with a mailserver on this IP. - - redirect_domain: Search for domains which redirect to this domain. - - ssl_hash: Search for domains which have an SSL certificate with this hash. - - ssl_subject: Search for domains which have an SSL certificate with this subject string. - - ssl_email: Search for domains which have an SSL certificate with this email in it. - - ssl_org: Search for domains which have an SSL certificate with this organization in it. - - google_analytics: Search for domains which have this Google Analytics code. - - adsense: Search for domains which have this AdSense code. - - tld: Filter by TLD. Must be combined with another parameter. - - search_hash: Use search hash from Iris to bring back domains. You can loop over results of your investigation as if it was a native Python list: diff --git a/domaintools/constants.py b/domaintools/constants.py index d1dc8b7..b000a26 100644 --- a/domaintools/constants.py +++ b/domaintools/constants.py @@ -56,3 +56,8 @@ class OutputFormat(Enum): "real-time-domain-discovery-feed-(api)": "domaindiscovery", "real-time-domain-discovery-feed-(s3)": "domaindiscovery", } + +SPECS_MAPPING = { + "iris": "domaintools/specs/iris-openapi.yaml", + # "rttf": "domaintools/specs/feeds-openapi.yaml", +} diff --git a/domaintools/docstring_patcher.py b/domaintools/docstring_patcher.py new file mode 100644 index 0000000..2543efd --- /dev/null +++ b/domaintools/docstring_patcher.py @@ -0,0 +1,164 @@ +import inspect +import functools +import textwrap + + +class DocstringPatcher: + """ + Patches docstrings for methods decorated with @api_endpoint. + """ + + def patch(self, api_instance): + method_names = [] + for attr_name in dir(api_instance): + attr = getattr(api_instance, attr_name) + # Look for the new decorator's tags + if ( + inspect.ismethod(attr) + and hasattr(attr, "_api_spec_name") + and hasattr(attr, "_api_path") + ): + method_names.append(attr_name) + + for attr_name in method_names: + original_method = getattr(api_instance, attr_name) + original_function = original_method.__func__ + + spec_name = original_function._api_spec_name + path = original_function._api_path + + spec_to_use = api_instance.specs.get(spec_name) + original_doc = inspect.getdoc(original_function) or "" + + all_doc_sections = [] + if spec_to_use: + path_item = spec_to_use.get("paths", {}).get(path, {}) + + # Loop over all HTTP methods defined for this path + for http_method in ["get", "post", "put", "delete", "patch"]: + if http_method in path_item: + # Generate a doc section for this specific operation + api_doc = self._generate_api_doc_string(spec_to_use, path, http_method) + all_doc_sections.append(api_doc) + + if not all_doc_sections: + all_doc_sections.append( + f"\n--- API Details Error ---" + f"\n (Could not find any operations for path '{path}')" + ) + + # Combine the original doc with all operation docs + new_doc = textwrap.dedent(original_doc) + "\n\n" + "\n\n".join(all_doc_sections) + + @functools.wraps(original_function) + def method_wrapper(*args, _orig_meth=original_method, **kwargs): + return _orig_meth(*args, **kwargs) + + method_wrapper.__doc__ = new_doc + setattr( + api_instance, + attr_name, + method_wrapper.__get__(api_instance, api_instance.__class__), + ) + + def _generate_api_doc_string(self, spec: dict, path: str, method: str) -> str: + """Creates the formatted API docstring section for ONE operation.""" + + details = self._get_operation_details(spec, path, method) + # Add a clear title for this specific method + lines = [f"--- Operation: {method.upper()} {path} ---"] + + # Render Query Params + lines.append(f"\n Summary: {details.get('summary')}") + lines.append(f" Description: {details.get('description')}") + lines.append(f" External Doc: {details.get('external_doc')}") + lines.append("\n Query Parameters:") + if not details["query_params"]: + lines.append(" (No query parameters)") + else: + for param in details["query_params"]: + lines.append(f"\n **{param['name']}** ({param['type']})") + lines.append(f" Required: {param['required']}") + lines.append(f" Description: {param['description']}") + + # Render Request Body + lines.append("\n Request Body:") + if not details["request_body"]: + lines.append(" (No request body)") + else: + body = details["request_body"] + lines.append(f"\n **{body['type']}**") + lines.append(f" Required: {body['required']}") + lines.append(f" Description: {body['description']}") + + return "\n".join(lines) + + def _get_operation_details(self, spec: dict, path: str, method: str) -> dict: + details = {"query_params": [], "request_body": None} + if not spec: + return details + try: + path_item = spec.get("paths", {}).get(path, {}) + operation = path_item.get(method.lower(), {}) + if not operation: + return details + all_param_defs = path_item.get("parameters", []) + operation.get("parameters", []) + details["summary"] = operation.get("summary") + details["description"] = operation.get("description") + details["external_doc"] = operation.get("externalDocs", {}).get("url", "N/A") + resolved_params = [] + for param_def in all_param_defs: + if "$ref" in param_def: + resolved_params.append(self._resolve_ref(spec, param_def["$ref"])) + else: + resolved_params.append(param_def) + for p in [p for p in resolved_params if p.get("in") == "query"]: + details["query_params"].append( + { + "name": p.get("name"), + "required": p.get("required", False), + "description": p.get("description", "N/A"), + "type": self._get_param_type(spec, p.get("schema")), + } + ) + body_def = operation.get("requestBody") + if body_def: + if "$ref" in body_def: + body_def = self._resolve_ref(spec, body_def["$ref"]) + content = body_def.get("content", {}) + media_type = next(iter(content.values()), None) + if media_type and "schema" in media_type: + schema = media_type["schema"] + schema_type = self._get_param_type(spec, schema) + if "$ref" in schema: + schema_type = schema["$ref"].split("/")[-1] + details["request_body"] = { + "required": body_def.get("required", False), + "description": body_def.get("description", "N/A"), + "type": schema_type, + } + return details + except Exception: + return details + + def _resolve_ref(self, spec: dict, ref: str): + if not spec or not ref.startswith("#/"): + return {} + parts = ref.split("/")[1:] + current_obj = spec + for part in parts: + if not isinstance(current_obj, dict): + return {} + current_obj = current_obj.get(part) + if current_obj is None: + return {} + return current_obj + + def _get_param_type(self, spec: dict, schema: dict) -> str: + if not schema: + return "N/A" + schema_ref = schema.get("$ref") + if schema_ref: + resolved_schema = self._resolve_ref(spec, schema_ref) + return resolved_schema.get("type", "N/A") + return schema.get("type", "N/A") diff --git a/domaintools/specs/iris-openapi.yaml b/domaintools/specs/iris-openapi.yaml new file mode 100644 index 0000000..f2dddb8 --- /dev/null +++ b/domaintools/specs/iris-openapi.yaml @@ -0,0 +1,2741 @@ +openapi: 3.0.3 +info: + title: DomainTools Iris API + version: 1.0.0 + description: | + The OpenAPI spec for DomainTools Iris endpoints. +servers: + - url: https://api.domaintools.com + description: DomainTools APIs +security: + - header_auth: [] + - open_key_auth: [] + - hmac_auth: [] +tags: + - name: Information + description: | + Access the latest information about your account, including service + limits. + - name: Iris Detect + description: | + Iris Detect is an Internet infrastructure detection, monitoring, and enforcement tool. + It rapidly discovers malicious domains that are engaged in brand impersonation, risk-scores them within minutes, and supports your automation of detection, escalation, and enforcement actions. + - name: Iris Enrich + description: | + Designed to support high query volumes with batch processing and fast response times, the Iris Enrich API provides actionable insights-at-scale with enterprise-scale ingestion of DomainTools data + - name: Iris Investigate + description: | + The Iris Investigate API is ideally suited for investigate and orchestrate use cases at human scale. Identify threats, map adversary infrastructure, and streamline investigations. +paths: + /v1/iris-detect/domains/: + patch: + operationId: patchDetectDomains + summary: Add and remove domains from Watchlist or Ignored lists. + tags: + - Iris Detect + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WatchlistState' + responses: + '200': + $ref: '#/components/responses/DetectWatchlistOk' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + externalDocs: + url: https://docs.domaintools.com/api/iris/detect/reference/#add-remove-from-watchlist + /v1/iris-detect/domains/ignored/: + get: + operationId: getDetectIgnored + summary: Provide ignored domains for active monitors in an account. + tags: + - Iris Detect + parameters: + - $ref: '#/components/parameters/appName' + - $ref: '#/components/parameters/appPartner' + - $ref: '#/components/parameters/appVersion' + - $ref: '#/components/parameters/changedSince' + - $ref: '#/components/parameters/discoveredBefore' + - $ref: '#/components/parameters/discoveredSince' + - $ref: '#/components/parameters/domainState' + - $ref: '#/components/parameters/escalatedSince' + - $ref: '#/components/parameters/includeDomainData' + - $ref: '#/components/parameters/irisContainsSearch' + - $ref: '#/components/parameters/irisResultsLimit' + - $ref: '#/components/parameters/monitorId' + - $ref: '#/components/parameters/mxExists' + - $ref: '#/components/parameters/resultsOffset' + - $ref: '#/components/parameters/order' + - $ref: '#/components/parameters/preview' + - $ref: '#/components/parameters/riskScoreRanges' + - $ref: '#/components/parameters/sortDetect' + - $ref: '#/components/parameters/tlds' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/DetectDomainList' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + externalDocs: + url: https://docs.domaintools.com/api/iris/detect/reference/#domains + /v1/iris-detect/domains/new/: + get: + operationId: getDetectNewDomains + summary: Provide newly discovered domains for active monitors in an account. + tags: + - Iris Detect + parameters: + - $ref: '#/components/parameters/appName' + - $ref: '#/components/parameters/appPartner' + - $ref: '#/components/parameters/appVersion' + - $ref: '#/components/parameters/discoveredBefore' + - $ref: '#/components/parameters/discoveredSince' + - $ref: '#/components/parameters/includeDomainData' + - $ref: '#/components/parameters/irisContainsSearch' + - $ref: '#/components/parameters/irisResultsLimit' + - $ref: '#/components/parameters/monitorId' + - $ref: '#/components/parameters/mxExists' + - $ref: '#/components/parameters/resultsOffset' + - $ref: '#/components/parameters/order' + - $ref: '#/components/parameters/preview' + - $ref: '#/components/parameters/riskScoreRanges' + - $ref: '#/components/parameters/sortDetect' + - $ref: '#/components/parameters/tlds' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/DetectDomainList' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + externalDocs: + url: https://docs.domaintools.com/api/iris/detect/reference/#domains + /v1/iris-detect/domains/watched/: + get: + operationId: getDomainsWatched + summary: Provide recently changed or escalated domains for active monitors in an account. + tags: + - Iris Detect + parameters: + - $ref: '#/components/parameters/appName' + - $ref: '#/components/parameters/appPartner' + - $ref: '#/components/parameters/appVersion' + - $ref: '#/components/parameters/changedSince' + - $ref: '#/components/parameters/discoveredBefore' + - $ref: '#/components/parameters/discoveredSince' + - $ref: '#/components/parameters/escalatedSince' + - $ref: '#/components/parameters/escalationTypes' + - $ref: '#/components/parameters/includeDomainData' + - $ref: '#/components/parameters/irisContainsSearch' + - $ref: '#/components/parameters/irisResultsLimit' + - $ref: '#/components/parameters/monitorId' + - $ref: '#/components/parameters/mxExists' + - $ref: '#/components/parameters/resultsOffset' + - $ref: '#/components/parameters/order' + - $ref: '#/components/parameters/preview' + - $ref: '#/components/parameters/riskScoreRanges' + - $ref: '#/components/parameters/sortDetect' + - $ref: '#/components/parameters/tlds' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/DetectDomainList' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + externalDocs: + url: https://docs.domaintools.com/api/iris/detect/reference/#domains + /v1/iris-detect/escalations/: + post: + operationId: postDetectEscalations + summary: Escalate internally and externally. + tags: + - Iris Detect + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WatchlistEscalation' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Escalations' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + externalDocs: + url: https://docs.domaintools.com/api/iris/detect/reference/#escalate + /v1/iris-detect/monitors/: + get: + operationId: getDetectMonitors + summary: Retrieves monitors and monitor IDs for an account. + tags: + - Iris Detect + parameters: + - $ref: '#/components/parameters/appName' + - $ref: '#/components/parameters/appPartner' + - $ref: '#/components/parameters/appVersion' + - $ref: '#/components/parameters/datetimeCountsSince' + - $ref: '#/components/parameters/includeCounts' + - $ref: '#/components/parameters/limitMonitors' + - $ref: '#/components/parameters/resultsOffset' + - $ref: '#/components/parameters/order' + - $ref: '#/components/parameters/sortMonitorList' + responses: + '200': + $ref: '#/components/responses/DetectMonitorSuccess' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + externalDocs: + url: https://docs.domaintools.com/api/iris/detect/reference/#monitor-list + /v1/iris-enrich/: + get: + operationId: getIrisEnrich + summary: Returns results from a GET request to Iris Enrich. + tags: + - Iris Enrich + parameters: + - $ref: '#/components/parameters/appName' + - $ref: '#/components/parameters/appPartner' + - $ref: '#/components/parameters/appVersion' + - $ref: '#/components/parameters/domainsQueryRequired' + - $ref: '#/components/parameters/parsedDomainRdapFlag' + - $ref: '#/components/parameters/parsedWhoisFlag' + - $ref: '#/components/parameters/responseFormat' + responses: + '200': + $ref: '#/components/responses/EnrichSuccess' + '206': + $ref: '#/components/responses/IrisPartialContent' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + '503': + $ref: '#/components/responses/ServiceUnavailable' + externalDocs: + url: https://docs.domaintools.com/api/iris/enrich/ + post: + operationId: postIrisEnrich + summary: Returns results from a POST request to Iris Enrich. + tags: + - Iris Enrich + requestBody: + $ref: '#/components/requestBodies/Enrich' + responses: + '200': + $ref: '#/components/responses/EnrichSuccess' + '206': + $ref: '#/components/responses/IrisPartialContent' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + '503': + $ref: '#/components/responses/ServiceUnavailable' + externalDocs: + url: https://docs.domaintools.com/api/iris/enrich/ + /v1/iris-investigate/: + get: + operationId: getIrisInvestigate + summary: | + Returns data from a GET request. + Parameters are identified in descriptions as either base or filter. + Search with base parameters and filter down base results with filter parameters. + tags: + - Iris Investigate + description: | + Consider using POST for complex requests. + parameters: + - $ref: '#/components/parameters/adsense' + - $ref: '#/components/parameters/baiduCode' + - $ref: '#/components/parameters/contactName' + - $ref: '#/components/parameters/contactPhone' + - $ref: '#/components/parameters/contactStreet' + - $ref: '#/components/parameters/domainLastIp' + - $ref: '#/components/parameters/domainsQuery' + - $ref: '#/components/parameters/emailAny' + - $ref: '#/components/parameters/emailDnsSoa' + - $ref: '#/components/parameters/emailDomain' + - $ref: '#/components/parameters/emailHistoricalWhois' + - $ref: '#/components/parameters/facebookCode' + - $ref: '#/components/parameters/googleAnalytics4Code' + - $ref: '#/components/parameters/googleAnalyticsCode' + - $ref: '#/components/parameters/googleTagManagerCode' + - $ref: '#/components/parameters/hotJarCode' + - $ref: '#/components/parameters/ianaId' + - $ref: '#/components/parameters/mailserverDomain' + - $ref: '#/components/parameters/mailserverHost' + - $ref: '#/components/parameters/mailserverIp' + - $ref: '#/components/parameters/matomoCode' + - $ref: '#/components/parameters/nameserverDomain' + - $ref: '#/components/parameters/nameserverHost' + - $ref: '#/components/parameters/nameserverIp' + - $ref: '#/components/parameters/redirectDomain' + - $ref: '#/components/parameters/registrant' + - $ref: '#/components/parameters/registrantHistoricalWhois' + - $ref: '#/components/parameters/registrantOrg' + - $ref: '#/components/parameters/registrar' + - $ref: '#/components/parameters/searchHash' + - $ref: '#/components/parameters/serverType' + - $ref: '#/components/parameters/sslAltNames' + - $ref: '#/components/parameters/sslCommonName' + - $ref: '#/components/parameters/sslDuration' + - $ref: '#/components/parameters/sslEmail' + - $ref: '#/components/parameters/sslHash' + - $ref: '#/components/parameters/sslOrg' + - $ref: '#/components/parameters/sslSubject' + - $ref: '#/components/parameters/statCounterProjectCode' + - $ref: '#/components/parameters/statCounterSecurityCode' + - $ref: '#/components/parameters/taggedWithAll' + - $ref: '#/components/parameters/taggedWithAny' + - $ref: '#/components/parameters/websiteTitle' + - $ref: '#/components/parameters/whoisFreeText' + - $ref: '#/components/parameters/whoisHistoricalFreeText' + - $ref: '#/components/parameters/yandexCode' + - $ref: '#/components/parameters/active' + - $ref: '#/components/parameters/createDate' + - $ref: '#/components/parameters/createDateWithin' + - $ref: '#/components/parameters/expirationDate' + - $ref: '#/components/parameters/firstSeenSince' + - $ref: '#/components/parameters/firstSeenWithin' + - $ref: '#/components/parameters/notTaggedWithAll' + - $ref: '#/components/parameters/notTaggedWithAny' + - $ref: '#/components/parameters/topLevelDomain' + - $ref: '#/components/parameters/parsedDomainRdapFlag' + - $ref: '#/components/parameters/parsedWhoisFlag' + - $ref: '#/components/parameters/nextPageUrl' + - $ref: '#/components/parameters/responseFormat' + - $ref: '#/components/parameters/resultsPageSize' + - $ref: '#/components/parameters/resultsPosition' + - $ref: '#/components/parameters/resultsSortBy' + - $ref: '#/components/parameters/resultsSortDirection' + - $ref: '#/components/parameters/appName' + - $ref: '#/components/parameters/appPartner' + - $ref: '#/components/parameters/appVersion' + responses: + '200': + $ref: '#/components/responses/InvestigateSuccess' + '206': + $ref: '#/components/responses/IrisPartialContent' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + '503': + $ref: '#/components/responses/ServiceUnavailable' + externalDocs: + url: https://docs.domaintools.com/api/iris/investigate/ + post: + operationId: postIrisInvestigate + summary: Returns data from a POST request. + tags: + - Iris Investigate + description: The GET method is available for simple queries. + requestBody: + $ref: '#/components/requestBodies/Investigate' + responses: + '200': + $ref: '#/components/responses/InvestigateSuccess' + '206': + $ref: '#/components/responses/IrisPartialContent' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + '503': + $ref: '#/components/responses/ServiceUnavailable' + externalDocs: + url: https://docs.domaintools.com/api/iris/investigate/ + /v1/account/: + get: + operationId: getAccountInfo + summary: Account Information + description: Information of the active API endpoints, rate limits and usage for an account. + tags: + - Information + parameters: + - $ref: '#/components/parameters/appPartner' + - $ref: '#/components/parameters/appName' + - $ref: '#/components/parameters/appVersion' + - $ref: '#/components/parameters/responseFormat' + responses: + '200': + $ref: '#/components/responses/AccountSuccess' + externalDocs: + url: https://docs.domaintools.com/api/general/account_information/ +components: + parameters: + appName: + name: app_name + in: query + description: | + Appliance, module, or playbook, or any combination of these. + schema: + $ref: '#/components/schemas/IdentifierString' + appPartner: + name: app_partner + in: query + description: | + Your product name. + schema: + $ref: '#/components/schemas/IdentifierString' + appVersion: + name: app_version + in: query + description: | + Version of your integration/connector. + schema: + $ref: '#/components/schemas/IdentifierString' + parsedDomainRdapFlag: + name: parsed_domain_rdap + in: query + description: | + Flag. + If set to `true`, includes the full parsed Domain RDAP record in the response. + schema: + $ref: '#/components/schemas/BooleanOptInFlag' + parsedWhoisFlag: + name: parsed_whois + in: query + description: | + Flag. + If set to 'true', includes the full parsed WHOIS record in the response. + schema: + $ref: '#/components/schemas/BooleanOptInFlag' + responseFormat: + name: format + in: query + description: | + Specifies the desired response format. + schema: + $ref: '#/components/schemas/ResponseFormat' + resultsPageSize: + name: page_size + in: query + description: | + Adjusts the number of results returned per page. + The default is 500. Use this parameter to request a smaller page size. + schema: + type: integer + minimum: 1 + maximum: 500 + offset: + $ref: '#/components/parameters/resultsOffset' + active: + in: query + name: active + description: | + Search filter parameter. + Set to `true` to only return domains that have either an entry in the global Domain Name System, OR are listed as registered by the registry. + Set to `false` to only return domains that do not have an entry in the global DNS, AND are not listed as registered by the registry. + schema: + type: boolean + adsense: + in: query + name: adsense + description: | + Base search parameter. Domains with a Google AdSense tracking code. + schema: + $ref: '#/components/schemas/IdentifierString' + baiduCode: + in: query + name: baidu_analytics + description: | + Base search parameter. Baidu Analytics code. + schema: + $ref: '#/components/schemas/IdentifierString' + changedSince: + in: query + name: changed_since + description: | + Most relevant for the `/watched` endpoint to control the timeframe for changes to DNS or WHOIS fields for watched domains. + schema: + $ref: '#/components/schemas/TimestampFilter' + contactName: + in: query + name: contact_name + description: | + Base search parameter. + Contact name from domain registration data. + Supports WHOIS/RDAP Fields Search. + schema: + $ref: '#/components/schemas/GenericString' + contactPhone: + in: query + name: contact_phone + description: | + Base search parameter. + Contact phone number from domain registration data. + Supports WHOIS/RDAP Fields Search. + schema: + $ref: '#/components/schemas/GenericString' + contactStreet: + in: query + name: contact_street + description: | + Base search parameter. + Contact street address from domain registration data. + Supports WHOIS/RDAP Fields Search. + schema: + $ref: '#/components/schemas/GenericString' + createDate: + in: query + name: create_date + description: | + Search filter parameter. + Filters domains based on the `create_date` field. + Supports [`>`, `>=`, `<`, `<=`] operators, or no operator for exact matches. + schema: + $ref: '#/components/schemas/DateOperatorsFilter' + createDateWithin: + in: query + name: create_date_within + description: | + Search filter parameter. + Filter domains based on the `create_date` field: the maximum number of days since a domain was first discovered. + schema: + $ref: '#/components/schemas/MaxDaysSince' + datetimeCountsSince: + in: query + name: datetime_counts_since + description: | + Filters counts to include only those generated since the specified ISO-8601 date-time. + This parameter is conditionally required if `include_counts` is set to `true`. + Example: `2022-02-10T00:00:00Z` + schema: + $ref: '#/components/schemas/TimestampFilter' + discoveredBefore: + in: query + name: discovered_before + description: | + Most relevant for the /new endpoint to control the timeframe for when a new domain was discovered. Returns domains discovered before provided date/time. Use with `discovered_since` to return domains discovered in a specific time window. + Example: `2022-02-10T00:00:00Z (ISO-8601)` + schema: + $ref: '#/components/schemas/TimestampFilter' + discoveredSince: + in: query + name: discovered_since + description: | + Most relevant for the /new endpoint to control the timeframe for when a new domain was discovered. + Example: `2022-02-10T00:00:00Z (ISO-8601)` + schema: + $ref: '#/components/schemas/TimestampFilter' + domainsQuery: + in: query + name: domain + description: | + Base search parameter. + One or more domains (comma-separated) to be investigated. + Example: `example.com,domaintools.com`. + schema: + $ref: '#/components/schemas/ApexDomainList' + domainsQueryRequired: + in: query + name: domain + description: | + Required. One or more domains (comma-separated) to be investigated. + Example: `example.com,domaintools.com` + required: true + schema: + $ref: '#/components/schemas/ApexDomainList' + domainState: + in: query + name: domain_state + description: | + Filters domains based on their state. + schema: + type: string + enum: + - active + - inactive + domainLastIp: + in: query + name: ip + description: | + Base search parameter. + IPv4 address the registered domain was last known to point to during an active DNS check. + required: false + schema: + $ref: '#/components/schemas/IPv4Address' + emailAny: + in: query + name: email + description: | + Base search parameter. + Email address from the most recently available WHOIS record, DNS SOA record or SSL certificate. + schema: + $ref: '#/components/schemas/EmailAddress' + emailDnsSoa: + in: query + name: email_dns_soa + description: | + Base search parameter. DNS SOA email. + schema: + $ref: '#/components/schemas/EmailAddress' + emailDomain: + in: query + name: email_domain + description: Only the domain portion of a WHOIS or DNS SOA email address. + schema: + $ref: '#/components/schemas/ApexDomain' + emailHistoricalWhois: + in: query + name: historical_free_text + description: Free text search of a domain's historical WHOIS records. + schema: + $ref: '#/components/schemas/GenericString' + escalatedSince: + in: query + name: escalated_since + description: 'Most relevant for the /watched endpoint to control the timeframe for when a domain was most recently escalated. Example: 2022-02-10T00:00:00Z (ISO-8601)' + schema: + $ref: '#/components/schemas/TimestampFilter' + escalationTypes: + in: query + name: escalation_types + description: Filters domains based on specific escalation types. Multiple types can be provided. + style: form + explode: true + schema: + $ref: '#/components/schemas/EscalationTypeEnum' + expirationDate: + in: query + name: expiration_date + description: | + Search filter parameter. + Only include domains expiring on a specific date. + schema: + $ref: '#/components/schemas/DateFilter' + facebookCode: + in: query + name: facebook + description: | + Facebook/Meta tracking code. + schema: + $ref: '#/components/schemas/IdentifierString' + firstSeenSince: + in: query + name: first_seen_since + description: | + Search filter parameter. + Filter domains based on the `first_seen` timestamp. + Returns domains whose `current_lifecycle_first_seen` value is *after* the given datetime. + schema: + $ref: '#/components/schemas/TimestampFilter' + firstSeenWithin: + in: query + name: first_seen_within + description: | + Search filter parameter. + Filter domains based on the `first_seen` field. + Returns only those domains first discovered within the last N seconds. + schema: + $ref: '#/components/schemas/TimeWindowSeconds' + googleAnalyticsCode: + in: query + name: google_analytics + description: | + Base search parameter. + Domains with a Google Analytics tracking code. + schema: + $ref: '#/components/schemas/IdentifierString' + googleAnalytics4Code: + in: query + name: google_analytics_4 + description: | + Base search parameter. + Domains with a Google Analytics tracking code. + schema: + $ref: '#/components/schemas/IdentifierString' + googleTagManagerCode: + in: query + name: google_tag_manager + description: | + Base search parameter. + Google Tag Manager tracking code. + schema: + $ref: '#/components/schemas/IdentifierString' + hotJarCode: + in: query + name: hotjar + description: | + Base search parameter. + Hotjar tracking code. + schema: + $ref: '#/components/schemas/IdentifierString' + ianaId: + in: query + name: iana_id + description: | + Base search parameter. + Registrar IANA code from most recent RDAP record for a domain. + schema: + $ref: '#/components/schemas/IdentifierString' + includeCounts: + in: query + name: include_counts + description: | + If set to `true`, the response will include counts for new, watched, changed, and escalated domains for each monitor. + schema: + $ref: '#/components/schemas/BooleanOptInFlag' + includeDomainData: + in: query + name: include_domain_data + description: | + If set to true, includes additional DNS and WHOIS details in the response. + schema: + $ref: '#/components/schemas/BooleanOptInFlag' + irisResultsLimit: + in: query + name: limit + description: | + Specify the maximum number of records to retrieve in an API query. + The maximum value is **100**, but this is reduced to **50** if `include_domain_data=true`. + schema: + type: integer + maximum: 100 + minimum: 1 + limitMonitors: + in: query + name: limit + description: | + Specifies the maximum number of monitors to retrieve. + The maximum value is 100, but this may be further restricted if `include_counts=true`. + schema: + type: integer + minimum: 1 + maximum: 100 + mailserverDomain: + in: query + name: mailserver_domain + description: | + Base search parameter. + Only the registered domain portion of the mail server (e.g., `domaintools.net`). + schema: + $ref: '#/components/schemas/ApexDomain' + mailserverHost: + in: query + name: mailserver_host + description: | + Base search parameter. + Fully-qualified mail server hostname (e.g., mx.domaintools.net). + Performs a Reverse MX lookup to identify domains using this mail server. + schema: + $ref: '#/components/schemas/Fqdn' + mailserverIp: + in: query + name: mailserver_ip + description: | + Base search parameter. + IP address of the mail server. + schema: + $ref: '#/components/schemas/IPv4Address' + matomoCode: + in: query + name: matomo + description: | + Base search parameter. + Matomo tracking code. + schema: + $ref: '#/components/schemas/IdentifierString' + monitorId: + in: query + name: monitor_id + description: Monitor ID from the monitors response - only used when requesting domains for specific monitors. + schema: + type: string + mxExists: + in: query + name: mx_exists + description: Whether domain currently has an MX record in DNS. + schema: + type: boolean + nameserverHost: + in: query + name: nameserver_host + description: | + Base search parameter. + Fully-qualified domain name (FQDN) of the name server. + required: false + schema: + $ref: '#/components/schemas/Hostname' + nameserverDomain: + in: query + name: nameserver_domain + description: | + Base search parameter. + The registered domain name of the nameserver (e.g., `example.com`). + schema: + $ref: '#/components/schemas/Hostname' + nameserverIp: + in: query + name: nameserver_ip + description: | + Base search parameter. + The IPv4 address of the name server. + schema: + $ref: '#/components/schemas/IPv4Address' + nextPageUrl: + in: query + name: next + description: The URL for the next page of search results, emitted as a `next` field in the response, using the `position` marker. + schema: + $ref: '#/components/schemas/BooleanOptInFlag' + notTaggedWithAll: + in: query + name: not_tagged_with_all + description: | + Search filter parameter. + Exclude all domains that are tagged with **all** of the specified tags in the Iris Investigation platform. + This parameter accepts a comma-separated list of tag names. + schema: + $ref: '#/components/schemas/CommaSeparatedTags' + notTaggedWithAny: + in: query + name: not_tagged_with_any + description: | + Search filter parameter. + Exclude all domains that are tagged with **any** of the specified tags in the Iris Investigation platform. + Accepts a comma-separated list of tags. + schema: + $ref: '#/components/schemas/CommaSeparatedTags' + order: + in: query + name: order + description: Specifies the sort order for the results, either ascending or descending. Used in conjunction with the 'sort' parameter. Defaults to `desc`. + schema: + type: string + enum: + - asc + - desc + preview: + in: query + name: preview + description: Use during API implementation and testing. Will limit results to 10 but not be limited by hourly restrictions. + required: false + schema: + $ref: '#/components/schemas/BooleanOptInFlag' + registrar: + in: query + name: registrar + description: | + Base search parameter. + Exact match to the WHOIS registrar field. + schema: + $ref: '#/components/schemas/OrgNameString' + registrant: + in: query + name: registrant + description: | + Base search parameter. + Exact match to the WHOIS registrant field. + schema: + $ref: '#/components/schemas/OrgNameString' + redirectDomain: + in: query + name: redirect_domain + description: | + Base search parameter. + Domains observed to redirect to another domain name. + schema: + $ref: '#/components/schemas/GenericString' + registrantHistoricalWhois: + in: query + name: historical_registrant + description: | + Base search parameter. + Registrant names from historical WHOIS records. + schema: + $ref: '#/components/schemas/OrgNameString' + registrantOrg: + in: query + name: registrant_org + description: | + Base search parameter. + Exact match to the WHOIS registrant organization field + schema: + $ref: '#/components/schemas/OrgNameString' + resultsPosition: + in: query + name: position + description: | + Cursor for paginated results. Use the value returned in a previous response to retrieve the next page. + required: false + schema: + $ref: '#/components/schemas/PositionToken' + dzzzzzzz: + in: query + name: sort_by + description: | + Specifies the field to sort the results by. + schema: + type: string + enum: + - first_seen_since + - create_date + - domain + - risk_score + resultsSortDirection: + in: query + name: sort_direction + description: | + Determines the sort direction, either ascending or descending. + This is used in combination with the 'sort_by' parameter. + schema: + type: string + enum: + - asc + - dsc + riskScoreRanges: + in: query + name: risk_score_ranges + description: | + Filters domains based on their risk score. Multiple ranges can be selected to broaden the filter. Consult [Domain Risk Scores](https://docs.domaintools.com/riskscore/) for help interpreting scores. + schema: + type: array + items: + type: string + enum: + - 0-0 + - 1-39 + - 40-69 + - 70-99 + - 100-100 + style: form + explode: true + irisContainsSearch: + in: query + name: search + description: Performs a `contains` search. + schema: + $ref: '#/components/schemas/BasicString' + searchHash: + in: query + name: search_hash + description: | + Base search parameter. + Token returned by the Iris Investigate UI when exporting a search. + Use this value to import and continue an existing search via the API. + schema: + $ref: '#/components/schemas/SearchHashToken' + serverType: + in: query + name: server_type + description: | + Base search parameter. + Domains hosted on a specific server type (e.g., from the 'Server' HTTP header). Must be an exact match. + schema: + $ref: '#/components/schemas/BasicString' + sortDetect: + in: query + name: sort + description: Sorts the domain list response. Valid fields to sort by are 'discovered_date', 'changed_date', and 'risk_score'. + schema: + type: array + items: + type: string + enum: + - discovered_date + - changed_date + - risk_score + style: form + explode: true + sortMonitorList: + in: query + name: sort[] + description: Provides options for sorting the monitor list. + schema: + type: string + enum: + - term + - created_date + - domain_counts_changed + - domain_counts_discovered + sslAltNames: + in: query + name: ssl_alt_names + description: | + Base search parameter. + Domains with a matching Subject Alternative Name (SAN) in their SSL certificate. + schema: + $ref: '#/components/schemas/GenericString' + sslCommonName: + in: query + name: ssl_common_name + description: | + Base search parameter. + Domains with a matching Common Name (CN) in their SSL certificate's subject. + schema: + $ref: '#/components/schemas/GenericString' + sslDuration: + in: query + name: ssl_duration + description: | + Base search parameter. + Domains with an SSL certificate valid for a specific number of days. + schema: + type: integer + minimum: 1 + sslEmail: + in: query + name: ssl_email + description: | + Base search parameter. + Email address extracted from the SSL certificate associated with a domain. + schema: + $ref: '#/components/schemas/EmailAddress' + sslHash: + in: query + name: ssl_hash + description: | + Base search parameter. + The SHA-1 hash of an SSL certificate, used to filter for domains associated with a specific certificate. + schema: + $ref: '#/components/schemas/Sha1HexString' + sslOrg: + in: query + name: ssl_org + description: | + Base search parameter. + Organization name from the SSL certificate. Must be an exact string match. + schema: + $ref: '#/components/schemas/OrgNameString' + sslSubject: + in: query + name: ssl_subject + description: | + Base search parameter. + Exact match to the Subject distinguished name (DN) string from the SSL certificate. + schema: + $ref: '#/components/schemas/SslSubjectDnString' + statCounterProjectCode: + in: query + name: statcounter_project + description: | + Base search parameter. + Statcounter Project tracker code. + schema: + $ref: '#/components/schemas/IdentifierString' + statCounterSecurityCode: + in: query + name: statcounter_security + description: | + Base search parameter. + Statcounter Security tracker code. + schema: + $ref: '#/components/schemas/IdentifierString' + taggedWithAll: + in: query + name: tagged_with_all + description: | + Search filter parameter. + Comma-separated list of tags. Only returns domains tagged with the full list of tags. + schema: + $ref: '#/components/schemas/GenericString' + taggedWithAny: + in: query + name: tagged_with_any + description: | + Search filter parameter. + Comma-separated list of Iris Investigate tags. Returns domains tagged with any of the tags in a list. + schema: + $ref: '#/components/schemas/GenericString' + tlds: + in: query + name: tlds + description: Filters on specific TLDs + schema: + type: array + items: + type: string + allowReserved: true + style: form + explode: true + topLevelDomain: + in: query + name: tld + description: | + Search filter parameter. + Restrict results to domains under the specified top-level domain (TLD). + schema: + $ref: '#/components/schemas/TopLevelDomain' + websiteTitle: + in: query + name: website_title + description: | + Base search parameter. + The value of the website’s `` HTML tag. Must be an exact match. + schema: + $ref: '#/components/schemas/WebsiteTitleString' + whoisFreeText: + in: query + name: whois + description: | + Base search parameter. + Free text search of a domain's most recent WHOIS record. + schema: + $ref: '#/components/schemas/GenericString' + whoisHistoricalFreeText: + in: query + name: historical_free_text + description: | + Base search parameter. + Free text search of a domain's historical WHOIS records. + schema: + $ref: '#/components/schemas/GenericString' + yandexCode: + in: query + name: yandex_metrica + description: | + Base search parameter. + Yandex Metrica tracker code. + schema: + $ref: '#/components/schemas/IdentifierString' + resultsOffset: + name: offset + in: query + description: Specifies the starting point for the result set, used for paginating when the number of results exceeds the 'limit'. An offset of 0 starts from the first result. + schema: + type: integer + minimum: 0 + requestBodies: + Enrich: + description: A request to the Iris Enrich endpoint. + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/EnrichRequestParameters' + Investigate: + description: A request to the Iris Investigate endpoint. + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/InvestigateRequestParameters' + responses: + DetectMonitorSuccess: + description: OK. A list of monitors was successfully retrieved. + content: + application/json: + schema: + $ref: '#/components/schemas/MonitorList' + DetectWatchlistOk: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Watchlist' + EnrichSuccess: + description: OK + content: + application/json: + schema: + type: object + properties: + response: + type: object + properties: + limit_exceeded: + type: boolean + message: + type: string + results_count: + type: integer + has_more_results: + type: boolean + results: + type: array + items: + $ref: '#/components/schemas/EnrichResult' + missing_domains: + type: array + items: + $ref: '#/components/schemas/ApexDomain' + InvestigateSuccess: + description: OK + content: + application/json: + schema: + type: object + properties: + response: + type: object + properties: + limit_exceeded: + type: boolean + has_more_results: + type: boolean + message: + type: string + results_count: + type: integer + total_count: + type: integer + results: + type: array + items: + $ref: '#/components/schemas/InvestigateResult' + missing_domains: + type: array + items: + $ref: '#/components/schemas/ApexDomain' + IrisBadRequest: + $ref: '#/components/responses/BadRequest' + IrisForbidden: + $ref: '#/components/responses/Forbidden' + IrisInternalServerError: + $ref: '#/components/responses/InternalServerError' + IrisNotFound: + $ref: '#/components/responses/NotFound' + IrisPartialContent: + description: '206: Partial Content. The response is a subset of the full results. The `has_more_results` field in the response will be `true`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + IrisServiceUnavailable: + $ref: '#/components/responses/ServiceUnavailable' + IrisUnauthorized: + $ref: '#/components/responses/Unauthorized' + AccountSuccess: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/AccountInformation' + BadRequest: + description: '400: Bad Request. The request was invalid. The response will contain details about the error.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + Unauthorized: + description: '401: Unauthorized. API credentials are required and were not provided, or the provided credentials are not valid.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + Forbidden: + description: '403: Forbidden. The API credentials provided do not have access to the requested resource or endpoint.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + NotFound: + description: '404: Not Found. The requested resource does not exist.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + InternalServerError: + description: '500: Internal Server Error. An unexpected error occurred on the server. If the problem persists, contact DomainTools support.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + ServiceUnavailable: + description: '503: Service Unavailable. The service is temporarily unavailable.' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + schemas: + ApexDomain: + type: string + description: A base (apex) domain like example.com, excluding subdomains. + pattern: ^(?!\-)(?:[a-zA-Z0-9-]{1,63}\.)+[a-zA-Z]{2,63}$ + BasicString: + type: string + minLength: 1 + maxLength: 255 + description: A non-empty string with a standard length limit. + BooleanOptInFlag: + type: boolean + description: | + An optional boolean flag that enables a specific feature or response extension when set to `true`. If `false` or omitted, the feature is ignored. + ErrorResponse: + type: object + properties: + error: + type: object + properties: + code: + type: integer + description: An internal error code. + message: + type: string + description: A human-readable error message. + Hostname: + type: string + format: hostname + description: A valid, fully-qualified domain name (FQDN). + IdentifierString: + type: string + minLength: 1 + maxLength: 128 + description: A short identifier, code, or handle. + IPv4Address: + type: string + format: ipv4 + description: A standard IPv4 address. + ResponseFormat: + type: string + enum: + - html + - json + - xml + description: | + The desired response format. + AccountInformation: + type: object + description: Contains the account details and a list of product subscriptions. + properties: + response: + type: object + properties: + account: + $ref: '#/components/schemas/AccountInfoAccount' + products: + type: array + description: A list of product subscriptions including usage limits and expiration dates. + items: + type: object + description: Details for a single product subscription. Keys include id, rate limits, usage, and expiration_date. + example: + id: domain-profile + per_month_limit: '50000' + per_hour_limit: null + per_minute_limit: '120' + absolute_limit: null + usage: + today: '0' + month: '1' + expiration_date: '2027-12-30' + AccountInfoAccount: + type: object + description: Basic details about the API account. + properties: + api_username: + type: string + description: The username associated with the account. + example: bdfidler + active: + type: boolean + description: Indicates if the account is currently active. + example: true + ApexDomainList: + type: string + pattern: ^([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(,([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,})*$ + description: A comma-separated list of one or more apex domains to investigate. + CommaSeparatedTags: + type: string + description: | + A comma-separated list of tag names. Tags are matched exactly and compared case-insensitively unless otherwise specified. + maxLength: 512 + DateFilter: + type: string + format: date + description: | + A calendar date in the format YYYY-MM-DD, per RFC 3339 (full-date). Does not include a time or timezone. + DateOperatorsFilter: + type: string + description: | + A date in YYYY-MM-DD format, optionally prefixed with an operator (`>`, `>=`, `<`, `<=`) for comparison. + Example: `<=2025-01-01` + pattern: ^(>|>=|<|<=)?\d{4}-\d{2}-\d{2}$ + DetectDomain: + type: object + properties: + state: + type: string + domain: + type: string + status: + type: string + enum: + - active + - inactive + discovered_date: + type: string + format: date-time + changed_date: + type: string + format: date-time + risk_score: + type: integer + risk_score_status: + description: Indicates if the risk score is 'provisional' (initial calculation) or full. + type: string + risk_score_components: + $ref: '#/components/schemas/DetectRiskComponents' + mx_exists: + type: boolean + tld: + type: string + id: + type: string + escalations: + type: array + items: + $ref: '#/components/schemas/DetectEscalation' + monitor_ids: + type: array + items: + type: string + name_server: + type: array + items: + type: object + properties: + host: + type: string + registrant_contact_email: + type: array + items: + type: string + registrar: + type: string + create_date: + type: integer + description: Domain creation date in YYYYMMDD format. + ip: + type: array + items: + type: object + properties: + country_code: + type: string + ip: + type: string + isp: + type: string + mx: + type: array + items: + type: object + properties: + host: + type: string + DetectDomainList: + type: object + properties: + watchlist_domains: + type: array + items: + $ref: '#/components/schemas/DetectDomain' + total_count: + type: integer + count: + type: integer + offset: + type: integer + limit: + type: integer + DetectEscalation: + type: object + properties: + escalation_type: + $ref: '#/components/schemas/EscalationTypeEnum' + id: + type: string + created: + type: string + format: date-time + created_by: + type: string + DetectRiskComponents: + type: object + properties: + proximity: + type: integer + threat_profile: + $ref: '#/components/schemas/DetectThreatProfile' + DetectThreatProfile: + type: object + properties: + phishing: + type: integer + malware: + type: integer + spam: + type: integer + EmailAddress: + type: string + format: email + maxLength: 254 + description: | + A valid email address, as defined by RFC 5322 and commonly used in WHOIS, SSL certificates, RDAP records, or domain registration contacts. + Escalations: + type: object + properties: + watchlist_domains: + type: array + items: + type: object + properties: + state: + type: string + domain: + type: string + discovered_date: + type: string + changed_date: + type: string + id: + type: string + assigned_by: + type: string + assigned_date: + type: string + EscalationTypeEnum: + type: string + description: The type of escalation action. + enum: + - blocked + - google_safe + Fqdn: + type: string + format: hostname + description: | + A Fully Qualified Domain Name (FQDN), including subdomains (e.g., www.example.com). Must be a valid DNS hostname per RFC 1123, excluding trailing dot. + minLength: 1 + maxLength: 253 + pattern: ^(?=.{1,253}$)(?:(?!-)[a-zA-Z0-9-]{1,63}(?<!-)\.)+[a-zA-Z]{2,63}$ + GenericString: + type: string + description: A generic and unconstrained string value. + MaxDaysSince: + type: integer + minimum: 1 + description: A positive integer representing the maximum number of days. + OrgNameString: + type: string + minLength: 1 + maxLength: 256 + pattern: ^[\p{L}\p{N} .,\-&()'/"]+$ + description: | + A human-readable organization name as used in WHOIS or RDAP records, such as a registrar, registrant, or sponsoring organization. Supports Unicode letters and numbers, along with common punctuation. + PositionToken: + type: string + description: | + An opaque string token used for cursor-based pagination. + Returned in the `position` field of a paginated response when `has_more_results` is true, and used as a query parameter to retrieve the next result set. + Example: `2c056abadfb64b67ba18896af2c5b900` + RiskScoreValue: + type: integer + minimum: 0 + maximum: 99 + SearchHashToken: + type: string + description: | + Opaque token representing a saved search from the Iris Investigate UI. + Exported via “Search → Export” in the Iris UI. + Example: `aGVsbG93b3yY2hfaGFzaF9jb2RlJsZF9zZWFcw==` + Sha1HexString: + type: string + description: | + A 40-character hexadecimal SHA-1 hash string, typically used to identify SSL certificates or digital signatures. + pattern: ^[a-fA-F0-9]{40}$ + SslSubjectDnString: + type: string + minLength: 1 + maxLength: 1024 + description: | + A distinguished name (DN) string representing the Subject field of an SSL certificate. + Typically includes components like CN, O, C, ST, and L in a comma-separated format. + Example: `C=US, ST=California, L=San Francisco, O=Example Corp, CN=example.com` + TimestampFilter: + type: string + format: date-time + description: | + An RFC 3339-compliant timestamp (e.g., `2025-01-01T00:00:00Z`) used to filter results by datetime thresholds. + TimeWindowSeconds: + type: integer + format: int64 + minimum: 1 + description: | + A positive integer representing a time window, in seconds. Used to filter results based on how recently an event occurred. + TopLevelDomain: + type: string + minLength: 2 + maxLength: 128 + pattern: ^[a-z]{2,63}(\.[a-z]{2,63})*$ + description: | + A top-level or public suffix domain, such as `com`, `org`, or `co.uk`. Must not include a leading dot. + WebsiteTitleString: + type: string + minLength: 1 + maxLength: 512 + description: | + The exact value of the `<title>` tag from a website's HTML. + DomainProfileTemplate: + type: object + description: A comprehensive template defining the complete structure of a domain profile. Specific data types are defined in inheriting schemas. + properties: + domain: + type: string + whois_url: + type: string + adsense: {} + alexa: + type: integer + popularity_rank: + type: number + active: + type: boolean + google_analytics: {} + ga4: + type: array + items: {} + gtm_codes: + type: array + items: {} + fb_codes: + type: array + items: {} + hotjar_codes: + type: array + items: {} + baidu_codes: + type: array + items: {} + yandex_codes: + type: array + items: {} + matomo_codes: + type: array + items: {} + statcounter_project_codes: + type: array + items: {} + statcounter_security_codes: + type: array + items: {} + admin_contact: + $ref: '#/components/schemas/BaseContact' + billing_contact: + $ref: '#/components/schemas/BaseContact' + registrant_contact: + $ref: '#/components/schemas/BaseContact' + technical_contact: + $ref: '#/components/schemas/BaseContact' + create_date: {} + expiration_date: {} + email_domain: + type: array + items: {} + soa_email: + type: array + items: {} + ssl_email: + type: array + items: {} + additional_whois_email: + type: array + items: {} + ip: + type: array + items: + type: object + properties: + address: {} + asn: + type: array + items: {} + country_code: {} + isp: {} + mx: + type: array + items: + type: object + properties: + host: {} + domain: {} + ip: + type: array + items: {} + priority: + type: number + name_server: + type: array + items: + type: object + properties: + host: {} + domain: {} + ip: + type: array + items: {} + domain_risk: + $ref: '#/components/schemas/DomainRisk' + redirect: {} + redirect_domain: {} + registrant_name: {} + registrant_org: {} + registrar: {} + registrar_status: + type: array + items: + type: string + spf_info: + type: string + ssl_info: + type: array + items: + $ref: '#/components/schemas/BaseSslInfo' + tld: + type: string + website_response: + type: number + data_updated_timestamp: + $ref: '#/components/schemas/TimestampFilter' + website_title: {} + server_type: {} + first_seen: {} + tags: + type: array + items: + type: object + properties: + label: + type: string + scope: + type: string + tagged_at: + type: string + parsed_whois: + $ref: '#/components/schemas/ParsedWhois' + parsed_domain_rdap: + $ref: '#/components/schemas/ParsedDomainRdap' + BaseContact: + type: object + description: A template for contact information blocks. + properties: + name: {} + org: {} + street: {} + city: {} + state: {} + postal: {} + country: {} + phone: {} + fax: {} + email: + type: array + items: {} + BaseSslInfo: + type: object + description: A template for SSL certificate information blocks. + properties: + hash: {} + subject: {} + organization: {} + email: + type: array + items: + type: string + alt_names: + type: array + items: {} + sources: + type: object + properties: + active: + type: integer + passive: + type: integer + common_name: {} + issuer_common_name: {} + not_after: {} + not_before: {} + duration: {} + DomainRisk: + type: object + properties: + risk_score: + $ref: '#/components/schemas/RiskScoreValue' + components: + type: array + description: A list of risk components and their individual scores that contribute to the overall domain risk. + items: + $ref: '#/components/schemas/DomainRiskComponent' + DomainRiskComponent: + description: A polymorphic schema representing one of several types of risk components. + oneOf: + - $ref: '#/components/schemas/Proximity' + - $ref: '#/components/schemas/ThreatProfile' + - $ref: '#/components/schemas/ThreatProfileMalware' + - $ref: '#/components/schemas/ThreatProfilePhishing' + - $ref: '#/components/schemas/ThreatProfileSpam' + - $ref: '#/components/schemas/ZeroListedScore' + discriminator: + propertyName: name + mapping: + proximity: '#/components/schemas/Proximity' + threat_profile: '#/components/schemas/ThreatProfile' + threat_profile_malware: '#/components/schemas/ThreatProfileMalware' + threat_profile_phishing: '#/components/schemas/ThreatProfilePhishing' + threat_profile_spam: '#/components/schemas/ThreatProfileSpam' + zerolist: '#/components/schemas/ZeroListedScore' + EnrichValue: + type: object + properties: + value: + type: string + EnrichTracker: + type: object + description: A tracker identifier. + properties: + value: + $ref: '#/components/schemas/IdentifierString' + EnrichRequestParameters: + type: object + required: + - domain + properties: + domain: + $ref: '#/components/parameters/domainsQueryRequired/schema' + app_partner: + $ref: '#/components/schemas/IdentifierString' + app_name: + $ref: '#/components/schemas/IdentifierString' + app_version: + $ref: '#/components/schemas/IdentifierString' + format: + $ref: '#/components/schemas/ResponseFormat' + parsed_whois: + $ref: '#/components/schemas/BooleanOptInFlag' + parsed_domain_rdap: + $ref: '#/components/schemas/BooleanOptInFlag' + EnrichResult: + allOf: + - $ref: '#/components/schemas/DomainProfileTemplate' + properties: + adsense: + $ref: '#/components/schemas/EnrichTracker' + google_analytics: + $ref: '#/components/schemas/EnrichTracker' + ga4: + type: array + items: + $ref: '#/components/schemas/EnrichTracker' + gtm_codes: + type: array + items: + $ref: '#/components/schemas/EnrichTracker' + fb_codes: + type: array + items: + $ref: '#/components/schemas/EnrichTracker' + hotjar_codes: + type: array + items: + $ref: '#/components/schemas/EnrichTracker' + baidu_codes: + type: array + items: + $ref: '#/components/schemas/EnrichTracker' + yandex_codes: + type: array + items: + $ref: '#/components/schemas/EnrichTracker' + matomo_codes: + type: array + items: + $ref: '#/components/schemas/EnrichTracker' + statcounter_project_codes: + type: array + items: + $ref: '#/components/schemas/EnrichTracker' + statcounter_security_codes: + type: array + items: + $ref: '#/components/schemas/EnrichTracker' + admin_contact: + $ref: '#/components/schemas/EnrichContact' + billing_contact: + $ref: '#/components/schemas/EnrichContact' + registrant_contact: + $ref: '#/components/schemas/EnrichContact' + technical_contact: + $ref: '#/components/schemas/EnrichContact' + create_date: + $ref: '#/components/schemas/EnrichValue' + expiration_date: + $ref: '#/components/schemas/EnrichValue' + email_domain: + type: array + items: + $ref: '#/components/schemas/EnrichValue' + soa_email: + type: array + items: + $ref: '#/components/schemas/EnrichValue' + ssl_email: + type: array + items: + $ref: '#/components/schemas/EnrichValue' + additional_whois_email: + type: array + items: + $ref: '#/components/schemas/EnrichValue' + ip: + type: array + items: + type: object + properties: + address: + $ref: '#/components/schemas/EnrichValue' + asn: + type: array + items: + $ref: '#/components/schemas/EnrichValue' + country_code: + $ref: '#/components/schemas/EnrichValue' + isp: + $ref: '#/components/schemas/EnrichValue' + mx: + type: array + items: + type: object + properties: + host: + $ref: '#/components/schemas/EnrichValue' + domain: + $ref: '#/components/schemas/EnrichValue' + ip: + type: array + items: + $ref: '#/components/schemas/EnrichValue' + priority: + type: number + name_server: + type: array + items: + type: object + properties: + host: + $ref: '#/components/schemas/EnrichValue' + domain: + $ref: '#/components/schemas/EnrichValue' + ip: + type: array + items: + $ref: '#/components/schemas/EnrichValue' + redirect: + $ref: '#/components/schemas/EnrichValue' + redirect_domain: + $ref: '#/components/schemas/EnrichValue' + registrant_name: + $ref: '#/components/schemas/EnrichValue' + registrant_org: + $ref: '#/components/schemas/EnrichValue' + registrar: + $ref: '#/components/schemas/EnrichValue' + server_type: + $ref: '#/components/schemas/EnrichValue' + first_seen: + $ref: '#/components/schemas/EnrichValue' + website_title: + $ref: '#/components/schemas/EnrichValue' + ssl_info: + type: array + items: + allOf: + - $ref: '#/components/schemas/BaseSslInfo' + properties: + hash: + $ref: '#/components/schemas/EnrichValue' + subject: + $ref: '#/components/schemas/EnrichValue' + organization: + $ref: '#/components/schemas/EnrichValue' + alt_names: + type: array + items: + $ref: '#/components/schemas/EnrichValue' + common_name: + $ref: '#/components/schemas/EnrichValue' + issuer_common_name: + $ref: '#/components/schemas/EnrichValue' + not_after: + type: integer + not_before: + type: integer + duration: + type: integer + EnrichContact: + allOf: + - $ref: '#/components/schemas/BaseContact' + properties: + name: + $ref: '#/components/schemas/EnrichValue' + org: + $ref: '#/components/schemas/EnrichValue' + street: + $ref: '#/components/schemas/EnrichValue' + city: + $ref: '#/components/schemas/EnrichValue' + state: + $ref: '#/components/schemas/EnrichValue' + postal: + $ref: '#/components/schemas/EnrichValue' + country: + $ref: '#/components/schemas/EnrichValue' + phone: + $ref: '#/components/schemas/EnrichValue' + fax: + $ref: '#/components/schemas/EnrichValue' + email: + type: array + items: + $ref: '#/components/schemas/EnrichValue' + PivotedValue: + type: object + properties: + value: + type: string + count: + type: integer + format: int64 + description: The number of other domains that share this exact value. + PivotedTracker: + type: object + description: A tracker identifier and its associated pivot count. + properties: + value: + $ref: '#/components/schemas/IdentifierString' + count: + type: integer + format: int64 + description: The number of other domains that share this exact value. + InvestigateResult: + allOf: + - $ref: '#/components/schemas/DomainProfileTemplate' + properties: + adsense: + $ref: '#/components/schemas/PivotedTracker' + google_analytics: + $ref: '#/components/schemas/PivotedTracker' + ga4: + type: array + items: + $ref: '#/components/schemas/PivotedTracker' + gtm_codes: + type: array + items: + $ref: '#/components/schemas/PivotedTracker' + fb_codes: + type: array + items: + $ref: '#/components/schemas/PivotedTracker' + hotjar_codes: + type: array + items: + $ref: '#/components/schemas/PivotedTracker' + baidu_codes: + type: array + items: + $ref: '#/components/schemas/PivotedTracker' + yandex_codes: + type: array + items: + $ref: '#/components/schemas/PivotedTracker' + matomo_codes: + type: array + items: + $ref: '#/components/schemas/PivotedTracker' + statcounter_project_codes: + type: array + items: + $ref: '#/components/schemas/PivotedTracker' + statcounter_security_codes: + type: array + items: + $ref: '#/components/schemas/PivotedTracker' + admin_contact: + $ref: '#/components/schemas/PivotedContact' + billing_contact: + $ref: '#/components/schemas/PivotedContact' + registrant_contact: + $ref: '#/components/schemas/PivotedContact' + technical_contact: + $ref: '#/components/schemas/PivotedContact' + create_date: + $ref: '#/components/schemas/PivotedValue' + expiration_date: + $ref: '#/components/schemas/PivotedValue' + email_domain: + type: array + items: + $ref: '#/components/schemas/PivotedValue' + soa_email: + type: array + items: + $ref: '#/components/schemas/PivotedValue' + ssl_email: + type: array + items: + $ref: '#/components/schemas/PivotedValue' + additional_whois_email: + type: array + items: + $ref: '#/components/schemas/PivotedValue' + ip: + type: array + items: + type: object + properties: + address: + $ref: '#/components/schemas/PivotedValue' + asn: + type: array + items: + $ref: '#/components/schemas/PivotedValue' + country_code: + $ref: '#/components/schemas/PivotedValue' + isp: + $ref: '#/components/schemas/PivotedValue' + mx: + type: array + items: + type: object + properties: + host: + $ref: '#/components/schemas/PivotedValue' + domain: + $ref: '#/components/schemas/PivotedValue' + ip: + type: array + items: + $ref: '#/components/schemas/PivotedValue' + priority: + type: number + name_server: + type: array + items: + type: object + properties: + host: + $ref: '#/components/schemas/PivotedValue' + domain: + $ref: '#/components/schemas/PivotedValue' + ip: + type: array + items: + $ref: '#/components/schemas/PivotedValue' + redirect: + $ref: '#/components/schemas/PivotedValue' + redirect_domain: + $ref: '#/components/schemas/PivotedValue' + registrant_name: + $ref: '#/components/schemas/PivotedValue' + registrant_org: + $ref: '#/components/schemas/PivotedValue' + registrar: + $ref: '#/components/schemas/PivotedValue' + server_type: + $ref: '#/components/schemas/PivotedValue' + first_seen: + $ref: '#/components/schemas/PivotedValue' + website_title: + $ref: '#/components/schemas/PivotedValue' + ssl_info: + type: array + items: + allOf: + - $ref: '#/components/schemas/BaseSslInfo' + properties: + hash: + $ref: '#/components/schemas/PivotedValue' + subject: + $ref: '#/components/schemas/PivotedValue' + organization: + $ref: '#/components/schemas/PivotedValue' + alt_names: + type: array + items: + $ref: '#/components/schemas/PivotedValue' + common_name: + $ref: '#/components/schemas/PivotedValue' + issuer_common_name: + $ref: '#/components/schemas/PivotedValue' + not_after: + $ref: '#/components/schemas/PivotedValue' + not_before: + $ref: '#/components/schemas/PivotedValue' + duration: + $ref: '#/components/schemas/PivotedValue' + Monitor: + type: object + description: Details of a single Iris Detect monitor. + properties: + term: + type: string + match_substring_variations: + type: boolean + nameserver_exclusions: + type: array + items: + type: string + text_exclusions: + type: array + items: + type: string + id: + type: string + created_date: + type: string + format: date-time + updated_date: + type: string + format: date-time + state: + type: string + enum: + - active + - inactive + status: + type: string + domain_counts: + $ref: '#/components/schemas/MonitorDomainCounts' + created_by: + type: string + MonitorDomainCounts: + type: object + description: Counts of domains associated with a monitor in various states. + properties: + new: + type: integer + description: The number of new domains discovered. + watched: + type: integer + description: The number of domains currently on the watchlist. + escalated: + type: integer + description: The number of domains that have been escalated. + changed: + type: integer + description: The number of watched domains that have changed. + MonitorList: + type: object + description: A list of Iris Detect monitors and associated metadata. + properties: + total_count: + type: integer + offset: + type: integer + limit: + type: integer + monitors: + type: array + items: + $ref: '#/components/schemas/Monitor' + PivotedContact: + allOf: + - $ref: '#/components/schemas/BaseContact' + properties: + name: + $ref: '#/components/schemas/PivotedValue' + org: + $ref: '#/components/schemas/PivotedValue' + street: + $ref: '#/components/schemas/PivotedValue' + city: + $ref: '#/components/schemas/PivotedValue' + state: + $ref: '#/components/schemas/PivotedValue' + postal: + $ref: '#/components/schemas/PivotedValue' + country: + $ref: '#/components/schemas/PivotedValue' + phone: + $ref: '#/components/schemas/PivotedValue' + fax: + $ref: '#/components/schemas/PivotedValue' + email: + type: array + items: + $ref: '#/components/schemas/PivotedValue' + InvestigateRequestParameters: + type: object + properties: + adsense: + $ref: '#/components/schemas/IdentifierString' + baidu_analytics: + $ref: '#/components/schemas/IdentifierString' + contact_name: + $ref: '#/components/parameters/contactName/schema' + contact_phone: + $ref: '#/components/parameters/contactPhone/schema' + contact_street: + $ref: '#/components/parameters/contactStreet/schema' + ip: + $ref: '#/components/schemas/IPv4Address' + domain: + $ref: '#/components/parameters/domainsQuery/schema' + email: + $ref: '#/components/parameters/emailAny/schema' + email_dns_soa: + $ref: '#/components/parameters/emailDnsSoa/schema' + email_domain: + $ref: '#/components/schemas/ApexDomain' + historical_free_text: + $ref: '#/components/parameters/whoisHistoricalFreeText/schema' + facebook: + $ref: '#/components/schemas/IdentifierString' + google_analytics_4: + $ref: '#/components/schemas/IdentifierString' + google_analytics: + $ref: '#/components/schemas/IdentifierString' + google_tag_manager: + $ref: '#/components/schemas/IdentifierString' + hotjar: + $ref: '#/components/schemas/IdentifierString' + iana_id: + $ref: '#/components/schemas/IdentifierString' + mailserver_domain: + $ref: '#/components/schemas/ApexDomain' + mailserver_host: + $ref: '#/components/parameters/mailserverHost/schema' + mailserver_ip: + $ref: '#/components/schemas/IPv4Address' + matomo: + $ref: '#/components/schemas/IdentifierString' + nameserver_domain: + $ref: '#/components/schemas/Hostname' + nameserver_host: + $ref: '#/components/schemas/Hostname' + nameserver_ip: + $ref: '#/components/schemas/IPv4Address' + redirect_domain: + $ref: '#/components/parameters/redirectDomain/schema' + registrant: + $ref: '#/components/parameters/registrant/schema' + historical_registrant: + $ref: '#/components/parameters/registrantHistoricalWhois/schema' + registrant_org: + $ref: '#/components/parameters/registrantOrg/schema' + registrar: + $ref: '#/components/parameters/registrar/schema' + search_hash: + $ref: '#/components/parameters/searchHash/schema' + server_type: + $ref: '#/components/schemas/BasicString' + ssl_alt_names: + $ref: '#/components/parameters/sslAltNames/schema' + ssl_common_name: + $ref: '#/components/parameters/sslCommonName/schema' + ssl_duration: + $ref: '#/components/parameters/sslDuration/schema' + ssl_email: + $ref: '#/components/parameters/sslEmail/schema' + ssl_hash: + $ref: '#/components/parameters/sslHash/schema' + ssl_org: + $ref: '#/components/parameters/sslOrg/schema' + ssl_subject: + $ref: '#/components/parameters/sslSubject/schema' + statcounter_project: + $ref: '#/components/schemas/IdentifierString' + statcounter_security: + $ref: '#/components/schemas/IdentifierString' + tagged_with_all: + $ref: '#/components/parameters/taggedWithAll/schema' + tagged_with_any: + $ref: '#/components/parameters/taggedWithAny/schema' + website_title: + $ref: '#/components/parameters/websiteTitle/schema' + whois: + $ref: '#/components/parameters/whoisFreeText/schema' + yandex_metrica: + $ref: '#/components/schemas/IdentifierString' + active: + $ref: '#/components/parameters/active/schema' + create_date: + $ref: '#/components/parameters/createDate/schema' + create_date_within: + $ref: '#/components/parameters/createDateWithin/schema' + expiration_date: + $ref: '#/components/parameters/expirationDate/schema' + first_seen_since: + $ref: '#/components/parameters/firstSeenSince/schema' + first_seen_within: + $ref: '#/components/parameters/firstSeenWithin/schema' + not_tagged_with_all: + $ref: '#/components/parameters/notTaggedWithAll/schema' + not_tagged_with_any: + $ref: '#/components/parameters/notTaggedWithAny/schema' + tld: + $ref: '#/components/parameters/topLevelDomain/schema' + parsed_domain_rdap: + $ref: '#/components/schemas/BooleanOptInFlag' + parsed_whois: + $ref: '#/components/schemas/BooleanOptInFlag' + next: + $ref: '#/components/schemas/BooleanOptInFlag' + format: + $ref: '#/components/schemas/ResponseFormat' + page_size: + $ref: '#/components/schemas/schema' + position: + $ref: '#/components/parameters/resultsPosition/schema' + sort_by: + $ref: '#/components/parameters/resultsSortBy/schema' + sort_direction: + $ref: '#/components/parameters/resultsSortDirection/schema' + app_name: + $ref: '#/components/schemas/IdentifierString' + app_partner: + $ref: '#/components/schemas/IdentifierString' + app_version: + $ref: '#/components/schemas/IdentifierString' + ParsedDomainRdap: + type: object + description: Parsed data from the domain's RDAP record. Present only when the request includes `parsed_domain_rdap=true`. + properties: + registrant_contact: + $ref: '#/components/schemas/RdapContact' + admin_contact: + $ref: '#/components/schemas/RdapContact' + technical_contact: + $ref: '#/components/schemas/RdapContact' + registrant_name: + $ref: '#/components/schemas/PivotedValue' + registrar_iana_id: + $ref: '#/components/schemas/PivotedValue' + additional_whois_email: + type: array + items: + $ref: '#/components/schemas/PivotedValue' + registrar: + $ref: '#/components/schemas/PivotedValue' + create_date: + $ref: '#/components/schemas/PivotedValue' + expiration_date: + $ref: '#/components/schemas/PivotedValue' + registrar_status: + type: array + items: + $ref: '#/components/schemas/PivotedValue' + email_domain: + type: array + items: + $ref: '#/components/schemas/PivotedValue' + ParsedWhois: + type: object + description: Parsed data from the domain's WHOIS record. Present only when the request includes `parsed_whois=true`. + properties: + technical_contact: + $ref: '#/components/schemas/WhoisContact' + registrant_contact: + $ref: '#/components/schemas/WhoisContact' + admin_contact: + $ref: '#/components/schemas/WhoisContact' + additional_whois_email: + type: array + items: + $ref: '#/components/schemas/PivotedValue' + registrar: + $ref: '#/components/schemas/PivotedValue' + create_date: + $ref: '#/components/schemas/PivotedValue' + expiration_date: + $ref: '#/components/schemas/PivotedValue' + registrant_name: + $ref: '#/components/schemas/PivotedValue' + registrant_org: + $ref: '#/components/schemas/PivotedValue' + registrar_status: + type: array + items: + $ref: '#/components/schemas/PivotedValue' + Proximity: + type: object + required: + - name + - risk_score + properties: + name: + type: string + description: The type of risk component. + enum: + - proximity + risk_score: + $ref: '#/components/schemas/RiskScoreValue' + RdapContact: + type: object + description: Contact details from an RDAP record. + properties: + name: + $ref: '#/components/schemas/PivotedValue' + org: + $ref: '#/components/schemas/PivotedValue' + street: + $ref: '#/components/schemas/PivotedValue' + city: + $ref: '#/components/schemas/PivotedValue' + state: + $ref: '#/components/schemas/PivotedValue' + postal: + $ref: '#/components/schemas/PivotedValue' + country: + $ref: '#/components/schemas/PivotedValue' + phone: + $ref: '#/components/schemas/PivotedValue' + fax: + $ref: '#/components/schemas/PivotedValue' + email: + type: array + items: + $ref: '#/components/schemas/PivotedValue' + ThreatProfile: + type: object + required: + - name + - risk_score + properties: + name: + type: string + description: The type of risk component. + enum: + - threat_profile + risk_score: + $ref: '#/components/schemas/RiskScoreValue' + description: Indicates risk based on machine-learning models. + ThreatProfileMalware: + type: object + required: + - name + - risk_score + properties: + name: + type: string + description: The type of risk component. + enum: + - threat_profile_malware + risk_score: + $ref: '#/components/schemas/RiskScoreValue' + description: Indicates risk based on a machine-learning model that classifies domains as malware-related. + ThreatProfilePhishing: + type: object + required: + - name + - risk_score + properties: + name: + type: string + description: The type of risk component. + enum: + - threat_profile_phishing + risk_score: + $ref: '#/components/schemas/RiskScoreValue' + description: Indicates risk based on a machine-learning model that classifies domains as phishing-related. + ThreatProfileSpam: + type: object + required: + - name + - risk_score + properties: + name: + type: string + description: The type of risk component. + enum: + - threat_profile_spam + risk_score: + $ref: '#/components/schemas/RiskScoreValue' + description: Indicates risk based on a machine-learning model that classifies domains as spam-related. + Watchlist: + type: object + properties: + watchlist_domains: + type: array + items: + type: object + properties: + state: + type: string + enum: + - watched + - ignored + domain: + type: string + discovered_date: + type: string + changed_date: + type: string + id: + type: string + assigned_by: + type: string + assigned_date: + type: string + WatchlistBase: + type: object + properties: + watchlist_domain_ids: + type: array + items: + type: string + app_partner: + $ref: '#/components/schemas/IdentifierString' + app_name: + $ref: '#/components/schemas/IdentifierString' + app_version: + $ref: '#/components/schemas/IdentifierString' + WatchlistState: + allOf: + - $ref: '#/components/schemas/WatchlistBase' + - type: object + properties: + state: + type: string + enum: + - watched + - ignored + WatchlistEscalation: + allOf: + - $ref: '#/components/schemas/WatchlistBase' + - type: object + properties: + escalation_type: + $ref: '#/components/schemas/EscalationTypeEnum' + WhoisContact: + type: object + description: Contact details from a WHOIS record. + properties: + name: + $ref: '#/components/schemas/PivotedValue' + org: + $ref: '#/components/schemas/PivotedValue' + street: + $ref: '#/components/schemas/PivotedValue' + city: + $ref: '#/components/schemas/PivotedValue' + state: + $ref: '#/components/schemas/PivotedValue' + postal: + $ref: '#/components/schemas/PivotedValue' + country: + $ref: '#/components/schemas/PivotedValue' + phone: + $ref: '#/components/schemas/PivotedValue' + fax: + $ref: '#/components/schemas/PivotedValue' + email: + type: array + items: + $ref: '#/components/schemas/PivotedValue' + ZeroListedScore: + type: object + required: + - name + - risk_score + properties: + name: + type: string + description: The type of risk component. + enum: + - zerolist + risk_score: + type: integer + description: The risk score for a zerolisted domain, which is always 0. + enum: + - 0 + description: Indicates the domain is on a known-good 'zero list' and is not considered a threat. + securitySchemes: + header_auth: + type: apiKey + in: header + name: X-Api-Key + description: | + A secure authentication method where the API key is provided in the `X-Api-Key` HTTP request header. + open_key_auth: + type: http + scheme: basic + description: | + A simple authentication scheme using your `api_username` as the username and your `api_key` as the password. We strongly recommend using HMAC or Header authentication instead to avoid exposing your credentials. + hmac_auth: + type: apiKey + in: query + name: signature + description: | + A secure scheme using a Hashed Message Authentication Code (HMAC). + This method requires three query parameters to be sent with the request: + 1. `api_username`: Your API username. + 2. `timestamp`: The current timestamp in ISO 8601 format (e.g., `2020-02-01T22:37:59Z`). + 3. `signature`: The HMAC signature of the request. This is a hash (SHA-256 recommended) of your username, the timestamp, and the request URI, signed with your secret API key. diff --git a/domaintools/utils.py b/domaintools/utils.py index 3cfc75a..7025136 100644 --- a/domaintools/utils.py +++ b/domaintools/utils.py @@ -1,9 +1,11 @@ +import functools +import re + from datetime import datetime from typing import Optional from domaintools.constants import Endpoint, OutputFormat - -import re +from domaintools.docstring_patcher import DocstringPatcher def get_domain_age(create_date): @@ -109,7 +111,9 @@ def prune_data(data_obj): prune_data(item) if not isinstance(item, int) and not item: items_to_prune.append(index) - data_obj[:] = [item for index, item in enumerate(data_obj) if index not in items_to_prune and len(item)] + data_obj[:] = [ + item for index, item in enumerate(data_obj) if index not in items_to_prune and len(item) + ] def find_emails(data_str): @@ -183,3 +187,39 @@ def validate_feeds_parameters(params): endpoint = params.get("endpoint") if endpoint == Endpoint.DOWNLOAD.value and format == OutputFormat.CSV.value: raise ValueError(f"{format} format is not available in {Endpoint.DOWNLOAD.value} API.") + + +def api_endpoint(spec_name: str, path: str): + """Decorator to tag a method as a GET API endpoint.""" + + def decorator(func): + func._api_spec_name = spec_name + func._api_path = path + + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + return func(*args, **kwargs) + + wrapper._api_spec_name = spec_name + wrapper._api_path = path + return wrapper + + return decorator + + +def auto_patch_docstrings(cls): + original_init = cls.__init__ + + @functools.wraps(original_init) + def new_init(self, *args, **kwargs): + original_init(self, *args, **kwargs) + try: + # We instantiate our patcher and run it + patcher = DocstringPatcher() + patcher.patch(self) + except Exception as e: + print(f"Auto-patching failed: {e}") + + cls.__init__ = new_init + + return cls From bfb010c4f0d8cb0562b87d730db6fe51b28f8a0e Mon Sep 17 00:00:00 2001 From: JD Babac <jbabac@domaintools.com> Date: Wed, 5 Nov 2025 22:54:27 +0800 Subject: [PATCH 2/8] IDEV-2204: Add tests. --- tests/test_docstring_patcher.py | 293 ++++++++++++++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 tests/test_docstring_patcher.py diff --git a/tests/test_docstring_patcher.py b/tests/test_docstring_patcher.py new file mode 100644 index 0000000..48535fb --- /dev/null +++ b/tests/test_docstring_patcher.py @@ -0,0 +1,293 @@ +import pytest +import inspect +import functools + +from domaintools.docstring_patcher import DocstringPatcher + + +@pytest.fixture +def patcher(): + """Returns an instance of the class under test.""" + return DocstringPatcher() + + +@pytest.fixture(scope="module") +def sample_spec(): + """ + Provides a comprehensive, reusable mock OpenAPI spec dictionary. + """ + return { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "components": { + "parameters": { + "LimitParam": { + "name": "limit", + "in": "query", + "description": "Max number of items to return.", + "schema": {"type": "integer"}, + } + }, + "schemas": { + "User": { + "type": "object", + "properties": {"name": {"type": "string"}}, + } + }, + "requestBodies": { + "UserBody": { + "description": "User object to create.", + "required": True, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/User"}} + }, + } + }, + }, + "paths": { + "/users": { + "get": { + "summary": "Get all users", + "description": "Returns a list of users.", + "externalDocs": {"url": "http://docs.example.com/get-users"}, + "parameters": [ + { + "name": "status", + "in": "query", + "required": True, + "description": "User's current status.", + "schema": {"type": "string"}, + }, + {"$ref": "#/components/parameters/LimitParam"}, + ], + }, + "post": { + "summary": "Create a new user", + "description": "Creates a single new user.", + "requestBody": {"$ref": "#/components/requestBodies/UserBody"}, + }, + }, + "/pets/{petId}": { + "get": { + "summary": "Get a single pet", + "description": "Returns one pet by ID.", + } + }, + "/health": { + # This path exists, but has no operations (get, post, etc.) + "description": "Health check path." + }, + }, + } + + +@pytest.fixture +def mock_api_instance(sample_spec): + """ + Provides a mock API instance with decorated methods + that the DocstringPatcher will look for. + """ + + # This decorator mimics the one you'd use in your real API class + def api_endpoint(spec_name, path): + def decorator(func): + func._api_spec_name = spec_name + func._api_path = path + + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + return decorator + + class MockAPI: + def __init__(self, specs): + # The patcher expects the instance to have a 'specs' attribute + self.specs = specs + + @api_endpoint(spec_name="v1", path="/users") + def user_operations(self): + """This is the original user docstring.""" + return "user_operations_called" + + @api_endpoint(spec_name="v1", path="/pets/{petId}") + def get_pet(self): + """Original pet docstring.""" + return "get_pet_called" + + @api_endpoint(spec_name="v1", path="/health") + def health_check(self): + """Original health docstring.""" + return "health_check_called" + + @api_endpoint(spec_name="v2_nonexistent", path="/users") + def bad_spec_name(self): + """Original bad spec docstring.""" + return "bad_spec_called" + + @api_endpoint(spec_name="v1", path="/nonexistent-path") + def bad_path(self): + """Original bad path docstring.""" + return "bad_path_called" + + def not_an_api_method(self): + """Internal method docstring.""" + return "internal_called" + + # Create an instance, passing in the spec fixture + api_instance = MockAPI(specs={"v1": sample_spec}) + return api_instance + + +# --- Test Cases --- + + +def test_patch_method_still_callable(patcher, mock_api_instance): + """ + Ensures that after patching, the method can still be called + and returns its original value. + """ + # Act + patcher.patch(mock_api_instance) + + # Assert + assert mock_api_instance.user_operations() == "user_operations_called" + assert mock_api_instance.get_pet() == "get_pet_called" + + +def test_patch_leaves_unmarked_methods_alone(patcher, mock_api_instance): + """ + Tests that methods without the decorator are not modified. + """ + # Arrange + original_doc = inspect.getdoc(mock_api_instance.not_an_api_method) + + # Act + patcher.patch(mock_api_instance) + + # Assert + new_doc = inspect.getdoc(mock_api_instance.not_an_api_method) + assert new_doc == original_doc + assert new_doc == "Internal method docstring." + + +def test_patch_preserves_original_docstring(patcher, mock_api_instance): + """ + Tests that the new docstring starts with the original docstring. + """ + # Arrange + original_doc = inspect.getdoc(mock_api_instance.user_operations) + assert original_doc == "This is the original user docstring." + + # Act + patcher.patch(mock_api_instance) + + # Assert + new_doc = inspect.getdoc(mock_api_instance.user_operations) + assert new_doc.startswith(original_doc) + assert len(new_doc) > len(original_doc) + + +def test_patch_handles_multiple_operations(patcher, mock_api_instance): + """ + Tests that a single method gets docs for ALL operations + on its path (e.g., GET and POST for /users). + """ + # Act + patcher.patch(mock_api_instance) + new_doc = inspect.getdoc(mock_api_instance.user_operations) + + # Assert + # Check for original doc + assert new_doc.startswith("This is the original user docstring.") + + # Check for GET operation details + assert "--- Operation: GET /users ---" in new_doc + assert "Summary: Get all users" in new_doc + assert "External Doc: http://docs.example.com/get-users" in new_doc + assert "**status** (string)" in new_doc + assert "Required: True" in new_doc + assert "Description: User's current status." in new_doc + + # Check for $ref'd query param + assert "**limit** (integer)" in new_doc + assert "Description: Max number of items to return." in new_doc + + # Check for POST operation details + assert "--- Operation: POST /users ---" in new_doc + assert "Summary: Create a new user" in new_doc + assert "Request Body:" in new_doc + + # Check for $ref'd request body + assert "**User**" in new_doc + assert "Description: User object to create." in new_doc + assert "Required: True" in new_doc + + +def test_patch_handles_single_operation(patcher, mock_api_instance): + """ + Tests a path with only one operation (GET) and no params/body. + """ + # Act + patcher.patch(mock_api_instance) + new_doc = inspect.getdoc(mock_api_instance.get_pet) + + # Assert + assert new_doc.startswith("Original pet docstring.") + assert "--- Operation: GET /pets/{petId} ---" in new_doc + assert "Summary: Get a single pet" in new_doc + + # Check for empty sections + assert "Query Parameters:\n (No query parameters)" in new_doc + assert "Request Body:\n (No request body)" in new_doc + + # Ensure other methods aren't included + assert "--- Operation: POST /pets/{petId} ---" not in new_doc + + +def test_patch_spec_not_found(patcher, mock_api_instance): + """ + Tests that an error message is added to the doc if the + spec name (e.g., 'v2_nonexistent') isn't in the instance's 'specs' dict. + """ + # Act + patcher.patch(mock_api_instance) + new_doc = inspect.getdoc(mock_api_instance.bad_spec_name) + + # Assert + assert new_doc.startswith("Original bad spec docstring.") + # Check for the specific error message your code generates + assert "--- API Details Error ---" in new_doc + assert "(Could not find any operations for path '/users')" in new_doc + + +def test_patch_path_not_found(patcher, mock_api_instance): + """ + Tests that an error message is added if the path exists on the method + but not in the spec file. + """ + # Act + patcher.patch(mock_api_instance) + new_doc = inspect.getdoc(mock_api_instance.bad_path) + + # Assert + assert new_doc.startswith("Original bad path docstring.") + assert "--- API Details Error ---" in new_doc + assert "(Could not find any operations for path '/nonexistent-path')" in new_doc + + +def test_patch_path_found_but_no_operations(patcher, mock_api_instance): + """ + Tests that an error message is added if the path (/health) is in + the spec but has no operations (get, post, etc.) defined. + """ + # Act + patcher.patch(mock_api_instance) + new_doc = inspect.getdoc(mock_api_instance.health_check) + + # Assert + assert new_doc.startswith("Original health docstring.") + assert "--- API Details Error ---" in new_doc + assert "(Could not find any operations for path '/health')" in new_doc From cb77e444d7cf5d8df229bf2f8488c021d9cc0b5e Mon Sep 17 00:00:00 2001 From: JD Babac <jbabac@domaintools.com> Date: Thu, 6 Nov 2025 03:34:12 +0800 Subject: [PATCH 3/8] IDEV-2204: Fix header key for api_key --- domaintools/base_results.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/domaintools/base_results.py b/domaintools/base_results.py index 35a6479..29c6704 100644 --- a/domaintools/base_results.py +++ b/domaintools/base_results.py @@ -94,8 +94,7 @@ def _get_session_params_and_headers(self): headers["accept"] = HEADER_ACCEPT_KEY_CSV_FORMAT if self.api.header_authentication: - header_key_for_api_key = "X-Api-Key" if is_rttf_product else "X-API-Key" - headers[header_key_for_api_key] = self.api.key + headers["X-Api-Key"] = self.api.key session_param_and_headers = {"parameters": parameters, "headers": headers} return session_param_and_headers @@ -342,7 +341,9 @@ def html(self): ) def as_list(self): - return "\n".join([json.dumps(item, indent=4, separators=(",", ": ")) for item in self._items()]) + return "\n".join( + [json.dumps(item, indent=4, separators=(",", ": ")) for item in self._items()] + ) def __str__(self): return str( From ad179d709b35e9b845327e4f33435d4f2eeadf24 Mon Sep 17 00:00:00 2001 From: JD Babac <jbabac@domaintools.com> Date: Thu, 6 Nov 2025 03:39:37 +0800 Subject: [PATCH 4/8] IDEV-2204: Specify methods to patch and allow patching of multiple methods per endpoint. --- domaintools/api.py | 2 +- domaintools/docstring_patcher.py | 36 +++++++++++++++++++++++++------- domaintools/utils.py | 26 ++++++++++++++++++----- 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/domaintools/api.py b/domaintools/api.py index aa2e15a..85e1e7c 100644 --- a/domaintools/api.py +++ b/domaintools/api.py @@ -667,7 +667,7 @@ def iris_enrich_cli(self, domains=None, **kwargs): **kwargs, ) - @api_endpoint(spec_name="iris", path="/v1/iris-investigate/") + @api_endpoint(spec_name="iris", path="/v1/iris-investigate/", methods="post") def iris_investigate( self, domains=None, diff --git a/domaintools/docstring_patcher.py b/domaintools/docstring_patcher.py index 2543efd..cb765b1 100644 --- a/domaintools/docstring_patcher.py +++ b/domaintools/docstring_patcher.py @@ -12,11 +12,11 @@ def patch(self, api_instance): method_names = [] for attr_name in dir(api_instance): attr = getattr(api_instance, attr_name) - # Look for the new decorator's tags if ( inspect.ismethod(attr) and hasattr(attr, "_api_spec_name") and hasattr(attr, "_api_path") + and hasattr(attr, "_api_methods") ): method_names.append(attr_name) @@ -26,6 +26,7 @@ def patch(self, api_instance): spec_name = original_function._api_spec_name path = original_function._api_path + http_methods_to_check = original_function._api_methods spec_to_use = api_instance.specs.get(spec_name) original_doc = inspect.getdoc(original_function) or "" @@ -34,20 +35,17 @@ def patch(self, api_instance): if spec_to_use: path_item = spec_to_use.get("paths", {}).get(path, {}) - # Loop over all HTTP methods defined for this path - for http_method in ["get", "post", "put", "delete", "patch"]: + for http_method in http_methods_to_check: if http_method in path_item: - # Generate a doc section for this specific operation api_doc = self._generate_api_doc_string(spec_to_use, path, http_method) all_doc_sections.append(api_doc) if not all_doc_sections: all_doc_sections.append( f"\n--- API Details Error ---" - f"\n (Could not find any operations for path '{path}')" + f"\n (Could not find operations {http_methods_to_check} for path '{path}')" ) - # Combine the original doc with all operation docs new_doc = textwrap.dedent(original_doc) + "\n\n" + "\n\n".join(all_doc_sections) @functools.wraps(original_function) @@ -68,10 +66,11 @@ def _generate_api_doc_string(self, spec: dict, path: str, method: str) -> str: # Add a clear title for this specific method lines = [f"--- Operation: {method.upper()} {path} ---"] - # Render Query Params lines.append(f"\n Summary: {details.get('summary')}") lines.append(f" Description: {details.get('description')}") lines.append(f" External Doc: {details.get('external_doc')}") + + # Render Query Params lines.append("\n Query Parameters:") if not details["query_params"]: lines.append(" (No query parameters)") @@ -102,16 +101,36 @@ def _get_operation_details(self, spec: dict, path: str, method: str) -> dict: operation = path_item.get(method.lower(), {}) if not operation: return details - all_param_defs = path_item.get("parameters", []) + operation.get("parameters", []) + + # Get params defined at the path level (shared by all) + path_level_params = path_item.get("parameters", []) + + # Get params defined at the operation level (specific to this method) + operation_level_params = operation.get("parameters", []) + + # If this operation has no params, AND it's not GET, + # AND a GET operation exists, then borrow GET's params. + if ( + not operation_level_params + and method.lower() in ["post", "put", "patch", "delete"] + and "get" in path_item + ): + get_operation = path_item.get("get", {}) + operation_level_params = get_operation.get("parameters", []) + + all_param_defs = path_level_params + operation_level_params + details["summary"] = operation.get("summary") details["description"] = operation.get("description") details["external_doc"] = operation.get("externalDocs", {}).get("url", "N/A") + resolved_params = [] for param_def in all_param_defs: if "$ref" in param_def: resolved_params.append(self._resolve_ref(spec, param_def["$ref"])) else: resolved_params.append(param_def) + for p in [p for p in resolved_params if p.get("in") == "query"]: details["query_params"].append( { @@ -121,6 +140,7 @@ def _get_operation_details(self, spec: dict, path: str, method: str) -> dict: "type": self._get_param_type(spec, p.get("schema")), } ) + body_def = operation.get("requestBody") if body_def: if "$ref" in body_def: diff --git a/domaintools/utils.py b/domaintools/utils.py index 7025136..5322c9b 100644 --- a/domaintools/utils.py +++ b/domaintools/utils.py @@ -2,7 +2,7 @@ import re from datetime import datetime -from typing import Optional +from typing import List, Optional, Union from domaintools.constants import Endpoint, OutputFormat from domaintools.docstring_patcher import DocstringPatcher @@ -189,19 +189,35 @@ def validate_feeds_parameters(params): raise ValueError(f"{format} format is not available in {Endpoint.DOWNLOAD.value} API.") -def api_endpoint(spec_name: str, path: str): - """Decorator to tag a method as a GET API endpoint.""" +def api_endpoint(spec_name: str, path: str, methods: Union[str, List[str]]): + """ + Decorator to tag a method as an API endpoint. + + Args: + spec_name: The key for the spec in api_instance.specs + path: The API path (e.g., "/users") + methods: A single method ("get") or list of methods (["get", "post"]) + that this function handles. + """ def decorator(func): func._api_spec_name = spec_name func._api_path = path + # Always store the methods as a list + if isinstance(methods, str): + func._api_methods = [methods] + else: + func._api_methods = methods + @functools.wraps(func) def wrapper(self, *args, **kwargs): return func(*args, **kwargs) - wrapper._api_spec_name = spec_name - wrapper._api_path = path + # Copy all tags to the wrapper + wrapper._api_spec_name = func._api_spec_name + wrapper._api_path = func._api_path + wrapper._api_methods = func._api_methods return wrapper return decorator From db6dad8d34277341645c312b510616b59110c798 Mon Sep 17 00:00:00 2001 From: JD Babac <jbabac@domaintools.com> Date: Thu, 6 Nov 2025 03:39:46 +0800 Subject: [PATCH 5/8] IDEV-2204: Adjust tests --- tests/test_docstring_patcher.py | 229 ++++++++++++++++++++------------ 1 file changed, 145 insertions(+), 84 deletions(-) diff --git a/tests/test_docstring_patcher.py b/tests/test_docstring_patcher.py index 48535fb..eb7adf2 100644 --- a/tests/test_docstring_patcher.py +++ b/tests/test_docstring_patcher.py @@ -1,8 +1,9 @@ import pytest import inspect -import functools +# Import the class to be tested from domaintools.docstring_patcher import DocstringPatcher +from domaintools.utils import api_endpoint @pytest.fixture @@ -64,6 +65,7 @@ def sample_spec(): "post": { "summary": "Create a new user", "description": "Creates a single new user.", + # NOTE: No 'parameters' key here, will inherit from GET "requestBody": {"$ref": "#/components/requestBodies/UserBody"}, }, }, @@ -86,48 +88,34 @@ def mock_api_instance(sample_spec): """ Provides a mock API instance with decorated methods that the DocstringPatcher will look for. - """ - - # This decorator mimics the one you'd use in your real API class - def api_endpoint(spec_name, path): - def decorator(func): - func._api_spec_name = spec_name - func._api_path = path - - @functools.wraps(func) - def wrapper(self, *args, **kwargs): - return func(*args, **kwargs) - return wrapper - - return decorator + """ class MockAPI: def __init__(self, specs): - # The patcher expects the instance to have a 'specs' attribute self.specs = specs - @api_endpoint(spec_name="v1", path="/users") + @api_endpoint(spec_name="v1", path="/users", methods=["get", "post"]) def user_operations(self): """This is the original user docstring.""" return "user_operations_called" - @api_endpoint(spec_name="v1", path="/pets/{petId}") + @api_endpoint(spec_name="v1", path="/pets/{petId}", methods="get") def get_pet(self): """Original pet docstring.""" return "get_pet_called" - @api_endpoint(spec_name="v1", path="/health") + @api_endpoint(spec_name="v1", path="/health", methods="get") def health_check(self): """Original health docstring.""" return "health_check_called" - @api_endpoint(spec_name="v2_nonexistent", path="/users") + @api_endpoint(spec_name="v2_nonexistent", path="/users", methods="get") def bad_spec_name(self): """Original bad spec docstring.""" return "bad_spec_called" - @api_endpoint(spec_name="v1", path="/nonexistent-path") + @api_endpoint(spec_name="v1", path="/nonexistent-path", methods="get") def bad_path(self): """Original bad path docstring.""" return "bad_path_called" @@ -136,12 +124,11 @@ def not_an_api_method(self): """Internal method docstring.""" return "internal_called" - # Create an instance, passing in the spec fixture api_instance = MockAPI(specs={"v1": sample_spec}) return api_instance -# --- Test Cases --- +# --- Original Test Cases (Updated) --- def test_patch_method_still_callable(patcher, mock_api_instance): @@ -149,10 +136,7 @@ def test_patch_method_still_callable(patcher, mock_api_instance): Ensures that after patching, the method can still be called and returns its original value. """ - # Act patcher.patch(mock_api_instance) - - # Assert assert mock_api_instance.user_operations() == "user_operations_called" assert mock_api_instance.get_pet() == "get_pet_called" @@ -161,13 +145,8 @@ def test_patch_leaves_unmarked_methods_alone(patcher, mock_api_instance): """ Tests that methods without the decorator are not modified. """ - # Arrange original_doc = inspect.getdoc(mock_api_instance.not_an_api_method) - - # Act patcher.patch(mock_api_instance) - - # Assert new_doc = inspect.getdoc(mock_api_instance.not_an_api_method) assert new_doc == original_doc assert new_doc == "Internal method docstring." @@ -177,117 +156,199 @@ def test_patch_preserves_original_docstring(patcher, mock_api_instance): """ Tests that the new docstring starts with the original docstring. """ - # Arrange original_doc = inspect.getdoc(mock_api_instance.user_operations) assert original_doc == "This is the original user docstring." - - # Act patcher.patch(mock_api_instance) - - # Assert new_doc = inspect.getdoc(mock_api_instance.user_operations) assert new_doc.startswith(original_doc) assert len(new_doc) > len(original_doc) -def test_patch_handles_multiple_operations(patcher, mock_api_instance): +def test_patch_handles_multiple_operations_and_inheritance(patcher, mock_api_instance): """ - Tests that a single method gets docs for ALL operations - on its path (e.g., GET and POST for /users). + Tests that a method with methods=["get", "post"] gets docs + for BOTH operations and that POST inherits GET's params. """ - # Act patcher.patch(mock_api_instance) new_doc = inspect.getdoc(mock_api_instance.user_operations) - # Assert # Check for original doc assert new_doc.startswith("This is the original user docstring.") - # Check for GET operation details - assert "--- Operation: GET /users ---" in new_doc - assert "Summary: Get all users" in new_doc - assert "External Doc: http://docs.example.com/get-users" in new_doc - assert "**status** (string)" in new_doc - assert "Required: True" in new_doc - assert "Description: User's current status." in new_doc + # --- Check GET operation details (the source) --- + get_section_index = new_doc.find("--- Operation: GET /users ---") + assert get_section_index != -1 + get_section = new_doc[get_section_index:] - # Check for $ref'd query param - assert "**limit** (integer)" in new_doc - assert "Description: Max number of items to return." in new_doc + assert "Summary: Get all users" in get_section + assert "External Doc: http://docs.example.com/get-users" in get_section + assert "**status** (string)" in get_section + assert "Required: True" in get_section + assert "Description: User's current status." in get_section + assert "**limit** (integer)" in get_section # $ref'd param + assert "Description: Max number of items to return." in get_section - # Check for POST operation details - assert "--- Operation: POST /users ---" in new_doc - assert "Summary: Create a new user" in new_doc - assert "Request Body:" in new_doc + # --- Check POST operation details (the inheritor) --- + post_section_index = new_doc.find("--- Operation: POST /users ---") + assert post_section_index != -1 + post_section = new_doc[post_section_index:] - # Check for $ref'd request body - assert "**User**" in new_doc - assert "Description: User object to create." in new_doc - assert "Required: True" in new_doc + assert "Summary: Create a new user" in post_section + assert "Request Body:" in post_section + assert "**User**" in post_section # $ref'd body + + # --- Check for INHERITED parameters --- + assert "**status** (string)" in post_section + assert "Required: True" in post_section + assert "Description: User's current status." in post_section + assert "**limit** (integer)" in post_section + assert "Description: Max number of items to return." in post_section def test_patch_handles_single_operation(patcher, mock_api_instance): """ Tests a path with only one operation (GET) and no params/body. """ - # Act patcher.patch(mock_api_instance) new_doc = inspect.getdoc(mock_api_instance.get_pet) - # Assert assert new_doc.startswith("Original pet docstring.") assert "--- Operation: GET /pets/{petId} ---" in new_doc assert "Summary: Get a single pet" in new_doc - - # Check for empty sections assert "Query Parameters:\n (No query parameters)" in new_doc assert "Request Body:\n (No request body)" in new_doc - - # Ensure other methods aren't included assert "--- Operation: POST /pets/{petId} ---" not in new_doc def test_patch_spec_not_found(patcher, mock_api_instance): """ - Tests that an error message is added to the doc if the - spec name (e.g., 'v2_nonexistent') isn't in the instance's 'specs' dict. + Tests error message if the spec name isn't in the 'specs' dict. """ - # Act patcher.patch(mock_api_instance) new_doc = inspect.getdoc(mock_api_instance.bad_spec_name) - - # Assert assert new_doc.startswith("Original bad spec docstring.") - # Check for the specific error message your code generates assert "--- API Details Error ---" in new_doc - assert "(Could not find any operations for path '/users')" in new_doc + assert "Could not find operations ['get']" in new_doc + assert "for path '/users'" in new_doc def test_patch_path_not_found(patcher, mock_api_instance): """ - Tests that an error message is added if the path exists on the method - but not in the spec file. + Tests error message if the path is not in the spec file. """ - # Act patcher.patch(mock_api_instance) new_doc = inspect.getdoc(mock_api_instance.bad_path) - - # Assert assert new_doc.startswith("Original bad path docstring.") assert "--- API Details Error ---" in new_doc - assert "(Could not find any operations for path '/nonexistent-path')" in new_doc + assert "for path '/nonexistent-path'" in new_doc def test_patch_path_found_but_no_operations(patcher, mock_api_instance): """ - Tests that an error message is added if the path (/health) is in - the spec but has no operations (get, post, etc.) defined. + Tests error message if the path is in the spec + but the specific method ("get") is not. """ - # Act patcher.patch(mock_api_instance) new_doc = inspect.getdoc(mock_api_instance.health_check) - - # Assert assert new_doc.startswith("Original health docstring.") assert "--- API Details Error ---" in new_doc - assert "(Could not find any operations for path '/health')" in new_doc + assert "for path '/health'" in new_doc + + +def test_post_inherits_get_parameters(patcher): + """ + Tests that a POST operation with no parameters defined + successfully inherits parameters from the GET operation + at the same path. + """ + # Arrange: Create a minimal spec to test this exact behavior + inheritance_spec = { + "openapi": "3.0.0", + "info": {"title": "Inheritance Test API"}, + "paths": { + "/widgets": { + "get": { + "summary": "Get widgets", + "parameters": [ + { + "name": "color", + "in": "query", + "description": "Widget color", + "schema": {"type": "string"}, + } + ], + }, + "post": { + "summary": "Create a widget", + # No 'parameters' key, should inherit from GET. + "requestBody": {"description": "Widget to create"}, + }, + } + }, + } + + class MockAPI: + def __init__(self, specs): + self.specs = specs + + @api_endpoint(spec_name="v1", path="/widgets", methods=["get", "post"]) + def widget_operations(self): + """Original widget docstring.""" + pass + + api_instance = MockAPI(specs={"v1": inheritance_spec}) + + patcher.patch(api_instance) + new_doc = inspect.getdoc(api_instance.widget_operations) + + # Assert + assert new_doc.startswith("Original widget docstring.") + + # Find the POST section and check for INHERITED params + post_section_index = new_doc.find("--- Operation: POST /widgets ---") + assert post_section_index > -1, "POST operation section not found" + + post_section = new_doc[post_section_index:] + assert "Summary: Create a widget" in post_section + assert "**color** (string)" in post_section + assert "Widget color" in post_section + + +def test_post_does_not_inherit_when_get_has_no_params(patcher): + """ + Tests that a POST operation does not inherit anything + if the GET operation also has no parameters. + """ + no_params_spec = { + "openapi": "3.0.0", + "paths": { + "/items": { + "get": {"summary": "Get items"}, + "post": {"summary": "Create an item"}, + } + }, + } + + class MockAPI: + def __init__(self, specs): + self.specs = specs + + @api_endpoint(spec_name="v1", path="/items", methods=["get", "post"]) + def item_operations(self): + """Original item docstring.""" + pass + + api_instance = MockAPI(specs={"v1": no_params_spec}) + + patcher.patch(api_instance) + new_doc = inspect.getdoc(api_instance.item_operations) + + # Check GET section + get_section_index = new_doc.find("--- Operation: GET /items ---") + get_section = new_doc[get_section_index:] + assert "Query Parameters:\n (No query parameters)" in get_section + + # Check POST section + post_section_index = new_doc.find("--- Operation: POST /items ---") + post_section = new_doc[post_section_index:] + assert "Query Parameters:\n (No query parameters)" in post_section From 274f17e75718879c2fbc17195c4f9b387df44b64 Mon Sep 17 00:00:00 2001 From: JD Babac <jbabac@domaintools.com> Date: Sat, 8 Nov 2025 02:02:50 +0800 Subject: [PATCH 6/8] IDEV-2204: Adjust implementation based on comments for post methods that should display Request Body instead of Query Parameters --- domaintools/docstring_patcher.py | 232 +++++++++++++++++++++++++------ 1 file changed, 191 insertions(+), 41 deletions(-) diff --git a/domaintools/docstring_patcher.py b/domaintools/docstring_patcher.py index cb765b1..45f4f54 100644 --- a/domaintools/docstring_patcher.py +++ b/domaintools/docstring_patcher.py @@ -1,11 +1,19 @@ import inspect import functools import textwrap +import logging class DocstringPatcher: """ Patches docstrings for methods decorated with @api_endpoint. + - Uses the 'methods' list provided by the decorator. + - Finds non-standard parameters inside the 'requestBody' object. + - Displays Query Params, Request Body, and Result Body (Responses) + for all operations. + - Unpacks and displays properties of request body schemas. + - Searches components.parameters for request body properties + that match by name. """ def patch(self, api_instance): @@ -24,9 +32,9 @@ def patch(self, api_instance): original_method = getattr(api_instance, attr_name) original_function = original_method.__func__ - spec_name = original_function._api_spec_name - path = original_function._api_path - http_methods_to_check = original_function._api_methods + spec_name = getattr(original_function, "_api_spec_name", None) + path = getattr(original_function, "_api_path", None) + http_methods_to_check = getattr(original_function, "_api_methods", []) spec_to_use = api_instance.specs.get(spec_name) original_doc = inspect.getdoc(original_function) or "" @@ -34,16 +42,15 @@ def patch(self, api_instance): all_doc_sections = [] if spec_to_use: path_item = spec_to_use.get("paths", {}).get(path, {}) - for http_method in http_methods_to_check: - if http_method in path_item: + if http_method.lower() in path_item: api_doc = self._generate_api_doc_string(spec_to_use, path, http_method) all_doc_sections.append(api_doc) if not all_doc_sections: all_doc_sections.append( f"\n--- API Details Error ---" - f"\n (Could not find operations {http_methods_to_check} for path '{path}')" + f"\n (Could not find operations {http_methods_to_check} for path '{path}' in spec '{spec_name}')" ) new_doc = textwrap.dedent(original_doc) + "\n\n" + "\n\n".join(all_doc_sections) @@ -63,14 +70,13 @@ def _generate_api_doc_string(self, spec: dict, path: str, method: str) -> str: """Creates the formatted API docstring section for ONE operation.""" details = self._get_operation_details(spec, path, method) - # Add a clear title for this specific method lines = [f"--- Operation: {method.upper()} {path} ---"] - lines.append(f"\n Summary: {details.get('summary')}") - lines.append(f" Description: {details.get('description')}") - lines.append(f" External Doc: {details.get('external_doc')}") + lines.append(f"\n Summary: {details.get('summary', 'N/A')}") + lines.append(f" Description: {details.get('description', 'N/A')}") + lines.append(f" External Doc: {details.get('external_doc', 'N/A')}") - # Render Query Params + # 1. Always display Query Parameters lines.append("\n Query Parameters:") if not details["query_params"]: lines.append(" (No query parameters)") @@ -80,7 +86,7 @@ def _generate_api_doc_string(self, spec: dict, path: str, method: str) -> str: lines.append(f" Required: {param['required']}") lines.append(f" Description: {param['description']}") - # Render Request Body + # 2. Always display Request Body lines.append("\n Request Body:") if not details["request_body"]: lines.append(" (No request body)") @@ -90,40 +96,77 @@ def _generate_api_doc_string(self, spec: dict, path: str, method: str) -> str: lines.append(f" Required: {body['required']}") lines.append(f" Description: {body['description']}") + if body.get("properties"): + lines.append(f" Properties:") + for prop in body["properties"]: + lines.append(f"\n **{prop['name']}** ({prop['type']})") + lines.append(f" Description: {prop['description']}") + + if body.get("parameters"): + lines.append(f" Parameters (associated with this body):") + for param in body["parameters"]: + param_in = param.get("in", "N/A") + lines.append( + f"\n **{param['name']}** ({param['type']}) [in: {param_in}]" + ) + lines.append(f" Required: {param['required']}") + lines.append(f" Description: {param['description']}") + + # 3. Always display Result Body (Responses) + lines.append("\n Result Body (Responses):") + if not details["responses"]: + lines.append(" (No responses defined in spec)") + else: + for resp in details["responses"]: + lines.append(f"\n **{resp['status_code']}**: ({resp['type']})") + lines.append(f" Description: {resp['description']}") + return "\n".join(lines) def _get_operation_details(self, spec: dict, path: str, method: str) -> dict: - details = {"query_params": [], "request_body": None} + """ + Gets all details. Includes: + - Logic to find non-standard 'parameters' in 'requestBody' + - Logic to parse requestBody schema properties + - Logic to parse responses + - **NEW**: Logic to match requestBody properties to components/parameters + """ + details = {"query_params": [], "request_body": None, "responses": []} if not spec: return details + try: + # --- Get component parameters for lookup --- + components = spec.get("components", {}) + all_component_params = components.get("parameters", {}) + path_item = spec.get("paths", {}).get(path, {}) operation = path_item.get(method.lower(), {}) if not operation: return details - # Get params defined at the path level (shared by all) + # --- Parameter Logic --- path_level_params = path_item.get("parameters", []) - - # Get params defined at the operation level (specific to this method) operation_level_params = operation.get("parameters", []) + body_level_params = [] - # If this operation has no params, AND it's not GET, - # AND a GET operation exists, then borrow GET's params. - if ( - not operation_level_params - and method.lower() in ["post", "put", "patch", "delete"] - and "get" in path_item - ): - get_operation = path_item.get("get", {}) - operation_level_params = get_operation.get("parameters", []) + body_def = operation.get("requestBody") + resolved_body_def = {} + if body_def: + if "$ref" in body_def: + resolved_body_def = self._resolve_ref(spec, body_def["$ref"]) + else: + resolved_body_def = body_def + body_level_params = resolved_body_def.get("parameters", []) all_param_defs = path_level_params + operation_level_params + # --- End Parameter Logic --- details["summary"] = operation.get("summary") details["description"] = operation.get("description") details["external_doc"] = operation.get("externalDocs", {}).get("url", "N/A") + # --- Query Param Processing (from path/operation only) --- resolved_params = [] for param_def in all_param_defs: if "$ref" in param_def: @@ -140,45 +183,152 @@ def _get_operation_details(self, spec: dict, path: str, method: str) -> dict: "type": self._get_param_type(spec, p.get("schema")), } ) + # --- End Query Param Processing --- - body_def = operation.get("requestBody") + # --- Request Body Processing --- if body_def: - if "$ref" in body_def: - body_def = self._resolve_ref(spec, body_def["$ref"]) - content = body_def.get("content", {}) + content = resolved_body_def.get("content", {}) media_type = next(iter(content.values()), None) + schema_type = "N/A" + schema = {} + if media_type and "schema" in media_type: schema = media_type["schema"] schema_type = self._get_param_type(spec, schema) - if "$ref" in schema: - schema_type = schema["$ref"].split("/")[-1] - details["request_body"] = { - "required": body_def.get("required", False), - "description": body_def.get("description", "N/A"), - "type": schema_type, + + details["request_body"] = { + "required": resolved_body_def.get("required", False), + "description": resolved_body_def.get("description", "N/A"), + "type": schema_type, + "parameters": [], + "properties": [], + } + + # --- Process schema properties with new lookup logic --- + resolved_schema = {} + if "$ref" in schema: + resolved_schema = self._resolve_ref(spec, schema["$ref"]) + elif schema.get("type") == "object": + resolved_schema = schema + + if resolved_schema.get("type") == "object" and "properties" in resolved_schema: + for prop_name, prop_def in resolved_schema["properties"].items(): + + found_param_match = False + # --- Try to find a match in components/parameters --- + # (Iterate over values, e.g., the LimitParam object) + for component_param_def in all_component_params.values(): + if component_param_def.get("name") == prop_name: + # Found a match! Use its details. + prop_type = self._get_param_type( + spec, component_param_def.get("schema") + ) + prop_desc = component_param_def.get("description", "N/A") + details["request_body"]["properties"].append( + {"name": prop_name, "type": prop_type, "description": prop_desc} + ) + found_param_match = True + break + + if not found_param_match: + # No match, process as a normal schema property + prop_type = self._get_param_type(spec, prop_def) + prop_desc = prop_def.get("description", "N/A") + details["request_body"]["properties"].append( + {"name": prop_name, "type": prop_type, "description": prop_desc} + ) + + # --- Body Parameter Processing (for non-standard spec) --- + resolved_body_params = [] + for param_def in body_level_params: + if "$ref" in param_def: + resolved_body_params.append(self._resolve_ref(spec, param_def["$ref"])) + else: + resolved_body_params.append(param_def) + + for p in resolved_body_params: + details["request_body"]["parameters"].append( + { + "name": p.get("name"), + "in": p.get("in"), + "required": p.get("required", False), + "description": p.get("description", "N/A"), + "type": self._get_param_type(spec, p.get("schema")), + } + ) + # --- End Request Body Processing --- + + # --- Response Processing Logic --- + responses_def = operation.get("responses", {}) + for status_code, resp_def in responses_def.items(): + resolved_resp = {} + if "$ref" in resp_def: + resolved_resp = self._resolve_ref(spec, resp_def["$ref"]) + else: + resolved_resp = resp_def + + description = resolved_resp.get("description", "N/A") + resp_type = "N/A" + content = resolved_resp.get("content", {}) + media_type = next(iter(content.values()), None) + + if media_type and "schema" in media_type: + schema = media_type["schema"] + resp_type = self._get_param_type(spec, schema) + + details["responses"].append( + { + "status_code": status_code, + "description": description, + "type": resp_type, } + ) + # --- END: Response Processing Logic --- + return details - except Exception: + except Exception as e: + logging.warning(f"Error parsing spec for {method.upper()} {path}: {e}", exc_info=True) return details def _resolve_ref(self, spec: dict, ref: str): + """Resolves a JSON schema $ref string.""" if not spec or not ref.startswith("#/"): return {} parts = ref.split("/")[1:] current_obj = spec for part in parts: - if not isinstance(current_obj, dict): + if isinstance(current_obj, list): + try: + current_obj = current_obj[int(part)] + except (IndexError, ValueError): + return {} + elif isinstance(current_obj, dict): + current_obj = current_obj.get(part) + else: return {} - current_obj = current_obj.get(part) if current_obj is None: return {} return current_obj def _get_param_type(self, spec: dict, schema: dict) -> str: + """Gets a display-friendly type name from a schema object.""" if not schema: return "N/A" + + # Check for malformed refs (like in your example spec) schema_ref = schema.get("$ref") + if not schema_ref: + # Handle user's typo: "$ref:" + schema_ref = schema.get("$ref:") + if schema_ref: - resolved_schema = self._resolve_ref(spec, schema_ref) - return resolved_schema.get("type", "N/A") - return schema.get("type", "N/A") + return schema_ref.split("/")[-1] + + schema_type = schema.get("type", "N/A") + + if schema_type == "array": + items_schema = schema.get("items", {}) + items_type = self._get_param_type(spec, items_schema) + return f"array[{items_type}]" + + return schema_type From 8a6618bfe7926bc44fb7bc6e97995ba88172153d Mon Sep 17 00:00:00 2001 From: JD Babac <jbabac@domaintools.com> Date: Sat, 8 Nov 2025 02:03:10 +0800 Subject: [PATCH 7/8] IDEV-2204: Adjust tests. --- tests/test_docstring_patcher.py | 677 ++++++++++++++++---------------- 1 file changed, 337 insertions(+), 340 deletions(-) diff --git a/tests/test_docstring_patcher.py b/tests/test_docstring_patcher.py index eb7adf2..065bea2 100644 --- a/tests/test_docstring_patcher.py +++ b/tests/test_docstring_patcher.py @@ -1,354 +1,351 @@ -import pytest -import inspect +import logging +import types + +from unittest.mock import Mock -# Import the class to be tested from domaintools.docstring_patcher import DocstringPatcher -from domaintools.utils import api_endpoint - - -@pytest.fixture -def patcher(): - """Returns an instance of the class under test.""" - return DocstringPatcher() - - -@pytest.fixture(scope="module") -def sample_spec(): - """ - Provides a comprehensive, reusable mock OpenAPI spec dictionary. - """ - return { - "openapi": "3.0.0", - "info": {"title": "Test API", "version": "1.0.0"}, - "components": { - "parameters": { - "LimitParam": { - "name": "limit", - "in": "query", - "description": "Max number of items to return.", - "schema": {"type": "integer"}, - } - }, - "schemas": { - "User": { - "type": "object", - "properties": {"name": {"type": "string"}}, - } + + +class TestDocstringPatcher: + + def _setup_mock_api( + self, + spec_dict: dict, + spec_name: str, + method_name: str, + path: str, + http_methods: list, + docstring: str, + ) -> Mock: + """ + Helper to create a mock API instance with a mock decorated method. + """ + # Create the mock API instance + mock_api = Mock() + mock_api.specs = {spec_name: spec_dict} + + # Create the underlying function that was decorated + def original_func(): + """Original docstring.""" + pass + + original_func.__doc__ = docstring + original_func._api_spec_name = spec_name + original_func._api_path = path + original_func._api_methods = http_methods + + # Create the mock instance method + mock_method = types.MethodType(original_func, mock_api) + setattr(mock_api, method_name, mock_method) + + return mock_api, method_name + + def setup_method(self, method): + """Pytest setup hook, runs before each test.""" + self.patcher = DocstringPatcher() + + # SPEC 1: The very first spec with non-standard 'parameters' in requestBody + self.SPEC_1_NON_STANDARD_PARAMS = { + "openapi": "3.0.0", + "info": {"title": "Spec 1"}, + "components": { + "parameters": { + "LimitParam": { + "name": "limit", + "in": "query", + "description": "Max number of items.", + "schema": {"type": "integer"}, + } + }, + "schemas": {"User": {"type": "object"}}, + "requestBodies": { + "UserBody": { + "description": "User object.", + "required": True, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/User"}} + }, + "parameters": [{"$ref": "#/components/parameters/LimitParam"}], + } + }, }, - "requestBodies": { - "UserBody": { - "description": "User object to create.", - "required": True, - "content": { - "application/json": {"schema": {"$ref": "#/components/schemas/User"}} + "paths": { + "/users": { + "post": { + "summary": "Create user", + "requestBody": {"$ref": "#/components/requestBodies/UserBody"}, }, } }, - }, - "paths": { - "/users": { - "get": { - "summary": "Get all users", - "description": "Returns a list of users.", - "externalDocs": {"url": "http://docs.example.com/get-users"}, - "parameters": [ - { - "name": "status", - "in": "query", - "required": True, - "description": "User's current status.", - "schema": {"type": "string"}, + } + + # SPEC 2: The spec with UserRequestParameters (name, age) + self.SPEC_2_SCHEMA_PROPS = { + "openapi": "3.0.0", + "info": {"title": "Spec 2"}, + "components": { + "schemas": { + "UserRequestParameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "User's name"}, + "age": {"type": "int", "description": "User's age"}, }, - {"$ref": "#/components/parameters/LimitParam"}, - ], + }, }, - "post": { - "summary": "Create a new user", - "description": "Creates a single new user.", - # NOTE: No 'parameters' key here, will inherit from GET - "requestBody": {"$ref": "#/components/requestBodies/UserBody"}, + "requestBodies": { + "UserBody": { + "description": "User object to create.", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/UserRequestParameters"} + } + }, + } }, }, - "/pets/{petId}": { - "get": { - "summary": "Get a single pet", - "description": "Returns one pet by ID.", + "paths": { + "/users": { + "post": { + "summary": "Create a new user", + "requestBody": {"$ref": "#/components/requestBodies/UserBody"}, + }, } }, - "/health": { - # This path exists, but has no operations (get, post, etc.) - "description": "Health check path." - }, - }, - } - - -@pytest.fixture -def mock_api_instance(sample_spec): - """ - Provides a mock API instance with decorated methods - that the DocstringPatcher will look for. - - """ - - class MockAPI: - def __init__(self, specs): - self.specs = specs - - @api_endpoint(spec_name="v1", path="/users", methods=["get", "post"]) - def user_operations(self): - """This is the original user docstring.""" - return "user_operations_called" - - @api_endpoint(spec_name="v1", path="/pets/{petId}", methods="get") - def get_pet(self): - """Original pet docstring.""" - return "get_pet_called" - - @api_endpoint(spec_name="v1", path="/health", methods="get") - def health_check(self): - """Original health docstring.""" - return "health_check_called" - - @api_endpoint(spec_name="v2_nonexistent", path="/users", methods="get") - def bad_spec_name(self): - """Original bad spec docstring.""" - return "bad_spec_called" - - @api_endpoint(spec_name="v1", path="/nonexistent-path", methods="get") - def bad_path(self): - """Original bad path docstring.""" - return "bad_path_called" - - def not_an_api_method(self): - """Internal method docstring.""" - return "internal_called" - - api_instance = MockAPI(specs={"v1": sample_spec}) - return api_instance - - -# --- Original Test Cases (Updated) --- - - -def test_patch_method_still_callable(patcher, mock_api_instance): - """ - Ensures that after patching, the method can still be called - and returns its original value. - """ - patcher.patch(mock_api_instance) - assert mock_api_instance.user_operations() == "user_operations_called" - assert mock_api_instance.get_pet() == "get_pet_called" - - -def test_patch_leaves_unmarked_methods_alone(patcher, mock_api_instance): - """ - Tests that methods without the decorator are not modified. - """ - original_doc = inspect.getdoc(mock_api_instance.not_an_api_method) - patcher.patch(mock_api_instance) - new_doc = inspect.getdoc(mock_api_instance.not_an_api_method) - assert new_doc == original_doc - assert new_doc == "Internal method docstring." - - -def test_patch_preserves_original_docstring(patcher, mock_api_instance): - """ - Tests that the new docstring starts with the original docstring. - """ - original_doc = inspect.getdoc(mock_api_instance.user_operations) - assert original_doc == "This is the original user docstring." - patcher.patch(mock_api_instance) - new_doc = inspect.getdoc(mock_api_instance.user_operations) - assert new_doc.startswith(original_doc) - assert len(new_doc) > len(original_doc) - - -def test_patch_handles_multiple_operations_and_inheritance(patcher, mock_api_instance): - """ - Tests that a method with methods=["get", "post"] gets docs - for BOTH operations and that POST inherits GET's params. - """ - patcher.patch(mock_api_instance) - new_doc = inspect.getdoc(mock_api_instance.user_operations) - - # Check for original doc - assert new_doc.startswith("This is the original user docstring.") - - # --- Check GET operation details (the source) --- - get_section_index = new_doc.find("--- Operation: GET /users ---") - assert get_section_index != -1 - get_section = new_doc[get_section_index:] - - assert "Summary: Get all users" in get_section - assert "External Doc: http://docs.example.com/get-users" in get_section - assert "**status** (string)" in get_section - assert "Required: True" in get_section - assert "Description: User's current status." in get_section - assert "**limit** (integer)" in get_section # $ref'd param - assert "Description: Max number of items to return." in get_section - - # --- Check POST operation details (the inheritor) --- - post_section_index = new_doc.find("--- Operation: POST /users ---") - assert post_section_index != -1 - post_section = new_doc[post_section_index:] - - assert "Summary: Create a new user" in post_section - assert "Request Body:" in post_section - assert "**User**" in post_section # $ref'd body - - # --- Check for INHERITED parameters --- - assert "**status** (string)" in post_section - assert "Required: True" in post_section - assert "Description: User's current status." in post_section - assert "**limit** (integer)" in post_section - assert "Description: Max number of items to return." in post_section - - -def test_patch_handles_single_operation(patcher, mock_api_instance): - """ - Tests a path with only one operation (GET) and no params/body. - """ - patcher.patch(mock_api_instance) - new_doc = inspect.getdoc(mock_api_instance.get_pet) - - assert new_doc.startswith("Original pet docstring.") - assert "--- Operation: GET /pets/{petId} ---" in new_doc - assert "Summary: Get a single pet" in new_doc - assert "Query Parameters:\n (No query parameters)" in new_doc - assert "Request Body:\n (No request body)" in new_doc - assert "--- Operation: POST /pets/{petId} ---" not in new_doc - - -def test_patch_spec_not_found(patcher, mock_api_instance): - """ - Tests error message if the spec name isn't in the 'specs' dict. - """ - patcher.patch(mock_api_instance) - new_doc = inspect.getdoc(mock_api_instance.bad_spec_name) - assert new_doc.startswith("Original bad spec docstring.") - assert "--- API Details Error ---" in new_doc - assert "Could not find operations ['get']" in new_doc - assert "for path '/users'" in new_doc - - -def test_patch_path_not_found(patcher, mock_api_instance): - """ - Tests error message if the path is not in the spec file. - """ - patcher.patch(mock_api_instance) - new_doc = inspect.getdoc(mock_api_instance.bad_path) - assert new_doc.startswith("Original bad path docstring.") - assert "--- API Details Error ---" in new_doc - assert "for path '/nonexistent-path'" in new_doc - - -def test_patch_path_found_but_no_operations(patcher, mock_api_instance): - """ - Tests error message if the path is in the spec - but the specific method ("get") is not. - """ - patcher.patch(mock_api_instance) - new_doc = inspect.getdoc(mock_api_instance.health_check) - assert new_doc.startswith("Original health docstring.") - assert "--- API Details Error ---" in new_doc - assert "for path '/health'" in new_doc - - -def test_post_inherits_get_parameters(patcher): - """ - Tests that a POST operation with no parameters defined - successfully inherits parameters from the GET operation - at the same path. - """ - # Arrange: Create a minimal spec to test this exact behavior - inheritance_spec = { - "openapi": "3.0.0", - "info": {"title": "Inheritance Test API"}, - "paths": { - "/widgets": { - "get": { - "summary": "Get widgets", - "parameters": [ - { - "name": "color", - "in": "query", - "description": "Widget color", - "schema": {"type": "string"}, - } - ], + } + + # SPEC 3: The final spec with the "lookup-by-name" logic + self.SPEC_3_LOOKUP_BY_NAME = { + "openapi": "3.0.0", + "info": {"title": "Spec 3"}, + "components": { + "parameters": { + "LimitParam": { + "name": "limit", + "in": "query", + "description": "Max number of items to return.", + "schema": {"type": "integer"}, + } }, - "post": { - "summary": "Create a widget", - # No 'parameters' key, should inherit from GET. - "requestBody": {"description": "Widget to create"}, + "schemas": { + "UserRequestParameters": { + "type": "object", + "properties": {"limit": {"$ref:": "#/components/schemas/ApexDomain"}}, + }, }, - } - }, - } - - class MockAPI: - def __init__(self, specs): - self.specs = specs - - @api_endpoint(spec_name="v1", path="/widgets", methods=["get", "post"]) - def widget_operations(self): - """Original widget docstring.""" - pass - - api_instance = MockAPI(specs={"v1": inheritance_spec}) - - patcher.patch(api_instance) - new_doc = inspect.getdoc(api_instance.widget_operations) - - # Assert - assert new_doc.startswith("Original widget docstring.") - - # Find the POST section and check for INHERITED params - post_section_index = new_doc.find("--- Operation: POST /widgets ---") - assert post_section_index > -1, "POST operation section not found" - - post_section = new_doc[post_section_index:] - assert "Summary: Create a widget" in post_section - assert "**color** (string)" in post_section - assert "Widget color" in post_section - - -def test_post_does_not_inherit_when_get_has_no_params(patcher): - """ - Tests that a POST operation does not inherit anything - if the GET operation also has no parameters. - """ - no_params_spec = { - "openapi": "3.0.0", - "paths": { - "/items": { - "get": {"summary": "Get items"}, - "post": {"summary": "Create an item"}, - } - }, - } - - class MockAPI: - def __init__(self, specs): - self.specs = specs - - @api_endpoint(spec_name="v1", path="/items", methods=["get", "post"]) - def item_operations(self): - """Original item docstring.""" - pass - - api_instance = MockAPI(specs={"v1": no_params_spec}) - - patcher.patch(api_instance) - new_doc = inspect.getdoc(api_instance.item_operations) - - # Check GET section - get_section_index = new_doc.find("--- Operation: GET /items ---") - get_section = new_doc[get_section_index:] - assert "Query Parameters:\n (No query parameters)" in get_section - - # Check POST section - post_section_index = new_doc.find("--- Operation: POST /items ---") - post_section = new_doc[post_section_index:] - assert "Query Parameters:\n (No query parameters)" in post_section + "requestBodies": { + "UserBody": { + "description": "User object to create.", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/UserRequestParameters"} + } + }, + } + }, + }, + "paths": { + "/users": { + "post": { + "summary": "Create a new user", + "requestBody": {"$ref": "#/components/requestBodies/UserBody"}, + }, + } + }, + } + + # SPEC 4: A full spec for GET, including Responses + self.SPEC_4_WITH_RESPONSE = { + "openapi": "3.0.0", + "info": {"title": "Spec 4"}, + "components": { + "parameters": { + "LimitParam": { + "name": "limit", + "in": "query", + "description": "Max items.", + "schema": {"type": "integer"}, + } + }, + "schemas": {"User": {"type": "object"}}, + }, + "paths": { + "/users": { + "get": { + "summary": "Get all users", + "parameters": [ + { + "name": "status", + "in": "query", + "required": True, + "description": "User status.", + "schema": {"type": "string"}, + }, + {"$ref": "#/components/parameters/LimitParam"}, + ], + "responses": { + "200": { + "description": "A list of users.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": {"$ref": "#/components/schemas/User"}, + } + } + }, + } + }, + }, + } + }, + } + + def test_spec_1_non_standard_params(self): + """ + Tests the first spec: parameters inside requestBody should + be displayed under 'Request Body'. + """ + mock_api, method_name = self._setup_mock_api( + spec_dict=self.SPEC_1_NON_STANDARD_PARAMS, + spec_name="spec1", + method_name="create_user", + path="/users", + http_methods=["post"], + docstring="This creates a user.", + ) + + self.patcher.patch(mock_api) + + doc = getattr(mock_api, method_name).__doc__ + + assert "This creates a user." in doc + assert "--- Operation: POST /users ---" in doc + assert "Request Body:" in doc + assert "**User**" in doc + assert "Parameters (associated with this body):" in doc + assert "**limit** (integer) [in: query]" in doc + assert "Description: Max number of items." in doc + assert "Query Parameters:" in doc + assert "(No query parameters)" in doc + + def test_spec_2_schema_props(self): + """ + Tests the second spec: requestBody schema properties (name, age) + should be unpacked and displayed. + """ + mock_api, method_name = self._setup_mock_api( + spec_dict=self.SPEC_2_SCHEMA_PROPS, + spec_name="spec2", + method_name="create_user", + path="/users", + http_methods=["post"], + docstring="Creates user.", + ) + + self.patcher.patch(mock_api) + + doc = getattr(mock_api, method_name).__doc__ + + assert "Creates user." in doc + assert "--- Operation: POST /users ---" in doc + assert "Request Body:" in doc + assert "**UserRequestParameters**" in doc + assert "Description: User object to create." in doc + assert "Properties:" in doc + assert "**name** (string)" in doc + assert "Description: User's name" in doc + assert "**age** (int)" in doc + assert "Description: User's age" in doc + assert "Parameters (associated with this body):" not in doc + + def test_spec_3_lookup_by_name(self): + """ + Tests the final spec: requestBody property 'limit' should + be matched with components.parameters.LimitParam by name. + """ + mock_api, method_name = self._setup_mock_api( + spec_dict=self.SPEC_3_LOOKUP_BY_NAME, + spec_name="spec3", + method_name="create_user", + path="/users", + http_methods=["post"], + docstring="Creates user.", + ) + + self.patcher.patch(mock_api) + + doc = getattr(mock_api, method_name).__doc__ + + assert "--- Operation: POST /users ---" in doc + assert "Request Body:" in doc + assert "**UserRequestParameters**" in doc + assert "Properties:" in doc + # This is the key assertion: + assert "**limit** (integer)" in doc + assert "Description: Max number of items to return." in doc + # Ensure it didn't use the $ref: value + assert "ApexDomain" not in doc + + def test_spec_4_get_with_response(self): + """ + Tests a GET operation with query params and a response. + """ + mock_api, method_name = self._setup_mock_api( + spec_dict=self.SPEC_4_WITH_RESPONSE, + spec_name="spec4", + method_name="get_users", + path="/users", + http_methods=["get"], + docstring="Gets users.", + ) + + self.patcher.patch(mock_api) + + doc = getattr(mock_api, method_name).__doc__ + + assert "Gets users." in doc + assert "--- Operation: GET /users ---" in doc + + # Check Query Params + assert "Query Parameters:" in doc + assert "**status** (string)" in doc + assert "Required: True" in doc + assert "Description: User status." in doc + assert "**limit** (integer)" in doc + assert "Description: Max items." in doc + + # Check Request Body + assert "Request Body:" in doc + assert "(No request body)" in doc + + # Check Responses + assert "Result Body (Responses):" in doc + assert "**200**: (array[User])" in doc + assert "Description: A list of users." in doc + + def test_patching_error_path(self, caplog): + """ + Tests that a failure to find the operation generates the + correct error docstring and logs a warning. + """ + mock_api, method_name = self._setup_mock_api( + spec_dict=self.SPEC_1_NON_STANDARD_PARAMS, # Spec doesn't matter + spec_name="spec1", + method_name="get_pets", + path="/pets", # This path doesn't exist in the spec + http_methods=["get"], + docstring="Original doc.", + ) + + with caplog.at_level(logging.WARNING): + self.patcher.patch(mock_api) + + doc = getattr(mock_api, method_name).__doc__ + + assert "Original doc." in doc + assert "--- API Details Error ---" in doc + assert "(Could not find operations ['get'] for path '/pets' in spec 'spec1')" in doc + + # Test that no *parsing* error was logged + assert "Error parsing spec" not in caplog.text From d0346ca2cea42986353cd038427be956929c631d Mon Sep 17 00:00:00 2001 From: JD Babac <jbabac@domaintools.com> Date: Wed, 12 Nov 2025 01:03:39 +0800 Subject: [PATCH 8/8] IDEV-2204: Move all decorators to a separate file --- domaintools/api.py | 7 ++--- domaintools/decorators.py | 57 +++++++++++++++++++++++++++++++++++++++ domaintools/utils.py | 56 +------------------------------------- 3 files changed, 60 insertions(+), 60 deletions(-) create mode 100644 domaintools/decorators.py diff --git a/domaintools/api.py b/domaintools/api.py index 85e1e7c..8a00f01 100644 --- a/domaintools/api.py +++ b/domaintools/api.py @@ -24,6 +24,7 @@ Results, FeedsResults, ) +from domaintools.decorators import api_endpoint, auto_patch_docstrings from domaintools.filters import ( filter_by_riskscore, filter_by_expire_date, @@ -31,11 +32,7 @@ filter_by_field, DTResultFilter, ) -from domaintools.utils import ( - api_endpoint, - auto_patch_docstrings, - validate_feeds_parameters, -) +from domaintools.utils import validate_feeds_parameters AVAILABLE_KEY_SIGN_HASHES = ["sha1", "sha256"] diff --git a/domaintools/decorators.py b/domaintools/decorators.py new file mode 100644 index 0000000..4640a8e --- /dev/null +++ b/domaintools/decorators.py @@ -0,0 +1,57 @@ +import functools + +from typing import List, Union + +from domaintools.docstring_patcher import DocstringPatcher + + +def api_endpoint(spec_name: str, path: str, methods: Union[str, List[str]]): + """ + Decorator to tag a method as an API endpoint. + + Args: + spec_name: The key for the spec in api_instance.specs + path: The API path (e.g., "/users") + methods: A single method ("get") or list of methods (["get", "post"]) + that this function handles. + """ + + def decorator(func): + func._api_spec_name = spec_name + func._api_path = path + + # Always store the methods as a list + if isinstance(methods, str): + func._api_methods = [methods] + else: + func._api_methods = methods + + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + return func(*args, **kwargs) + + # Copy all tags to the wrapper + wrapper._api_spec_name = func._api_spec_name + wrapper._api_path = func._api_path + wrapper._api_methods = func._api_methods + return wrapper + + return decorator + + +def auto_patch_docstrings(cls): + original_init = cls.__init__ + + @functools.wraps(original_init) + def new_init(self, *args, **kwargs): + original_init(self, *args, **kwargs) + try: + # We instantiate our patcher and run it + patcher = DocstringPatcher() + patcher.patch(self) + except Exception as e: + print(f"Auto-patching failed: {e}") + + cls.__init__ = new_init + + return cls diff --git a/domaintools/utils.py b/domaintools/utils.py index 5322c9b..9587f85 100644 --- a/domaintools/utils.py +++ b/domaintools/utils.py @@ -1,11 +1,9 @@ -import functools import re from datetime import datetime -from typing import List, Optional, Union +from typing import Optional from domaintools.constants import Endpoint, OutputFormat -from domaintools.docstring_patcher import DocstringPatcher def get_domain_age(create_date): @@ -187,55 +185,3 @@ def validate_feeds_parameters(params): endpoint = params.get("endpoint") if endpoint == Endpoint.DOWNLOAD.value and format == OutputFormat.CSV.value: raise ValueError(f"{format} format is not available in {Endpoint.DOWNLOAD.value} API.") - - -def api_endpoint(spec_name: str, path: str, methods: Union[str, List[str]]): - """ - Decorator to tag a method as an API endpoint. - - Args: - spec_name: The key for the spec in api_instance.specs - path: The API path (e.g., "/users") - methods: A single method ("get") or list of methods (["get", "post"]) - that this function handles. - """ - - def decorator(func): - func._api_spec_name = spec_name - func._api_path = path - - # Always store the methods as a list - if isinstance(methods, str): - func._api_methods = [methods] - else: - func._api_methods = methods - - @functools.wraps(func) - def wrapper(self, *args, **kwargs): - return func(*args, **kwargs) - - # Copy all tags to the wrapper - wrapper._api_spec_name = func._api_spec_name - wrapper._api_path = func._api_path - wrapper._api_methods = func._api_methods - return wrapper - - return decorator - - -def auto_patch_docstrings(cls): - original_init = cls.__init__ - - @functools.wraps(original_init) - def new_init(self, *args, **kwargs): - original_init(self, *args, **kwargs) - try: - # We instantiate our patcher and run it - patcher = DocstringPatcher() - patcher.patch(self) - except Exception as e: - print(f"Auto-patching failed: {e}") - - cls.__init__ = new_init - - return cls