diff --git a/plugins/module_utils/__init__.py b/plugins/module_utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/common/__init__.py b/plugins/module_utils/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/common/exceptions.py b/plugins/module_utils/common/exceptions.py new file mode 100644 index 00000000..16e31ac6 --- /dev/null +++ b/plugins/module_utils/common/exceptions.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +# exceptions.py + +Exception classes for the cisco.nd Ansible collection. +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from typing import Any, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + ConfigDict, +) + + +class NDErrorData(BaseModel): + """ + # Summary + + Pydantic model for structured error data from NDModule requests. + + This model provides type-safe error information that can be serialized + to a dict for use with Ansible's fail_json. + + ## Attributes + + - msg: Human-readable error message (required) + - status: HTTP status code as integer (optional) + - request_payload: Request payload that was sent (optional) + - response_payload: Response payload from controller (optional) + - raw: Raw response content for non-JSON responses (optional) + + ## Raises + + - None + """ + + model_config = ConfigDict(extra="forbid") + + msg: str + status: Optional[int] = None + request_payload: Optional[dict[str, Any]] = None + response_payload: Optional[dict[str, Any]] = None + raw: Optional[Any] = None + + +class NDModuleError(Exception): + """ + # Summary + + Exception raised by NDModule when a request fails. + + This exception wraps an NDErrorData Pydantic model, providing structured + error information that can be used by callers to build appropriate error + responses (e.g., Ansible fail_json). + + ## Usage Example + + ```python + try: + data = nd.request("/api/v1/endpoint", HttpVerbEnum.POST, payload) + except NDModuleError as e: + print(f"Error: {e.msg}") + print(f"Status: {e.status}") + if e.response_payload: + print(f"Response: {e.response_payload}") + # Use to_dict() for fail_json + module.fail_json(**e.to_dict()) + ``` + + ## Raises + + - None + """ + + # pylint: disable=too-many-arguments + def __init__( + self, + msg: str, + status: Optional[int] = None, + request_payload: Optional[dict[str, Any]] = None, + response_payload: Optional[dict[str, Any]] = None, + raw: Optional[Any] = None, + ) -> None: + self.error_data = NDErrorData( + msg=msg, + status=status, + request_payload=request_payload, + response_payload=response_payload, + raw=raw, + ) + super().__init__(msg) + + @property + def msg(self) -> str: + """Human-readable error message.""" + return self.error_data.msg + + @property + def status(self) -> Optional[int]: + """HTTP status code.""" + return self.error_data.status + + @property + def request_payload(self) -> Optional[dict[str, Any]]: + """Request payload that was sent.""" + return self.error_data.request_payload + + @property + def response_payload(self) -> Optional[dict[str, Any]]: + """Response payload from controller.""" + return self.error_data.response_payload + + @property + def raw(self) -> Optional[Any]: + """Raw response content for non-JSON responses.""" + return self.error_data.raw + + def to_dict(self) -> dict[str, Any]: + """ + # Summary + + Convert exception attributes to a dict for use with fail_json. + + Returns a dict containing only non-None attributes. + + ## Raises + + - None + """ + return self.error_data.model_dump(exclude_none=True) diff --git a/plugins/module_utils/common/pydantic_compat.py b/plugins/module_utils/common/pydantic_compat.py new file mode 100644 index 00000000..e1550a18 --- /dev/null +++ b/plugins/module_utils/common/pydantic_compat.py @@ -0,0 +1,243 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# pylint: disable=too-few-public-methods +""" +# Summary + +Pydantic compatibility layer. + +This module provides a single location for Pydantic imports with fallback +implementations when Pydantic is not available. This ensures consistent +behavior across all modules and follows the DRY principle. + +## Usage + +### Importing + +Rather than importing directly from pydantic, import from this module: + +```python +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import BaseModel +``` + +This ensure that Ansible sanity tests will not fail due to missing Pydantic dependencies. +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import traceback +from typing import TYPE_CHECKING, Any, Callable, Union + +if TYPE_CHECKING: + # Type checkers always see the real Pydantic types + from pydantic import ( + AfterValidator, + BaseModel, + BeforeValidator, + ConfigDict, + Field, + PydanticExperimentalWarning, + StrictBool, + ValidationError, + field_serializer, + field_validator, + model_validator, + validator, + ) + + HAS_PYDANTIC = True # pylint: disable=invalid-name + PYDANTIC_IMPORT_ERROR = None # pylint: disable=invalid-name +else: + # Runtime: try to import, with fallback + try: + from pydantic import ( + AfterValidator, + BaseModel, + BeforeValidator, + ConfigDict, + Field, + PydanticExperimentalWarning, + StrictBool, + ValidationError, + field_serializer, + field_validator, + model_validator, + validator, + ) + except ImportError: + HAS_PYDANTIC = False # pylint: disable=invalid-name + PYDANTIC_IMPORT_ERROR: Union[str, None] = traceback.format_exc() # pylint: disable=invalid-name + + # Fallback: Minimal BaseModel replacement + class BaseModel: + """Fallback BaseModel when pydantic is not available.""" + + model_config = {"validate_assignment": False, "use_enum_values": False} + + def __init__(self, **kwargs): + """Accept keyword arguments and set them as attributes.""" + for key, value in kwargs.items(): + setattr(self, key, value) + + def model_dump(self, exclude_none: bool = False, exclude_defaults: bool = False) -> dict: # pylint: disable=unused-argument + """Return a dictionary of field names and values. + + Args: + exclude_none: If True, exclude fields with None values + exclude_defaults: Accepted for API compatibility but not implemented in fallback + """ + result = {} + for key, value in self.__dict__.items(): + if exclude_none and value is None: + continue + result[key] = value + return result + + # Fallback: ConfigDict that does nothing + def ConfigDict(**kwargs) -> dict: # pylint: disable=unused-argument,invalid-name + """Pydantic ConfigDict fallback when pydantic is not available.""" + return kwargs + + # Fallback: Field that does nothing + def Field(**kwargs) -> Any: # pylint: disable=unused-argument,invalid-name + """Pydantic Field fallback when pydantic is not available.""" + if "default_factory" in kwargs: + return kwargs["default_factory"]() + return kwargs.get("default") + + # Fallback: field_serializer decorator that does nothing + def field_serializer(*args, **kwargs): # pylint: disable=unused-argument + """Pydantic field_serializer fallback when pydantic is not available.""" + + def decorator(func): + return func + + return decorator + + # Fallback: field_validator decorator that does nothing + def field_validator(*args, **kwargs) -> Callable[..., Any]: # pylint: disable=unused-argument,invalid-name + """Pydantic field_validator fallback when pydantic is not available.""" + + def decorator(func): + return func + + return decorator + + # Fallback: AfterValidator that returns the function unchanged + def AfterValidator(func): # pylint: disable=invalid-name + """Pydantic AfterValidator fallback when pydantic is not available.""" + return func + + # Fallback: BeforeValidator that returns the function unchanged + def BeforeValidator(func): # pylint: disable=invalid-name + """Pydantic BeforeValidator fallback when pydantic is not available.""" + return func + + # Fallback: PydanticExperimentalWarning + PydanticExperimentalWarning = Warning + + # Fallback: StrictBool + StrictBool = bool + + # Fallback: ValidationError + class ValidationError(Exception): + """ + Pydantic ValidationError fallback when pydantic is not available. + """ + + def __init__(self, message="A custom error occurred."): + self.message = message + super().__init__(self.message) + + def __str__(self): + return f"ValidationError: {self.message}" + + # Fallback: model_validator decorator that does nothing + def model_validator(*args, **kwargs): # pylint: disable=unused-argument + """Pydantic model_validator fallback when pydantic is not available.""" + + def decorator(func): + return func + + return decorator + + # Fallback: validator decorator that does nothing + def validator(*args, **kwargs): # pylint: disable=unused-argument + """Pydantic validator fallback when pydantic is not available.""" + + def decorator(func): + return func + + return decorator + + else: + HAS_PYDANTIC = True # pylint: disable=invalid-name + PYDANTIC_IMPORT_ERROR = None # pylint: disable=invalid-name + + +def require_pydantic(module) -> None: + """ + # Summary + + Call `module.fail_json` if pydantic is not installed. + + Intended to be called once at the top of a module's `main()` function, + immediately after `AnsibleModule` is instantiated, to provide a clear + error message when pydantic is a required dependency. + + ## Example + + ```python + from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import require_pydantic + + def main(): + module = AnsibleModule(argument_spec=...) + require_pydantic(module) + ``` + + ## Raises + + None + + ## Notes + + - Does nothing if pydantic is installed. + - Uses Ansible's `missing_required_lib` to produce a standardized error + message that includes installation instructions. + """ + if not HAS_PYDANTIC: + from ansible.module_utils.basic import missing_required_lib # pylint: disable=import-outside-toplevel + + module.fail_json(msg=missing_required_lib("pydantic"), exception=PYDANTIC_IMPORT_ERROR) + + +__all__ = [ + "AfterValidator", + "BaseModel", + "BeforeValidator", + "ConfigDict", + "Field", + "HAS_PYDANTIC", + "PYDANTIC_IMPORT_ERROR", + "PydanticExperimentalWarning", + "StrictBool", + "ValidationError", + "field_serializer", + "field_validator", + "model_validator", + "require_pydantic", + "validator", +] diff --git a/plugins/module_utils/enums.py b/plugins/module_utils/enums.py new file mode 100644 index 00000000..55d1f1ac --- /dev/null +++ b/plugins/module_utils/enums.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +# pylint: disable=wrong-import-position +# pylint: disable=missing-module-docstring +# Copyright: (c) 2026, Allen Robel (@allenrobel) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +# Summary + +Enum definitions for Nexus Dashboard Ansible modules. + +## Enums + +- HttpVerbEnum: Enum for HTTP verb values used in endpoints. +- OperationType: Enum for operation types used by Results to determine if changes have occurred. +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from enum import Enum + + +class BooleanStringEnum(str, Enum): + """ + # Summary + + Enum for boolean string values used in query parameters. + + ## Members + + - TRUE: Represents the string "true". + - FALSE: Represents the string "false". + """ + + TRUE = "true" + FALSE = "false" + + +class HttpVerbEnum(str, Enum): + """ + # Summary + + Enum for HTTP verb values used in endpoints. + + ## Members + + - GET: Represents the HTTP GET method. + - POST: Represents the HTTP POST method. + - PUT: Represents the HTTP PUT method. + - DELETE: Represents the HTTP DELETE method. + - PATCH: Represents the HTTP PATCH method. + """ + + GET = "GET" + POST = "POST" + PUT = "PUT" + DELETE = "DELETE" + PATCH = "PATCH" + + @classmethod + def values(cls) -> list[str]: + """ + # Summary + + Returns a list of all enum values. + + ## Returns + + - A list of string values representing the enum members. + """ + return sorted([member.value for member in cls]) + + +class OperationType(Enum): + """ + # Summary + + Enumeration for operation types. + + Used by Results to determine if changes have occurred based on the operation type. + + - QUERY: Represents a query operation which does not change state. + - CREATE: Represents a create operation which adds new resources. + - UPDATE: Represents an update operation which modifies existing resources. + - DELETE: Represents a delete operation which removes resources. + + # Usage + + ```python + from plugins.module_utils.enums import OperationType + class MyModule: + def __init__(self): + self.operation_type = OperationType.QUERY + ``` + + The above informs the Results class that the current operation is a query, and thus + no changes should be expected. + + Specifically, Results._determine_if_changed() will return False for QUERY operations, + while it will evaluate CREATE, UPDATE, and DELETE operations in more detail to + determine if any changes have occurred. + """ + + QUERY = "query" + CREATE = "create" + UPDATE = "update" + DELETE = "delete" + + def changes_state(self) -> bool: + """ + # Summary + + Return True if this operation type can change controller state. + + ## Returns + + - `bool`: True if operation can change state, False otherwise + + ## Examples + + ```python + OperationType.QUERY.changes_state() # Returns False + OperationType.CREATE.changes_state() # Returns True + OperationType.DELETE.changes_state() # Returns True + ``` + """ + return self in ( + OperationType.CREATE, + OperationType.UPDATE, + OperationType.DELETE, + ) + + def is_read_only(self) -> bool: + """ + # Summary + + Return True if this operation type is read-only. + + ## Returns + + - `bool`: True if operation is read-only, False otherwise + + ## Examples + + ```python + OperationType.QUERY.is_read_only() # Returns True + OperationType.CREATE.is_read_only() # Returns False + ``` + """ + return self == OperationType.QUERY diff --git a/plugins/module_utils/nd_v2.py b/plugins/module_utils/nd_v2.py new file mode 100644 index 00000000..0a3fe61a --- /dev/null +++ b/plugins/module_utils/nd_v2.py @@ -0,0 +1,317 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +# nd_v2.py + +Simplified NDModule using RestSend infrastructure with exception-based error handling. + +This module provides a streamlined interface for interacting with Nexus Dashboard +controllers. Unlike the original nd.py which uses Ansible's fail_json/exit_json, +this module raises Python exceptions, making it: + +- Easier to unit test +- Reusable with non-Ansible code (e.g., raw Python Requests) +- More Pythonic in error handling + +## Usage Example + +```python +from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import ( + NDModule, + NDModuleError, + nd_argument_spec, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + +def main(): + argument_spec = nd_argument_spec() + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + + nd = NDModule(module) + + try: + data = nd.request("/api/v1/some/endpoint", HttpVerbEnum.GET) + module.exit_json(changed=False, data=data) + except NDModuleError as e: + module.fail_json(msg=e.msg, status=e.status, response_payload=e.response_payload) +``` +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import logging +from typing import Any, Optional + +from ansible.module_utils.basic import env_fallback +from ansible_collections.cisco.nd.plugins.module_utils.common.exceptions import NDModuleError +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.rest.protocols.response_handler import ResponseHandlerProtocol +from ansible_collections.cisco.nd.plugins.module_utils.rest.protocols.sender import SenderProtocol +from ansible_collections.cisco.nd.plugins.module_utils.rest.response_handler_nd import ResponseHandler +from ansible_collections.cisco.nd.plugins.module_utils.rest.rest_send import RestSend +from ansible_collections.cisco.nd.plugins.module_utils.rest.sender_nd import Sender + + +def nd_argument_spec() -> dict[str, Any]: + """ + Return the common argument spec for ND modules. + + This function provides the standard arguments that all ND modules + should accept for connection and authentication. + """ + return dict( + host=dict(type="str", required=False, aliases=["hostname"], fallback=(env_fallback, ["ND_HOST"])), + port=dict(type="int", required=False, fallback=(env_fallback, ["ND_PORT"])), + username=dict(type="str", fallback=(env_fallback, ["ND_USERNAME", "ANSIBLE_NET_USERNAME"])), + password=dict(type="str", required=False, no_log=True, fallback=(env_fallback, ["ND_PASSWORD", "ANSIBLE_NET_PASSWORD"])), + output_level=dict(type="str", default="normal", choices=["debug", "info", "normal"], fallback=(env_fallback, ["ND_OUTPUT_LEVEL"])), + timeout=dict(type="int", default=30, fallback=(env_fallback, ["ND_TIMEOUT"])), + use_proxy=dict(type="bool", fallback=(env_fallback, ["ND_USE_PROXY"])), + use_ssl=dict(type="bool", fallback=(env_fallback, ["ND_USE_SSL"])), + validate_certs=dict(type="bool", fallback=(env_fallback, ["ND_VALIDATE_CERTS"])), + login_domain=dict(type="str", fallback=(env_fallback, ["ND_LOGIN_DOMAIN"])), + ) + + +class NDModule: + """ + # Summary + + Simplified NDModule using RestSend infrastructure with exception-based error handling. + + This class provides a clean interface for making REST API requests to Nexus Dashboard + controllers. It uses the RestSend/Sender/ResponseHandler infrastructure for + separation of concerns and testability. + + ## Key Differences from nd.py NDModule + + 1. Uses exceptions (NDModuleError) instead of fail_json/exit_json + 2. No Connection class dependency - uses Sender for HTTP operations + 3. Minimal state - only tracks request/response metadata + 4. request() leverages RestSend -> Sender -> ResponseHandler + + ## Usage Example + + ```python + from ansible_collections.cisco.nd.plugins.module_utils.nd_v2 import NDModule, NDModuleError + from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + + nd = NDModule(module) + + try: + # GET request + data = nd.request("/api/v1/endpoint") + + # POST request with payload + result = nd.request("/api/v1/endpoint", HttpVerbEnum.POST, {"key": "value"}) + except NDModuleError as e: + module.fail_json(**e.to_dict()) + ``` + + ## Raises + + - NDModuleError: When a request fails (replaces fail_json) + - ValueError: When RestSend encounters configuration errors + - TypeError: When invalid types are passed to RestSend + """ + + def __init__(self, module) -> None: + """ + Initialize NDModule with an AnsibleModule instance. + + Args: + module: AnsibleModule instance (or compatible mock for testing) + """ + self.class_name = self.__class__.__name__ + self.module = module + self.params: dict[str, Any] = module.params + + self.log = logging.getLogger(f"nd.{self.class_name}") + + # Request/response state (for debugging and error reporting) + self.method: Optional[str] = None + self.path: Optional[str] = None + self.response: Optional[str] = None + self.status: Optional[int] = None + self.url: Optional[str] = None + + # RestSend infrastructure (lazy initialized) + self._rest_send: Optional[RestSend] = None + self._sender: Optional[SenderProtocol] = None + self._response_handler: Optional[ResponseHandlerProtocol] = None + + if self.module._debug: + self.module.warn("Enable debug output because ANSIBLE_DEBUG was set.") + self.params["output_level"] = "debug" + + def _get_rest_send(self) -> RestSend: + """ + # Summary + + Lazy initialization of RestSend and its dependencies. + + ## Returns + + - RestSend: Configured RestSend instance ready for use. + """ + method_name = "_get_rest_send" + params = {} + if self._rest_send is None: + params = { + "check_mode": self.module.check_mode, + "state": self.params.get("state"), + } + self._sender = Sender() + self._sender.ansible_module = self.module + self._response_handler = ResponseHandler() + self._rest_send = RestSend(params) + self._rest_send.sender = self._sender + self._rest_send.response_handler = self._response_handler + + msg = f"{self.class_name}.{method_name}: " + msg += "Initialized RestSend instance with params: " + msg += f"{params}" + self.log.debug(msg) + return self._rest_send + + @property + def rest_send(self) -> RestSend: + """ + # Summary + + Access to the RestSend instance used by this NDModule. + + ## Returns + + - RestSend: The RestSend instance. + + ## Raises + + - `ValueError`: If accessed before `request()` has been called. + + ## Usage + + ```python + nd = NDModule(module) + data = nd.request("/api/v1/endpoint") + + # Access RestSend response/result + response = nd.rest_send.response_current + result = nd.rest_send.result_current + ``` + """ + if self._rest_send is None: + msg = f"{self.class_name}.rest_send: " + msg += "rest_send must be initialized before accessing. " + msg += "Call request() first." + raise ValueError(msg) + return self._rest_send + + def request( + self, + path: str, + verb: HttpVerbEnum = HttpVerbEnum.GET, + data: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + """ + # Summary + + Make a REST API request to the Nexus Dashboard controller. + + This method uses the RestSend infrastructure for improved separation + of concerns and testability. + + ## Args + + - path: The fully-formed API endpoint path including query string + (e.g., "/appcenter/cisco/ndfc/api/v1/endpoint?param=value") + - verb: HTTP verb as HttpVerbEnum (default: HttpVerbEnum.GET) + - data: Optional request payload as a dict + + ## Returns + + The response DATA from the controller (parsed JSON body). + + For full response metadata (status, message, etc.), access + `rest_send.response_current` and `rest_send.result_current` + after calling this method. + + ## Raises + + - `NDModuleError`: If the request fails (with status, payload, etc.) + - `ValueError`: If RestSend encounters configuration errors + - `TypeError`: If invalid types are passed + """ + method_name = "request" + # If PATCH with empty data, return early (existing behavior) + if verb == HttpVerbEnum.PATCH and not data: + return {} + + rest_send = self._get_rest_send() + + # Send the request + try: + rest_send.path = path + rest_send.verb = verb # type: ignore[assignment] + msg = f"{self.class_name}.{method_name}: " + msg += "Sending request " + msg += f"verb: {verb}, " + msg += f"path: {path}" + if data: + rest_send.payload = data + msg += f", data: {data}" + self.log.debug(msg) + rest_send.commit() + except (TypeError, ValueError) as error: + raise ValueError(f"Error in request: {error}") from error + + # Get response and result from RestSend + response = rest_send.response_current + result = rest_send.result_current + + # Update state for debugging/error reporting + self.method = verb.value + self.path = path + self.response = response.get("MESSAGE") + self.status = response.get("RETURN_CODE", -1) + self.url = response.get("REQUEST_PATH") + + # Handle errors based on result + if not result.get("success", False): + response_data = response.get("DATA") + + # Get error message from ResponseHandler + error_msg = self._response_handler.error_message if self._response_handler else "Unknown error" + + # Build exception with available context + raw = None + payload = None + + if isinstance(response_data, dict): + if "raw_response" in response_data: + raw = response_data["raw_response"] + else: + payload = response_data + + raise NDModuleError( + msg=error_msg if error_msg else "Unknown error", + status=self.status, + request_payload=data, + response_payload=payload, + raw=raw, + ) + + # Return the response data on success + return response.get("DATA", {}) diff --git a/plugins/module_utils/rest/__init__.py b/plugins/module_utils/rest/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/rest/protocols/__init__.py b/plugins/module_utils/rest/protocols/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/rest/protocols/response_handler.py b/plugins/module_utils/rest/protocols/response_handler.py new file mode 100644 index 00000000..487e12cf --- /dev/null +++ b/plugins/module_utils/rest/protocols/response_handler.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +# pylint: disable=missing-module-docstring +# pylint: disable=unnecessary-ellipsis +# pylint: disable=wrong-import-position +# Copyright: (c) 2026, Allen Robel (@arobel) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +""" +Protocol definition for ResponseHandler classes. +""" + +try: + from typing import Protocol, runtime_checkable +except ImportError: + try: + from typing_extensions import Protocol, runtime_checkable # type: ignore[assignment] + except ImportError: + + class Protocol: # type: ignore[no-redef] + """Stub for Python < 3.8 without typing_extensions.""" + + def runtime_checkable(cls): # type: ignore[no-redef] + return cls + + +from typing import Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + + +@runtime_checkable +class ResponseHandlerProtocol(Protocol): + """ + # Summary + + Protocol defining the interface for response handlers in RestSend. + + Any class implementing this protocol must provide: + + - `response` property (getter/setter): The controller response dict. + - `result` property (getter): The calculated result based on response and verb. + - `verb` property (getter/setter): The HTTP method (GET, POST, PUT, DELETE, etc.). + - `commit()` method: Parses response and sets result. + + ## Notes + + - Getters for `response`, `result`, and `verb` should raise `ValueError` if + accessed before being set. + + ## Example Implementations + + - `ResponseHandler` in `response_handler_nd.py`: Handles Nexus Dashboard responses. + - Future: `ResponseHandlerApic` for APIC controller responses. + """ + + @property + def response(self) -> dict: + """ + # Summary + + The controller response. + + ## Raises + + - ValueError: If accessed before being set. + """ + ... + + @response.setter + def response(self, value: dict) -> None: + pass + + @property + def result(self) -> dict: + """ + # Summary + + The calculated result based on response and verb. + + ## Raises + + - ValueError: If accessed before commit() is called. + """ + ... + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + HTTP method for the request. + + ## Raises + + - ValueError: If accessed before being set. + """ + ... + + @verb.setter + def verb(self, value: HttpVerbEnum) -> None: + pass + + def commit(self) -> None: + """ + # Summary + + Parse the response and set the result. + + ## Raises + + - ValueError: If response or verb is not set. + """ + ... + + @property + def error_message(self) -> Optional[str]: + """ + # Summary + + Human-readable error message extracted from response. + + ## Returns + + - str: Error message if an error occurred. + - None: If the request was successful or commit() not called. + """ + ... diff --git a/plugins/module_utils/rest/protocols/response_validation.py b/plugins/module_utils/rest/protocols/response_validation.py new file mode 100644 index 00000000..d1ec5ef0 --- /dev/null +++ b/plugins/module_utils/rest/protocols/response_validation.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +# Summary + +Protocol definition for version-specific response validation strategies. + +## Description + +This module defines the ResponseValidationStrategy protocol which specifies +the interface for handling version-specific differences in ND API responses, +including status code validation and error message extraction. + +When ND API v2 is released with different status codes or response formats, +implementing a new strategy class allows clean separation of v1 and v2 logic. +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +try: + from typing import Protocol, runtime_checkable +except ImportError: + try: + from typing_extensions import Protocol, runtime_checkable # type: ignore[assignment] + except ImportError: + + class Protocol: # type: ignore[no-redef] + """Stub for Python < 3.8 without typing_extensions.""" + + def runtime_checkable(cls): # type: ignore[no-redef] + return cls + + +from typing import Optional + +# pylint: disable=unnecessary-ellipsis + + +@runtime_checkable +class ResponseValidationStrategy(Protocol): + """ + # Summary + + Protocol for version-specific response validation. + + ## Description + + This protocol defines the interface for handling version-specific + differences in ND API responses, including status code validation + and error message extraction. + + Implementations of this protocol enable injecting version-specific + behavior into ResponseHandler without modifying the handler itself. + + ## Methods + + See property and method definitions below. + + ## Raises + + None - implementations may raise exceptions per their logic + """ + + @property + def success_codes(self) -> set[int]: + """ + # Summary + + Return set of HTTP status codes considered successful. + + ## Returns + + - Set of integers representing success status codes + """ + ... + + @property + def not_found_code(self) -> int: + """ + # Summary + + Return HTTP status code for resource not found. + + ## Returns + + - Integer representing not-found status code (typically 404) + """ + ... + + def is_success(self, response: dict) -> bool: + """ + # Summary + + Check if the full response indicates success. + + ## Description + + Implementations must check both the HTTP status code and any embedded error + indicators in the response body, since some ND API endpoints return a + successful status code (e.g. 200) while embedding an error in the payload. + + ## Parameters + + - response: Response dict with keys RETURN_CODE, MESSAGE, DATA, etc. + + ## Returns + + - True if the response is fully successful (good status code and no embedded error), False otherwise + + ## Raises + + None + """ + ... + + def is_not_found(self, return_code: int) -> bool: + """ + # Summary + + Check if return code indicates not found. + + ## Parameters + + - return_code: HTTP status code to check + + ## Returns + + - True if code matches not_found_code, False otherwise + + ## Raises + + None + """ + ... + + def is_changed(self, response: dict) -> bool: + """ + # Summary + + Check if a successful mutation request actually changed state. + + ## Description + + Some ND API endpoints include a `modified` response header (string `"true"` or + `"false"`) that explicitly signals whether the operation mutated any state. + Implementations should honour this header when present and default to `True` + when it is absent (matching the historical behaviour for PUT/POST/DELETE). + + This method should only be called after `is_success` has returned `True`. + + ## Parameters + + - response: Response dict with keys RETURN_CODE, MESSAGE, DATA, and any HTTP + response headers (lowercased) forwarded by the HttpAPI plugin. + + ## Returns + + - True if the operation changed state (or if the `modified` header is absent) + - False if the `modified` header is explicitly `"false"` + + ## Raises + + None + """ + ... + + def extract_error_message(self, response: dict) -> Optional[str]: + """ + # Summary + + Extract error message from response DATA. + + ## Parameters + + - response: Response dict with keys RETURN_CODE, MESSAGE, DATA, etc. + + ## Returns + + - Error message string if found, None otherwise + + ## Raises + + None - should return None gracefully if error message cannot be extracted + """ + ... diff --git a/plugins/module_utils/rest/protocols/sender.py b/plugins/module_utils/rest/protocols/sender.py new file mode 100644 index 00000000..5e55047c --- /dev/null +++ b/plugins/module_utils/rest/protocols/sender.py @@ -0,0 +1,103 @@ +# pylint: disable=wrong-import-position +# pylint: disable=missing-module-docstring +# pylint: disable=unnecessary-ellipsis +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +try: + from typing import Protocol, runtime_checkable +except ImportError: + try: + from typing_extensions import Protocol, runtime_checkable # type: ignore[assignment] + except ImportError: + + class Protocol: # type: ignore[no-redef] + """Stub for Python < 3.8 without typing_extensions.""" + + def runtime_checkable(cls): # type: ignore[no-redef] + return cls + + +from typing import Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + + +@runtime_checkable +class SenderProtocol(Protocol): + """ + # Summary + + Protocol defining the sender interface for RestSend. + + Any class implementing this protocol must provide: + + - `path` property (getter/setter): The endpoint path for the REST request. + - `verb` property (getter/setter): The HTTP method (GET, POST, PUT, DELETE, etc.). + - `payload` property (getter/setter): Optional request payload as a dict. + - `response` property (getter): The response from the controller. + - `commit()` method: Sends the request to the controller. + + ## Example Implementations + + - `Sender` in `sender_nd.py`: Uses Ansible HttpApi plugin. + - `Sender` in `sender_file.py`: Reads responses from files (for testing). + """ + + @property + def path(self) -> str: + """Endpoint path for the REST request.""" + ... + + @path.setter + def path(self, value: str) -> None: + """Set the endpoint path for the REST request.""" + ... + + @property + def verb(self) -> HttpVerbEnum: + """HTTP method for the REST request.""" + ... + + @verb.setter + def verb(self, value: HttpVerbEnum) -> None: + """Set the HTTP method for the REST request.""" + ... + + @property + def payload(self) -> Optional[dict]: + """Optional payload to send to the controller.""" + ... + + @payload.setter + def payload(self, value: dict) -> None: + """Set the optional payload for the REST request.""" + ... + + @property + def response(self) -> dict: + """The response from the controller.""" + ... + + def commit(self) -> None: + """ + Send the request to the controller. + + Raises: + ConnectionError: If there is an error with the connection. + """ + ... diff --git a/plugins/module_utils/rest/response_handler_nd.py b/plugins/module_utils/rest/response_handler_nd.py new file mode 100644 index 00000000..e7026d30 --- /dev/null +++ b/plugins/module_utils/rest/response_handler_nd.py @@ -0,0 +1,401 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +# response_handler_nd.py + +Implements the ResponseHandler interface for handling Nexus Dashboard controller responses. + +## Version Compatibility + +This handler is designed for ND API v1 responses (ND 4.2+). + +### Status Code Assumptions + +Status codes are defined by the injected `ResponseValidationStrategy`, defaulting +to `NdV1Strategy` (ND 4.2+): + +- Success: 200, 201, 202, 204, 207 +- Not Found: 404 (treated as success for GET) +- Error: 405, 409 + +If ND API v2 uses different codes, inject a new strategy via the +`validation_strategy` property rather than modifying this class. + +### Response Format + +Expects ND HttpApi plugin to provide responses with these keys: + +- RETURN_CODE (int): HTTP status code (e.g., 200, 404, 500) +- MESSAGE (str): HTTP reason phrase (e.g., "OK", "Not Found") +- DATA (dict): Parsed JSON body or dict with raw_response if parsing failed +- REQUEST_PATH (str): The request URL path +- METHOD (str): The HTTP method used (GET, POST, PUT, DELETE, PATCH) + +### Supported Error Formats + +The error_message property handles multiple ND API v1 error response formats: + +1. code/message dict: {"code": , "message": } +2. messages array: {"messages": [{"code": , "severity": , "message": }]} +3. errors array: {"errors": [, ...]} +4. raw_response: {"raw_response": } for non-JSON responses + +If ND API v2 changes error response structures, error extraction logic will need updates. + +## Future v2 Considerations + +If ND API v2 changes response format or status codes, implement a new strategy +class (e.g. `NdV2Strategy`) conforming to `ResponseValidationStrategy` and inject +it via `response_handler.validation_strategy = NdV2Strategy()`. + +TODO: Should response be converted to a Pydantic model by this class? +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import copy +import logging +from typing import Any, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.rest.protocols.response_validation import ResponseValidationStrategy +from ansible_collections.cisco.nd.plugins.module_utils.rest.response_strategies.nd_v1_strategy import NdV1Strategy + + +class ResponseHandler: + """ + # Summary + + Implement the response handler interface for injection into RestSend(). + + ## Raises + + - `TypeError` if: + - `response` is not a dict. + - `ValueError` if: + - `response` is missing any fields required by the handler + to calculate the result. + - Required fields: + - `RETURN_CODE` + - `MESSAGE` + - `response` is not set prior to calling `commit()`. + - `verb` is not set prior to calling `commit()`. + + ## Interface specification + + - `response` setter property + - Accepts a dict containing the controller response. + - Raises `TypeError` if: + - `response` is not a dict. + - Raises `ValueError` if: + - `response` is missing any fields required by the handler + to calculate the result, for example `RETURN_CODE` and + `MESSAGE`. + - `result` getter property + - Returns a dict containing the calculated result based on the + controller response and the request verb. + - Raises `ValueError` if `result` is accessed before calling + `commit()`. + - `result` setter property + - Set internally by the handler based on the response and verb. + - `verb` setter property + - Accepts an HttpVerbEnum enum defining the request verb. + - Valid verb: One of "DELETE", "GET", "POST", "PUT". + - e.g. HttpVerbEnum.GET, HttpVerbEnum.POST, etc. + - Raises `ValueError` if verb is not set prior to calling `commit()`. + - `commit()` method + - Parse `response` and set `result`. + - Raise `ValueError` if: + - `response` is not set. + - `verb` is not set. + + ## Usage example + + ```python + # import and instantiate the class + from ansible_collections.cisco.nd.plugins.module_utils.rest.response_handler_nd import \ + ResponseHandler + response_handler = ResponseHandler() + + try: + # Set the response from the controller + response_handler.response = controller_response + + # Set the request verb + response_handler.verb = HttpVerbEnum.GET + + # Call commit to parse the response + response_handler.commit() + + # Access the result + result = response_handler.result + except (TypeError, ValueError) as error: + handle_error(error) + ``` + + """ + + def __init__(self) -> None: + self.class_name = self.__class__.__name__ + method_name = "__init__" + + self.log = logging.getLogger(f"nd.{self.class_name}") + + self._response: Optional[dict[str, Any]] = None + self._result: Optional[dict[str, Any]] = None + self._strategy: ResponseValidationStrategy = NdV1Strategy() + self._verb: Optional[HttpVerbEnum] = None + + msg = f"ENTERED {self.class_name}.{method_name}" + self.log.debug(msg) + + def _handle_response(self) -> None: + """ + # Summary + + Call the appropriate handler for response based on the value of self.verb + """ + if self.verb == HttpVerbEnum.GET: + self._handle_get_response() + else: + self._handle_post_put_delete_response() + + def _handle_get_response(self) -> None: + """ + # Summary + + Handle GET responses from the controller and set self.result. + + - self.result is a dict containing: + - found: + - False if RETURN_CODE == 404 + - True otherwise (when successful) + - success: + - True if RETURN_CODE in (200, 201, 202, 204, 207, 404) + - False otherwise (error status codes) + """ + result = {} + return_code = self.response.get("RETURN_CODE") + + # 404 Not Found - resource doesn't exist, but request was successful + if self._strategy.is_not_found(return_code): + result["found"] = False + result["success"] = True + # Success codes with no embedded error - resource found + elif self._strategy.is_success(self.response): + result["found"] = True + result["success"] = True + # Error codes - request failed + else: + result["found"] = False + result["success"] = False + + self.result = copy.copy(result) + + def _handle_post_put_delete_response(self) -> None: + """ + # Summary + + Handle POST, PUT, DELETE responses from the controller and set + self.result. + + - self.result is a dict containing: + - changed: + - True if RETURN_CODE in (200, 201, 202, 204, 207) and no ERROR + - False otherwise + - success: + - True if RETURN_CODE in (200, 201, 202, 204, 207) and no ERROR + - False otherwise + """ + result = {} + + # Success codes with no embedded error indicate the operation completed. + # Use the modified header (when present) as the authoritative signal for + # whether state was actually changed, falling back to True when absent. + if self._strategy.is_success(self.response): + result["success"] = True + result["changed"] = self._strategy.is_changed(self.response) + else: + result["success"] = False + result["changed"] = False + + self.result = copy.copy(result) + + def commit(self) -> None: + """ + # Summary + + Parse the response from the controller and set self.result + based on the response. + + ## Raises + + - ``ValueError`` if: + - ``response`` is not set. + - ``verb`` is not set. + """ + method_name = "commit" + msg = f"{self.class_name}.{method_name}: " + msg += f"response {self.response}, verb {self.verb}" + self.log.debug(msg) + self._handle_response() + + @property + def response(self) -> dict[str, Any]: + """ + # Summary + + The controller response. + + ## Raises + + - getter: ``ValueError`` if response is not set. + - setter: ``TypeError`` if ``response`` is not a dict. + - setter: ``ValueError`` if ``response`` is missing required fields + (``RETURN_CODE``, ``MESSAGE``). + """ + if self._response is None: + msg = f"{self.class_name}.response: " + msg += "response must be set before accessing." + raise ValueError(msg) + return self._response + + @response.setter + def response(self, value: dict[str, Any]) -> None: + method_name = "response" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"{self.class_name}.{method_name} must be a dict. " + msg += f"Got {value}." + raise TypeError(msg) + if value.get("MESSAGE", None) is None: + msg = f"{self.class_name}.{method_name}: " + msg += "response must have a MESSAGE key. " + msg += f"Got: {value}." + raise ValueError(msg) + if value.get("RETURN_CODE", None) is None: + msg = f"{self.class_name}.{method_name}: " + msg += "response must have a RETURN_CODE key. " + msg += f"Got: {value}." + raise ValueError(msg) + self._response = value + + @property + def result(self) -> dict[str, Any]: + """ + # Summary + + The result calculated by the handler based on the controller response. + + ## Raises + + - getter: ``ValueError`` if result is not set (commit() not called). + - setter: ``TypeError`` if result is not a dict. + """ + if self._result is None: + msg = f"{self.class_name}.result: " + msg += "result must be set before accessing. Call commit() first." + raise ValueError(msg) + return self._result + + @result.setter + def result(self, value: dict[str, Any]) -> None: + method_name = "result" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"{self.class_name}.{method_name} must be a dict. " + msg += f"Got {value}." + raise TypeError(msg) + self._result = value + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + HTTP method for the REST request e.g. HttpVerbEnum.GET, HttpVerbEnum.POST, etc. + + ## Raises + + - ``ValueError`` if value is not set. + """ + if self._verb is None: + raise ValueError(f"{self.class_name}.verb is not set.") + return self._verb + + @verb.setter + def verb(self, value: HttpVerbEnum) -> None: + self._verb = value + + @property + def error_message(self) -> Optional[str]: + """ + # Summary + + Extract a human-readable error message from the response DATA. + + Delegates to the injected `ResponseValidationStrategy`. Returns None if + result indicates success or if `commit()` has not been called. + + ## Returns + + - str: Human-readable error message if an error occurred. + - None: If the request was successful or `commit()` not called. + + ## Raises + + None + """ + if self._result is not None and not self._result.get("success", True): + return self._strategy.extract_error_message(self._response) + return None + + @property + def validation_strategy(self) -> ResponseValidationStrategy: + """ + # Summary + + The response validation strategy used to check status codes and extract + error messages. + + ## Returns + + - `ResponseValidationStrategy`: The current strategy instance. + + ## Raises + + None + """ + return self._strategy + + @validation_strategy.setter + def validation_strategy(self, value: ResponseValidationStrategy) -> None: + """ + # Summary + + Set the response validation strategy. + + ## Raises + + ### TypeError + + - If `value` does not implement `ResponseValidationStrategy`. + """ + method_name = "validation_strategy" + if not isinstance(value, ResponseValidationStrategy): + msg = f"{self.class_name}.{method_name}: " + msg += f"Expected ResponseValidationStrategy. Got {type(value)}." + raise TypeError(msg) + self._strategy = value diff --git a/plugins/module_utils/rest/response_strategies/__init__.py b/plugins/module_utils/rest/response_strategies/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/module_utils/rest/response_strategies/nd_v1_strategy.py b/plugins/module_utils/rest/response_strategies/nd_v1_strategy.py new file mode 100644 index 00000000..58c7784f --- /dev/null +++ b/plugins/module_utils/rest/response_strategies/nd_v1_strategy.py @@ -0,0 +1,267 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +# Summary + +ND API v1 response validation strategy. + +## Description + +Implements status code validation and error message extraction for ND API v1 +responses (ND 4.2). + +This strategy encapsulates the response handling logic previously hardcoded +in ResponseHandler, enabling version-specific behavior to be injected. +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +from typing import Any, Optional + + +class NdV1Strategy: + """ + # Summary + + Response validation strategy for ND API v1. + + ## Description + + Implements status code validation and error message extraction + for ND API v1 (ND 4.2+). + + ## Status Codes + + - Success: 200, 201, 202, 204, 207 + - Not Found: 404 (treated as success for GET) + - Error: anything not in success codes and not 404 + + ## Error Formats Supported + + 1. raw_response: Non-JSON response stored in DATA.raw_response + 2. code/message: DATA.code and DATA.message + 3. messages array: all DATA.messages[].{code, severity, message} joined with "; " + 4. errors array: all DATA.errors[] joined with "; " + 5. Connection failure: No DATA with REQUEST_PATH and MESSAGE + 6. Non-dict DATA: Stringified DATA value + 7. Unknown: Fallback with RETURN_CODE + + ## Raises + + None + """ + + @property + def success_codes(self) -> set[int]: + """ + # Summary + + Return v1 success codes. + + ## Returns + + - Set of integers: {200, 201, 202, 204, 207} + + ## Raises + + None + """ + return {200, 201, 202, 204, 207} + + @property + def not_found_code(self) -> int: + """ + # Summary + + Return v1 not found code. + + ## Returns + + - Integer: 404 + + ## Raises + + None + """ + return 404 + + def is_success(self, response: dict) -> bool: + """ + # Summary + + Check if the full response indicates success (v1). + + ## Description + + Returns True only when both conditions hold: + + 1. `RETURN_CODE` is in `success_codes` + 2. The response body contains no embedded error indicators + + Embedded error indicators checked: + + - Top-level `ERROR` key is present + - `DATA.error` key is present + + ## Parameters + + - response: Response dict with keys RETURN_CODE, MESSAGE, DATA, etc. + + ## Returns + + - True if the response is fully successful, False otherwise + + ## Raises + + None + """ + return_code = response.get("RETURN_CODE", -1) + if return_code not in self.success_codes: + return False + if response.get("ERROR") is not None: + return False + data = response.get("DATA") + if isinstance(data, dict) and data.get("error") is not None: + return False + return True + + def is_not_found(self, return_code: int) -> bool: + """ + # Summary + + Check if return code indicates not found (v1). + + ## Parameters + + - return_code: HTTP status code to check + + ## Returns + + - True if code matches not_found_code, False otherwise + + ## Raises + + None + """ + return return_code == self.not_found_code + + def is_changed(self, response: dict) -> bool: + """ + # Summary + + Check if a successful mutation request actually changed state (v1). + + ## Description + + ND API v1 may include a `modified` response header (forwarded by the HttpAPI + plugin as a lowercase key in the response dict) with string values `"true"` or + `"false"`. When present, this header is the authoritative signal for whether + the operation mutated any state on the controller. + + When the header is absent the method defaults to `True`, preserving the + historical behaviour for verbs (DELETE, POST, PUT) where ND does not send it. + + ## Parameters + + - response: Response dict with keys RETURN_CODE, MESSAGE, DATA, and any HTTP + response headers (lowercased) forwarded by the HttpAPI plugin. + + ## Returns + + - False if the `modified` header is present and equals `"false"` (case-insensitive) + - True otherwise + + ## Raises + + None + """ + modified = response.get("modified") + if modified is None: + return True + return str(modified).lower() != "false" + + def extract_error_message(self, response: dict) -> Optional[str]: + """ + # Summary + + Extract error message from v1 response DATA. + + ## Description + + Handles multiple ND API v1 error formats in priority order: + + 1. Connection failure (no DATA) + 2. Non-JSON response (raw_response in DATA) + 3. code/message dict + 4. messages array with code/severity/message (all items joined) + 5. errors array (all items joined) + 6. Unknown dict format + 7. Non-dict DATA + + ## Parameters + + - response: Response dict with keys RETURN_CODE, MESSAGE, DATA, REQUEST_PATH + + ## Returns + + - Error message string if found, None otherwise + + ## Raises + + None - returns None gracefully if error message cannot be extracted + """ + msg: Optional[str] = None + + response_data = response.get("DATA") if response else None + return_code = response.get("RETURN_CODE", -1) if response else -1 + + # No response data - connection failure + if response_data is None: + request_path = response.get("REQUEST_PATH", "unknown") if response else "unknown" + message = response.get("MESSAGE", "Unknown error") if response else "Unknown error" + msg = f"Connection failed for {request_path}. {message}" + # Dict response data - check various ND error formats + elif isinstance(response_data, dict): + # Type-narrow response_data to dict[str, Any] for pylint + # pylint: disable=unsupported-membership-test,unsubscriptable-object + data_dict: dict[str, Any] = response_data + # Raw response (non-JSON) + if "raw_response" in data_dict: + msg = "ND Error: Response could not be parsed as JSON" + # code/message format + elif "code" in data_dict and "message" in data_dict: + msg = f"ND Error {data_dict['code']}: {data_dict['message']}" + + # messages array format + if msg is None and "messages" in data_dict and len(data_dict.get("messages", [])) > 0: + parts = [] + for m in data_dict["messages"]: + if all(k in m for k in ("code", "severity", "message")): + parts.append(f"ND Error {m['code']} ({m['severity']}): {m['message']}") + if parts: + msg = "; ".join(parts) + + # errors array format + if msg is None and "errors" in data_dict and len(data_dict.get("errors", [])) > 0: + msg = f"ND Error: {'; '.join(str(e) for e in data_dict['errors'])}" + + # Unknown dict format - fallback + if msg is None: + msg = f"ND Error: Request failed with status {return_code}" + # Non-dict response data + else: + msg = f"ND Error: {response_data}" + + return msg diff --git a/plugins/module_utils/rest/rest_send.py b/plugins/module_utils/rest/rest_send.py new file mode 100644 index 00000000..4e903fb0 --- /dev/null +++ b/plugins/module_utils/rest/rest_send.py @@ -0,0 +1,797 @@ +# -*- coding: utf-8 -*- +# pylint: disable=wrong-import-position +# pylint: disable=missing-module-docstring +# Copyright: (c) 2026, Allen Robel (@arobel) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import copy +import inspect +import json +import logging +from time import sleep +from typing import Any, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.rest.protocols.response_handler import ResponseHandlerProtocol +from ansible_collections.cisco.nd.plugins.module_utils.rest.protocols.sender import SenderProtocol +from ansible_collections.cisco.nd.plugins.module_utils.rest.results import Results + + +class RestSend: + """ + # Summary + + - Send REST requests to the controller with retries. + - Accepts a `Sender()` class that implements SenderProtocol. + - The sender interface is defined in + `module_utils/rest/protocols/sender.py` + - Accepts a `ResponseHandler()` class that implements the response + handler interface. + - The response handler interface is defined in + `module_utils/rest/protocols/response_handler.py` + + ## Raises + + - `ValueError` if: + - ResponseHandler() raises `TypeError` or `ValueError` + - Sender().commit() raises `ValueError` + - `verb` is not a valid verb (GET, POST, PUT, DELETE) + - `TypeError` if: + - `check_mode` is not a `bool` + - `path` is not a `str` + - `payload` is not a `dict` + - `response` is not a `dict` + - `response_current` is not a `dict` + - `response_handler` is not an instance of + `ResponseHandler()` + - `result` is not a `dict` + - `result_current` is not a `dict` + - `send_interval` is not an `int` + - `sender` is not an instance of `SenderProtocol` + - `timeout` is not an `int` + - `unit_test` is not a `bool` + + ## Usage discussion + + - A Sender() class is used in the usage example below that requires an + instance of `AnsibleModule`, and uses the connection plugin (plugins/httpapi.nd.py) + to send requests to the controller. + - See ``module_utils/rest/protocols/sender.py`` for details about + implementing `Sender()` classes. + - A `ResponseHandler()` class is used in the usage example below that + abstracts controller response handling. It accepts a controller + response dict and returns a result dict. + - See `module_utils/rest/protocols/response_handler.py` for details + about implementing `ResponseHandler()` classes. + + ## Usage example + + ```python + params = {"check_mode": False, "state": "merged"} + sender = Sender() # class that implements SenderProtocol + sender.ansible_module = ansible_module + + try: + rest_send = RestSend(params) + rest_send.sender = sender + rest_send.response_handler = ResponseHandler() + rest_send.unit_test = True # optional, use in unit tests for speed + rest_send.path = "/rest/top-down/fabrics" + rest_send.verb = HttpVerbEnum.GET + rest_send.payload = my_payload # optional + rest_send.save_settings() # save current check_mode and timeout + rest_send.timeout = 300 # optional + rest_send.check_mode = True + # Do things with rest_send... + rest_send.commit() + rest_send.restore_settings() # restore check_mode and timeout + except (TypeError, ValueError) as error: + # Handle error + + # list of responses from the controller for this session + response = rest_send.response + # dict containing the current controller response + response_current = rest_send.response_current + # list of results from the controller for this session + result = rest_send.result + # dict containing the current controller result + result_current = rest_send.result_current + ``` + """ + + def __init__(self, params) -> None: + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"nd.{self.class_name}") + + self.params = params + msg = "ENTERED RestSend(): " + msg += f"params: {self.params}" + self.log.debug(msg) + + self._check_mode: bool = False + self._path: Optional[str] = None + self._payload: Optional[dict] = None + self._response: list[dict[str, Any]] = [] + self._response_current: dict[str, Any] = {} + self._response_handler: Optional[ResponseHandlerProtocol] = None + self._result: list[dict] = [] + self._result_current: dict = {} + self._send_interval: int = 5 + self._sender: Optional[SenderProtocol] = None + self._timeout: int = 300 + self._unit_test: bool = False + self._verb: HttpVerbEnum = HttpVerbEnum.GET + + # See save_settings() and restore_settings() + self.saved_timeout: Optional[int] = None + self.saved_check_mode: Optional[bool] = None + + self.check_mode = self.params.get("check_mode", False) + + msg = "ENTERED RestSend(): " + msg += f"check_mode: {self.check_mode}" + self.log.debug(msg) + + def restore_settings(self) -> None: + """ + # Summary + + Restore `check_mode` and `timeout` to their saved values. + + ## Raises + + None + + ## See also + + - `save_settings()` + + ## Discussion + + This is useful when a task needs to temporarily set `check_mode` + to False, (or change the timeout value) and then restore them to + their original values. + + - `check_mode` is not restored if `save_settings()` has not + previously been called. + - `timeout` is not restored if `save_settings()` has not + previously been called. + """ + if self.saved_check_mode is not None: + self.check_mode = self.saved_check_mode + if self.saved_timeout is not None: + self.timeout = self.saved_timeout + + def save_settings(self) -> None: + """ + # Summary + + Save the current values of `check_mode` and `timeout` for later + restoration. + + ## Raises + + None + + ## See also + + - `restore_settings()` + + ## NOTES + + - `check_mode` is not saved if it has not yet been initialized. + - `timeout` is not saved if it has not yet been initialized. + """ + if self.check_mode is not None: + self.saved_check_mode = self.check_mode + if self.timeout is not None: + self.saved_timeout = self.timeout + + def commit(self) -> None: + """ + # Summary + + Send the REST request to the controller + + ## Raises + + - `ValueError` if: + - RestSend()._commit_normal_mode() raises + `ValueError` + - ResponseHandler() raises `TypeError` or `ValueError` + - Sender().commit() raises `ValueError` + - `verb` is not a valid verb (GET, POST, PUT, DELETE) + - `TypeError` if: + - `check_mode` is not a `bool` + - `path` is not a `str` + - `payload` is not a `dict` + - `response` is not a `dict` + - `response_current` is not a `dict` + - `response_handler` is not an instance of + `ResponseHandler()` + - `result` is not a `dict` + - `result_current` is not a `dict` + - `send_interval` is not an `int` + - `sender` is not an instance of `Sender()` + - `timeout` is not an `int` + - `unit_test` is not a `bool` + + """ + method_name = "commit" + msg = f"{self.class_name}.{method_name}: " + msg += f"check_mode: {self.check_mode}, " + msg += f"verb: {self.verb}, " + msg += f"path: {self.path}." + self.log.debug(msg) + + try: + if self.check_mode is True: + self._commit_check_mode() + else: + self._commit_normal_mode() + except (TypeError, ValueError) as error: + msg = f"{self.class_name}.{method_name}: " + msg += "Error during commit. " + msg += f"Error details: {error}" + raise ValueError(msg) from error + + def _commit_check_mode(self) -> None: + """ + # Summary + + Simulate a controller request for check_mode. + + ## Raises + + - `ValueError` if: + - ResponseHandler() raises `TypeError` or `ValueError` + - self.response_current raises `TypeError` + - self.result_current raises `TypeError` + - self.response raises `TypeError` + - self.result raises `TypeError` + + + ## Properties read: + + - `verb`: HttpVerbEnum e.g. HttpVerb.DELETE, HttpVerb.GET, etc. + - `path`: HTTP path e.g. http://controller_ip/path/to/endpoint + - `payload`: Optional HTTP payload + + ## Properties written: + + - `response_current`: raw simulated response + - `result_current`: result from self._handle_response() method + """ + method_name = "_commit_check_mode" + + msg = f"{self.class_name}.{method_name}: " + msg += f"verb {self.verb}, path {self.path}." + self.log.debug(msg) + + response_current: dict = {} + response_current["RETURN_CODE"] = 200 + response_current["METHOD"] = self.verb + response_current["REQUEST_PATH"] = self.path + response_current["MESSAGE"] = "OK" + response_current["CHECK_MODE"] = True + response_current["DATA"] = {"simulated": "check-mode-response", "status": "Success"} + + try: + self.response_current = response_current + self.response_handler.response = self.response_current + self.response_handler.verb = self.verb + self.response_handler.commit() + self.result_current = self.response_handler.result + self._response.append(self.response_current) + self._result.append(self.result_current) + except (TypeError, ValueError) as error: + msg = f"{self.class_name}.{method_name}: " + msg += "Error building response/result. " + msg += f"Error detail: {error}" + raise ValueError(msg) from error + + def _commit_normal_mode(self) -> None: + """ + # Summary + + Call sender.commit() with retries until successful response or timeout is exceeded. + + ## Raises + + - `ValueError` if: + - HandleResponse() raises `ValueError` + - Sender().commit() raises `ValueError` + - `verb` is not a valid verb (GET, POST, PUT, DELETE)""" + method_name = "_commit_normal_mode" + timeout = copy.copy(self.timeout) + + msg = "Entering commit loop. " + msg += f"timeout: {timeout}, unit_test: {self.unit_test}." + self.log.debug(msg) + + self.sender.path = self.path + self.sender.verb = self.verb + if self.payload is not None: + self.sender.payload = self.payload + success = False + while timeout > 0 and success is False: + msg = f"{self.class_name}.{method_name}: " + msg += "Calling sender.commit(): " + msg += f"timeout {timeout}, success {success}, verb {self.verb}, path {self.path}." + self.log.debug(msg) + + try: + self.sender.commit() + except ValueError as error: + raise ValueError(error) from error + + self.response_current = self.sender.response + # Handle controller response and derive result + try: + self.response_handler.response = self.response_current + self.response_handler.verb = self.verb + self.response_handler.commit() + self.result_current = self.response_handler.result + except (TypeError, ValueError) as error: + msg = f"{self.class_name}.{method_name}: " + msg += "Error building response/result. " + msg += f"Error detail: {error}" + self.log.debug(msg) + raise ValueError(msg) from error + + msg = f"{self.class_name}.{method_name}: " + msg += f"timeout: {timeout}. " + msg += f"result_current: {json.dumps(self.result_current, indent=4, sort_keys=True)}." + self.log.debug(msg) + + msg = f"{self.class_name}.{method_name}: " + msg += f"timeout: {timeout}. " + msg += "response_current: " + msg += f"{json.dumps(self.response_current, indent=4, sort_keys=True)}." + self.log.debug(msg) + + success = self.result_current["success"] + if success is False: + if self.unit_test is False: + sleep(self.send_interval) + timeout -= self.send_interval + msg = f"{self.class_name}.{method_name}: " + msg += f"Subtracted {self.send_interval} from timeout. " + msg += f"timeout: {timeout}." + self.log.debug(msg) + + self._response.append(self.response_current) + self._result.append(self.result_current) + self._payload = None + + @property + def check_mode(self) -> bool: + """ + # Summary + + Determines if changes should be made on the controller. + + ## Raises + + - `TypeError` if value is not a `bool` + + ## Default + + `False` + + - If `False`, write operations, if any, are made on the controller. + - If `True`, write operations are not made on the controller. + Instead, controller responses for write operations are simulated + to be successful (200 response code) and these simulated responses + are returned by RestSend(). Read operations are not affected + and are sent to the controller and real responses are returned. + + ## Discussion + + We want to be able to read data from the controller for read-only + operations (i.e. to set check_mode to False temporarily, even when + the user has set check_mode to True). For example, SwitchDetails + is a read-only operation, and we want to be able to read this data to + provide a real controller response to the user. + """ + return self._check_mode + + @check_mode.setter + def check_mode(self, value: bool) -> None: + method_name = "check_mode" + if not isinstance(value, bool): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be a boolean. Got {value}." + raise TypeError(msg) + self._check_mode = value + + @property + def failed_result(self) -> dict: + """ + Return a result for a failed task with no changes + """ + return Results().failed_result + + @property + def path(self) -> str: + """ + # Summary + + Endpoint path for the REST request. + + ## Raises + + - getter: `ValueError` if `path` is not set before accessing. + + ## Example + + `/appcenter/cisco/ndfc/api/v1/...etc...` + """ + if self._path is None: + msg = f"{self.class_name}.path: path must be set before accessing." + raise ValueError(msg) + return self._path + + @path.setter + def path(self, value: str) -> None: + self._path = value + + @property + def payload(self) -> Optional[dict]: + """ + # Summary + + Return the payload to send to the controller, or None. + + ## Raises + + - setter: `TypeError` if value is not a `dict` + """ + return self._payload + + @payload.setter + def payload(self, value: dict): + method_name = "payload" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be a dict. Got {value}." + raise TypeError(msg) + self._payload = value + + @property + def response_current(self) -> dict: + """ + # Summary + + Return the current response from the controller as a `dict`. + `commit()` must be called first. + + ## Raises + + - setter: `TypeError` if value is not a `dict` + """ + return copy.deepcopy(self._response_current) + + @response_current.setter + def response_current(self, value): + method_name = "response_current" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be a dict. " + msg += f"Got type {type(value).__name__}, " + msg += f"Value: {value}." + raise TypeError(msg) + self._response_current = value + + @property + def response(self) -> list[dict]: + """ + # Summary + + The aggregated list of responses from the controller. + + `commit()` must be called first. + + ## Raises + + - setter: `TypeError` if value is not a `dict` + + """ + return copy.deepcopy(self._response) + + @response.setter + def response(self, value: dict): + method_name = "response" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be a dict. " + msg += f"Got type {type(value).__name__}, " + msg += f"Value: {value}." + raise TypeError(msg) + self._response.append(value) + + @property + def response_handler(self) -> ResponseHandlerProtocol: + """ + # Summary + + A class that implements ResponseHandlerProtocol. + + ## Raises + + - getter: `ValueError` if `response_handler` is not set before accessing. + - setter: `TypeError` if `value` does not implement `ResponseHandlerProtocol`. + + ## NOTES + + - See module_utils/rest/protocols/response_handler.py for the protocol definition. + """ + if self._response_handler is None: + msg = f"{self.class_name}.response_handler: " + msg += "response_handler must be set before accessing." + raise ValueError(msg) + return self._response_handler + + @staticmethod + def _has_member_static(obj: Any, member: str) -> bool: + """ + Check whether an object has a member without triggering descriptors. + + This avoids invoking property getters during dependency validation. + """ + try: + inspect.getattr_static(obj, member) + return True + except AttributeError: + return False + + @response_handler.setter + def response_handler(self, value: ResponseHandlerProtocol): + required_members = ( + "response", + "result", + "verb", + "commit", + "error_message", + ) + missing_members = [member for member in required_members if not self._has_member_static(value, member)] + if missing_members: + msg = f"{self.class_name}.response_handler: " + msg += "value must implement ResponseHandlerProtocol. " + msg += f"Missing members: {missing_members}. " + msg += f"Got type {type(value).__name__}." + raise TypeError(msg) + if not callable(getattr(value, "commit", None)): + msg = f"{self.class_name}.response_handler: " + msg += "value.commit must be callable. " + msg += f"Got type {type(value).__name__}." + raise TypeError(msg) + self._response_handler = value + + @property + def result(self) -> list[dict]: + """ + # Summary + + The aggregated list of results from the controller. + + `commit()` must be called first. + + ## Raises + + - setter: `TypeError` if: + - value is not a `dict`. + + """ + return copy.deepcopy(self._result) + + @result.setter + def result(self, value: dict): + method_name = "result" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be a dict. " + msg += f"Got type {type(value).__name__}, " + msg += f"Value: {value}." + raise TypeError(msg) + self._result.append(value) + + @property + def result_current(self) -> dict: + """ + # Summary + + The current result from the controller + + `commit()` must be called first. + + This is a dict containing the current result. + + ## Raises + + - setter: `TypeError` if value is not a `dict` + + """ + return copy.deepcopy(self._result_current) + + @result_current.setter + def result_current(self, value: dict): + method_name = "result_current" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be a dict. " + msg += f"Got {value}." + raise TypeError(msg) + self._result_current = value + + @property + def send_interval(self) -> int: + """ + # Summary + + Send interval, in seconds, for retrying responses from the controller. + + ## Raises + + - setter: ``TypeError`` if value is not an `int` + + ## Default + + `5` + """ + return self._send_interval + + @send_interval.setter + def send_interval(self, value: int) -> None: + method_name = "send_interval" + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be an integer. " + msg += f"Got type {type(value).__name__}, " + msg += f"value {value}." + # Check explicit boolean first since isinstance(True, int) is True + if isinstance(value, bool): + raise TypeError(msg) + if not isinstance(value, int): + raise TypeError(msg) + self._send_interval = value + + @property + def sender(self) -> SenderProtocol: + """ + # Summary + + A class implementing the SenderProtocol. + + See module_utils/rest/protocols/sender.py for SenderProtocol definition. + + ## Raises + + - getter: ``ValueError`` if sender is not set before accessing. + - setter: ``TypeError`` if value does not implement SenderProtocol. + """ + if self._sender is None: + msg = f"{self.class_name}.sender: " + msg += "sender must be set before accessing." + raise ValueError(msg) + return self._sender + + @sender.setter + def sender(self, value: SenderProtocol): + required_members = ( + "path", + "verb", + "payload", + "response", + "commit", + ) + missing_members = [member for member in required_members if not self._has_member_static(value, member)] + if missing_members: + msg = f"{self.class_name}.sender: " + msg += "value must implement SenderProtocol. " + msg += f"Missing members: {missing_members}. " + msg += f"Got type {type(value).__name__}." + raise TypeError(msg) + if not callable(getattr(value, "commit", None)): + msg = f"{self.class_name}.sender: " + msg += "value.commit must be callable. " + msg += f"Got type {type(value).__name__}." + raise TypeError(msg) + self._sender = value + + @property + def timeout(self) -> int: + """ + # Summary + + Timeout, in seconds, for retrieving responses from the controller. + + ## Raises + + - setter: ``TypeError`` if value is not an ``int`` + + ## Default + + `300` + """ + return self._timeout + + @timeout.setter + def timeout(self, value: int) -> None: + method_name = "timeout" + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be an integer. " + msg += f"Got type {type(value).__name__}, " + msg += f"value {value}." + if isinstance(value, bool): + raise TypeError(msg) + if not isinstance(value, int): + raise TypeError(msg) + self._timeout = value + + @property + def unit_test(self) -> bool: + """ + # Summary + + Is RestSend being called from a unit test. + Set this to True in unit tests to speed the test up. + + ## Raises + + - setter: `TypeError` if value is not a `bool` + + ## Default + + `False` + """ + return self._unit_test + + @unit_test.setter + def unit_test(self, value: bool) -> None: + method_name = "unit_test" + if not isinstance(value, bool): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be a boolean. " + msg += f"Got type {type(value).__name__}, " + msg += f"value {value}." + raise TypeError(msg) + self._unit_test = value + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + HTTP method for the REST request e.g. HttpVerbEnum.GET, HttpVerbEnum.POST, etc. + + ## Raises + + - setter: `TypeError` if value is not an instance of HttpVerbEnum + - getter: `ValueError` if verb is not set before accessing. + """ + if self._verb is None: + msg = f"{self.class_name}.verb: " + msg += "verb must be set before accessing." + raise ValueError(msg) + return self._verb + + @verb.setter + def verb(self, value: HttpVerbEnum): + if not isinstance(value, HttpVerbEnum): + msg = f"{self.class_name}.verb: " + msg += "verb must be an instance of HttpVerbEnum. " + msg += f"Got type {type(value).__name__}." + raise TypeError(msg) + self._verb = value diff --git a/plugins/module_utils/rest/results.py b/plugins/module_utils/rest/results.py new file mode 100644 index 00000000..921f0a0f --- /dev/null +++ b/plugins/module_utils/rest/results.py @@ -0,0 +1,1012 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# pylint: disable=too-many-instance-attributes,too-many-public-methods,line-too-long,too-many-lines +""" +Exposes public class Results to collect results across Ansible tasks. +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import copy +import logging +from typing import Any, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.common.pydantic_compat import ( + BaseModel, + ConfigDict, + Field, + ValidationError, +) +from ansible_collections.cisco.nd.plugins.module_utils.enums import OperationType + + +class ApiCallResult(BaseModel): + """ + # Summary + + Pydantic model for a single task result. + + Represents all data for one API call including its response, result, diff, + and metadata. Immutable after creation to prevent accidental modification + of registered results. + + ## Raises + + - `ValidationError`: if field validation fails during instantiation + + ## Attributes + + - `sequence_number`: Unique sequence number for this task (required, >= 1) + - `response`: Controller response dict (required) + - `result`: Handler result dict (required) + - `diff`: Changes dict (required, can be empty) + - `metadata`: Task metadata dict (required) + - `changed`: Whether this task resulted in changes (required) + - `failed`: Whether this task failed (required) + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + sequence_number: int = Field(ge=1) + response: dict[str, Any] + result: dict[str, Any] + diff: dict[str, Any] + metadata: dict[str, Any] + changed: bool + failed: bool + + +class FinalResultData(BaseModel): + """ + # Summary + + Pydantic model for the final aggregated result. + + This is the structure returned to Ansible's `exit_json`/`fail_json`. + Contains aggregated data from all registered tasks. + + ## Raises + + - `ValidationError`: if field validation fails during instantiation + + ## Attributes + + - `changed`: Overall changed status across all tasks (required) + - `failed`: Overall failed status across all tasks (required) + - `diff`: List of all diff dicts (default empty list) + - `response`: List of all response dicts (default empty list) + - `result`: List of all result dicts (default empty list) + - `metadata`: List of all metadata dicts (default empty list) + """ + + model_config = ConfigDict(extra="forbid") + + changed: bool + failed: bool + diff: list[dict[str, Any]] = Field(default_factory=list) + response: list[dict[str, Any]] = Field(default_factory=list) + result: list[dict[str, Any]] = Field(default_factory=list) + metadata: list[dict[str, Any]] = Field(default_factory=list) + + +class PendingApiCall(BaseModel): + """ + # Summary + + Pydantic model for the current task data being built. + + Mutable model used to stage data for the current task before + it's registered and converted to an immutable `ApiCallResult`. + Provides validation while allowing flexibility during the build phase. + + ## Raises + + - `ValidationError`: if field validation fails during instantiation or assignment + + ## Attributes + + - `response`: Controller response dict (default empty dict) + - `result`: Handler result dict (default empty dict) + - `diff`: Changes dict (default empty dict) + - `action`: Action name for metadata (default empty string) + - `state`: Ansible state for metadata (default empty string) + - `check_mode`: Check mode flag for metadata (default False) + - `operation_type`: Operation type determining if changes might occur (default QUERY) + """ + + model_config = ConfigDict(extra="allow", validate_assignment=True) + + response: dict[str, Any] = Field(default_factory=dict) + result: dict[str, Any] = Field(default_factory=dict) + diff: dict[str, Any] = Field(default_factory=dict) + action: str = "" + state: str = "" + check_mode: bool = False + operation_type: OperationType = OperationType.QUERY + + +class Results: + """ + # Summary + + Collect and aggregate results across tasks using Pydantic data models. + + ## Raises + + - `TypeError`: if properties are not of the correct type + - `ValueError`: if Pydantic validation fails or required data is missing + + ## Architecture + + This class uses a three-model Pydantic architecture for data validation: + + 1. `PendingApiCall` - Mutable staging area for building the current task + 2. `ApiCallResult` - Immutable registered API call result with validation (frozen=True) + 3. `FinalResultData` - Aggregated result for Ansible output + + The lifecycle is: **Build (Current) → Register (Task) → Aggregate (Final)** + + ## Description + + Provides a mechanism to collect results across tasks. The task classes + must support this Results class. Specifically, they must implement the + following: + + 1. Accept an instantiation of `Results()` + - Typically a class property is used for this + 2. Populate the `Results` instance with the current task data + - Set properties: `response_current`, `result_current`, `diff_current` + - Set metadata properties: `action`, `state`, `check_mode`, `operation_type` + 3. Optional. Register the task result with `Results.register_api_call()` + - Converts current task to immutable `ApiCallResult` + - Validates data with Pydantic + - Resets current task for next registration + - Tasks are NOT required to be registered. There are cases where + a task's information would not be useful to an end-user. If this + is the case, the task can simply not be registered. + + `Results` should be instantiated in the main Ansible Task class and + passed to all other task classes for which results are to be collected. + The task classes should populate the `Results` instance with the results + of the task and then register the results with `Results.register_api_call()`. + + This may be done within a separate class (as in the example below, where + the `FabricDelete()` class is called from the `TaskDelete()` class. + The `Results` instance can then be used to build the final result, by + calling `Results.build_final_result()`. + + ## Example Usage + + We assume an Ansible module structure as follows: + + - `TaskCommon()`: Common methods used by the various ansible + state classes. + - `TaskDelete(TaskCommon)`: Implements the delete state + - `TaskMerge(TaskCommon)`: Implements the merge state + - `TaskQuery(TaskCommon)`: Implements the query state + - etc... + + In TaskCommon, `Results` is instantiated and, hence, is inherited by all + state classes.: + + ```python + class TaskCommon: + def __init__(self): + self._results = Results() + + @property + def results(self) -> Results: + ''' + An instance of the Results class. + ''' + return self._results + + @results.setter + def results(self, value: Results) -> None: + self._results = value + ``` + + In each of the state classes (TaskDelete, TaskMerge, TaskQuery, etc...) + a class is instantiated (in the example below, FabricDelete) that + supports collecting results for the Results instance: + + ```python + class TaskDelete(TaskCommon): + def __init__(self, ansible_module): + super().__init__(ansible_module) + self.fabric_delete = FabricDelete(self.ansible_module) + + def commit(self): + ''' + delete the fabric + ''' + ... + self.fabric_delete.fabric_names = ["FABRIC_1", "FABRIC_2"] + self.fabric_delete.results = self.results + # results.register_api_call() is optionally called within the + # commit() method of the FabricDelete class. + self.fabric_delete.commit() + ``` + + Finally, within the main() method of the Ansible module, the final result + is built by calling Results.build_final_result(): + + ```python + if ansible_module.params["state"] == "deleted": + task = TaskDelete(ansible_module) + task.commit() + elif ansible_module.params["state"] == "merged": + task = TaskDelete(ansible_module) + task.commit() + # etc, for other states... + + # Build the final result + task.results.build_final_result() + + # Call fail_json() or exit_json() based on the final result + if True in task.results.failed: + ansible_module.fail_json(**task.results.final_result) + ansible_module.exit_json(**task.results.final_result) + ``` + + results.final_result will be a dict with the following structure + + ```json + { + "changed": True, # or False + "failed": True, # or False + "diff": { + [{"diff1": "diff"}, {"diff2": "diff"}, {"etc...": "diff"}], + } + "response": { + [{"response1": "response"}, {"response2": "response"}, {"etc...": "response"}], + } + "result": { + [{"result1": "result"}, {"result2": "result"}, {"etc...": "result"}], + } + "metadata": { + [{"metadata1": "metadata"}, {"metadata2": "metadata"}, {"etc...": "metadata"}], + } + } + ``` + + diff, response, and result dicts are per the Ansible ND Collection standard output. + + An example of a result dict would be (sequence_number is added by Results): + + ```json + { + "found": true, + "sequence_number": 1, + "success": true + } + ``` + + An example of a metadata dict would be (sequence_number is added by Results): + + + ```json + { + "action": "merge", + "check_mode": false, + "state": "merged", + "sequence_number": 1 + } + ``` + + `sequence_number` indicates the order in which the task was registered + with `Results`. It provides a way to correlate the diff, response, + result, and metadata across all tasks. + + ## Typical usage within a task class such as FabricDelete + + ```python + from ansible_collections.cisco.nd.plugins.module_utils.enums import OperationType + from ansible_collections.cisco.nd.plugins.module_utils.rest.results import Results + from ansible_collections.cisco.nd.plugins.module_utils.rest.rest_send import RestSend + ... + class FabricDelete: + def __init__(self, ansible_module): + ... + self.action: str = "fabric_delete" + self.operation_type: OperationType = OperationType.DELETE # Determines if changes might occur + self._rest_send: RestSend = RestSend(params) + self._results: Results = Results() + ... + + def commit(self): + ... + # Set current task data (no need to manually track changed/failed) + self._results.response_current = self._rest_send.response_current + self._results.result_current = self._rest_send.result_current + self._results.diff_current = {} # or actual diff if available + # register_api_call() determines changed/failed automatically + self._results.register_api_call() + ... + + @property + def results(self) -> Results: + ''' + An instance of the Results class. + ''' + return self._results + @results.setter + def results(self, value: Results) -> None: + self._results = value + self._results.action = self.action + self._results.operation_type = self.operation_type + """ + + def __init__(self) -> None: + self.class_name: str = self.__class__.__name__ + + self.log: logging.Logger = logging.getLogger(f"nd.{self.class_name}") + + # Task sequence tracking + self.task_sequence_number: int = 0 + + # Registered tasks (immutable after registration) + self._tasks: list[ApiCallResult] = [] + + # Current task being built (mutable) + self._current: PendingApiCall = PendingApiCall() + + # Final result (built on demand) + self._final_result: Optional[FinalResultData] = None + + msg = f"ENTERED {self.class_name}():" + self.log.debug(msg) + + def add_response_data(self, value: dict[str, Any]) -> None: + """ + # Summary + + No-op. Formerly added a dict to the response_data list. + + .. deprecated:: + + `add_response_data()` is deprecated and may be removed in a future release. + Use `register_api_call()` instead; response data is now derived from registered tasks. + + ## Raises + + None + """ + + def _increment_task_sequence_number(self) -> None: + """ + # Summary + + Increment a unique task sequence number. + + ## Raises + + None + """ + self.task_sequence_number += 1 + msg = f"self.task_sequence_number: {self.task_sequence_number}" + self.log.debug(msg) + + def _determine_if_changed(self) -> bool: + """ + # Summary + + Determine if the current task resulted in changes. + + This is a private helper method used during task registration. + Checks operation type, check mode, explicit changed flag, + and diff content to determine if changes occurred. + + ## Raises + + None + + ## Returns + + - `bool`: True if changes occurred, False otherwise + """ + method_name: str = "_determine_if_changed" + + msg = f"{self.class_name}.{method_name}: ENTERED: " + msg += f"action={self._current.action}, " + msg += f"operation_type={self._current.operation_type}, " + msg += f"state={self._current.state}, " + msg += f"check_mode={self._current.check_mode}" + self.log.debug(msg) + + # Early exit for read-only operations + if self._current.check_mode or self._current.operation_type.is_read_only(): + msg = f"{self.class_name}.{method_name}: No changes (read-only operation)" + self.log.debug(msg) + return False + + # Check explicit changed flag in result + changed_flag = self._current.result.get("changed") + if changed_flag is not None: + msg = f"{self.class_name}.{method_name}: changed={changed_flag} (from result)" + self.log.debug(msg) + return changed_flag + + # Check if diff has content (besides sequence_number) + has_diff_content = any(key != "sequence_number" for key in self._current.diff) + + msg = f"{self.class_name}.{method_name}: changed={has_diff_content} (from diff)" + self.log.debug(msg) + return has_diff_content + + def register_api_call(self) -> None: + """ + # Summary + + Register the current task result. + + Converts `PendingApiCall` to immutable `ApiCallResult`, increments + sequence number, and aggregates changed/failed status. The current task + is then reset for the next task. + + ## Raises + + - `ValueError`: if Pydantic validation fails for task result data + - `ValueError`: if required fields are missing + + ## Description + + 1. Increment the task sequence number + 2. Build metadata from current task properties + 3. Determine if anything changed using `_determine_if_changed()` + 4. Determine if task failed based on `result["success"]` flag + 5. Add sequence_number to response, result, and diff + 6. Create immutable `ApiCallResult` with validation + 7. Register the task and update aggregated changed/failed sets + 8. Reset current task for next registration + """ + method_name: str = "register_api_call" + + msg = f"{self.class_name}.{method_name}: " + msg += f"ENTERED: action={self._current.action}, " + msg += f"result_current={self._current.result}" + self.log.debug(msg) + + # Increment sequence number + self._increment_task_sequence_number() + + # Build metadata from current task + metadata = { + "action": self._current.action, + "check_mode": self._current.check_mode, + "sequence_number": self.task_sequence_number, + "state": self._current.state, + } + + # Determine changed status + changed = self._determine_if_changed() + + # Determine failed status from result + success = self._current.result.get("success") + if success is True: + failed = False + elif success is False: + failed = True + else: + msg = f"{self.class_name}.{method_name}: " + msg += "result['success'] is not a boolean. " + msg += f"result={self._current.result}. " + msg += "Setting failed=False." + self.log.debug(msg) + failed = False + + # Add sequence_number to response, result, diff + response = copy.deepcopy(self._current.response) + response["sequence_number"] = self.task_sequence_number + + result = copy.deepcopy(self._current.result) + result["sequence_number"] = self.task_sequence_number + + diff = copy.deepcopy(self._current.diff) + diff["sequence_number"] = self.task_sequence_number + + # Create immutable ApiCallResult with validation + try: + task_data = ApiCallResult( + sequence_number=self.task_sequence_number, + response=response, + result=result, + diff=diff, + metadata=metadata, + changed=changed, + failed=failed, + ) + except ValidationError as error: + msg = f"{self.class_name}.{method_name}: " + msg += f"Validation failed for task result: {error}" + raise ValueError(msg) from error + + # Register the task + self._tasks.append(task_data) + + # Reset current task for next task + self._current = PendingApiCall() + + # Log registration + if self.log.isEnabledFor(logging.DEBUG): + msg = f"{self.class_name}.{method_name}: " + msg += f"Registered task {self.task_sequence_number}: " + msg += f"changed={changed}, failed={failed}" + self.log.debug(msg) + + def build_final_result(self) -> None: + """ + # Summary + + Build the final result from all registered tasks. + + Creates a `FinalResultData` Pydantic model with aggregated + changed/failed status and all task data. The model is stored + internally and can be accessed via the `final_result` property. + + ## Raises + + - `ValueError`: if Pydantic validation fails for final result + + ## Description + + The final result consists of the following: + + ```json + { + "changed": True, # or False + "failed": True, + "diff": { + [], + }, + "response": { + [], + }, + "result": { + [], + }, + "metadata": { + [], + } + ``` + """ + method_name: str = "build_final_result" + + msg = f"{self.class_name}.{method_name}: " + msg += f"changed={self.changed}, failed={self.failed}" + self.log.debug(msg) + + # Aggregate data from all tasks + diff_list = [task.diff for task in self._tasks] + response_list = [task.response for task in self._tasks] + result_list = [task.result for task in self._tasks] + metadata_list = [task.metadata for task in self._tasks] + + # Create FinalResultData with validation + try: + self._final_result = FinalResultData( + changed=True in self.changed, + failed=True in self.failed, + diff=diff_list, + response=response_list, + result=result_list, + metadata=metadata_list, + ) + except ValidationError as error: + msg = f"{self.class_name}.{method_name}: " + msg += f"Validation failed for final result: {error}" + raise ValueError(msg) from error + + msg = f"{self.class_name}.{method_name}: " + msg += f"Built final result: changed={self._final_result.changed}, " + msg += f"failed={self._final_result.failed}, " + msg += f"tasks={len(self._tasks)}" + self.log.debug(msg) + + @property + def final_result(self) -> dict[str, Any]: + """ + # Summary + + Return the final result as a dict for Ansible `exit_json`/`fail_json`. + + ## Raises + + - `ValueError`: if `build_final_result()` hasn't been called + + ## Returns + + - `dict[str, Any]`: The final result dictionary with all aggregated data + """ + if self._final_result is None: + msg = f"{self.class_name}.final_result: " + msg += "build_final_result() must be called before accessing final_result" + raise ValueError(msg) + return self._final_result.model_dump() + + @property + def failed_result(self) -> dict[str, Any]: + """ + # Summary + + Return a result for a failed task with no changes + + ## Raises + + None + """ + result: dict = {} + result["changed"] = False + result["failed"] = True + result["diff"] = [{}] + result["response"] = [{}] + result["result"] = [{}] + return result + + @property + def ok_result(self) -> dict[str, Any]: + """ + # Summary + + Return a result for a successful task with no changes + + ## Raises + + None + """ + result: dict = {} + result["changed"] = False + result["failed"] = False + result["diff"] = [{}] + result["response"] = [{}] + result["result"] = [{}] + return result + + @property + def action(self) -> str: + """ + # Summary + + Action name for the current task. + + Used in metadata to indicate the action that was taken. + + ## Raises + + None + """ + return self._current.action + + @action.setter + def action(self, value: str) -> None: + method_name: str = "action" + if not isinstance(value, str): + msg = f"{self.class_name}.{method_name}: " + msg += f"value must be a string. Got {type(value).__name__}." + raise TypeError(msg) + self._current.action = value + + @property + def operation_type(self) -> OperationType: + """ + # Summary + + The operation type for the current operation. + + Used to determine if the operation might change controller state. + + ## Raises + + None + + ## Returns + + The current operation type (`OperationType` enum value) + """ + return self._current.operation_type + + @operation_type.setter + def operation_type(self, value: OperationType) -> None: + """ + # Summary + + Set the operation type for the current task. + + ## Raises + + - `TypeError`: if value is not an `OperationType` instance + + ## Parameters + + - value: The operation type to set (must be an `OperationType` enum value) + """ + method_name: str = "operation_type" + if not isinstance(value, OperationType): + msg = f"{self.class_name}.{method_name}: " + msg += "value must be an OperationType instance. " + msg += f"Got type {type(value).__name__}, value {value}." + raise TypeError(msg) + self._current.operation_type = value + + @property + def changed(self) -> set[bool]: + """ + # Summary + + Returns a set() containing boolean values indicating whether anything changed. + + Derived from the `changed` attribute of all registered `ApiCallResult` tasks. + + ## Raises + + None + + ## Returns + + - A set() of boolean values indicating whether any tasks changed + + ## See also + + - `register_api_call()` method to register tasks. + """ + return {task.changed for task in self._tasks} + + @property + def check_mode(self) -> bool: + """ + # Summary + + Ansible check_mode flag for the current task. + + - `True` if check_mode is enabled, `False` otherwise. + + ## Raises + + None + """ + return self._current.check_mode + + @check_mode.setter + def check_mode(self, value: bool) -> None: + method_name: str = "check_mode" + if not isinstance(value, bool): + msg = f"{self.class_name}.{method_name}: " + msg += f"value must be a bool. Got {type(value).__name__}." + raise TypeError(msg) + self._current.check_mode = value + + @property + def diff(self) -> list[dict[str, Any]]: + """ + # Summary + + A list of dicts representing the changes made across all registered tasks. + + ## Raises + + None + + ## Returns + + - `list[dict[str, Any]]`: List of diff dictionaries from all registered tasks + """ + return [task.diff for task in self._tasks] + + @property + def diff_current(self) -> dict[str, Any]: + """ + # Summary + + A dict representing the current diff for the current task. + + ## Raises + + - setter: `TypeError` if value is not a dict + """ + return self._current.diff + + @diff_current.setter + def diff_current(self, value: dict[str, Any]) -> None: + method_name: str = "diff_current" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"value must be a dict. Got {type(value).__name__}." + raise TypeError(msg) + self._current.diff = value + + @property + def failed(self) -> set[bool]: + """ + # Summary + + A set() of boolean values indicating whether any tasks failed. + + Derived from the `failed` attribute of all registered `ApiCallResult` tasks. + + - If the set contains True, at least one task failed. + - If the set contains only False all tasks succeeded. + + ## Raises + + None + + ## See also + + - `register_api_call()` method to register tasks. + """ + return {task.failed for task in self._tasks} + + @property + def metadata(self) -> list[dict[str, Any]]: + """ + # Summary + + A list of dicts representing the metadata for all registered tasks. + + ## Raises + + None + + ## Returns + + - `list[dict[str, Any]]`: List of metadata dictionaries from all registered tasks + """ + return [task.metadata for task in self._tasks] + + @property + def metadata_current(self) -> dict[str, Any]: + """ + # Summary + + Return the current metadata which is comprised of the following properties: + + - action + - check_mode + - sequence_number + - state + + ## Raises + + None + """ + value: dict[str, Any] = {} + value["action"] = self.action + value["check_mode"] = self.check_mode + value["sequence_number"] = self.task_sequence_number + value["state"] = self.state + return value + + @property + def response_current(self) -> dict[str, Any]: + """ + # Summary + + Return a `dict` containing the current response from the controller for the current task. + + ## Raises + + - setter: `TypeError` if value is not a dict + """ + return self._current.response + + @response_current.setter + def response_current(self, value: dict[str, Any]) -> None: + method_name: str = "response_current" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"value must be a dict. Got {type(value).__name__}." + raise TypeError(msg) + self._current.response = value + + @property + def response(self) -> list[dict[str, Any]]: + """ + # Summary + + Return the response list; `list` of `dict`, where each `dict` contains a + response from the controller across all registered tasks. + + ## Raises + + None + + ## Returns + + - `list[dict[str, Any]]`: List of response dictionaries from all registered tasks + """ + return [task.response for task in self._tasks] + + @property + def response_data(self) -> list[dict[str, Any]]: + """ + # Summary + + Return a `list` of `dict`, where each `dict` contains the contents of the DATA key within the responses that have been added. + + .. deprecated:: + + `response_data` is deprecated and may be removed in a future release. Use the `response` property instead. + + ## Raises + + None + + ## See also + + `add_response_data()` method to add to the response_data list. + """ + return self.response + + @property + def result(self) -> list[dict[str, Any]]: + """ + # Summary + + A `list` of `dict`, where each `dict` contains a result across all registered tasks. + + ## Raises + + None + + ## Returns + + - `list[dict[str, Any]]`: List of result dictionaries from all registered tasks + """ + return [task.result for task in self._tasks] + + @property + def result_current(self) -> dict[str, Any]: + """ + # Summary + + A `dict` representing the current result for the current task. + + ## Raises + + - setter: `TypeError` if value is not a dict + """ + return self._current.result + + @result_current.setter + def result_current(self, value: dict[str, Any]) -> None: + method_name: str = "result_current" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"value must be a dict. Got {type(value).__name__}." + raise TypeError(msg) + self._current.result = value + + @property + def state(self) -> str: + """ + # Summary + + The Ansible state for the current task. + + ## Raises + + - setter: `TypeError` if value is not a string + """ + return self._current.state + + @state.setter + def state(self, value: str) -> None: + method_name: str = "state" + if not isinstance(value, str): + msg = f"{self.class_name}.{method_name}: " + msg += f"value must be a string. Got {type(value).__name__}." + raise TypeError(msg) + self._current.state = value diff --git a/plugins/module_utils/rest/sender_nd.py b/plugins/module_utils/rest/sender_nd.py new file mode 100644 index 00000000..ae333dd0 --- /dev/null +++ b/plugins/module_utils/rest/sender_nd.py @@ -0,0 +1,322 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +""" +Sender module conforming to SenderProtocol. + +See plugins/module_utils/protocol_sender.py for the protocol definition. +""" + +# isort: off +# fmt: off +from __future__ import (absolute_import, division, print_function) +from __future__ import annotations +# fmt: on +# isort: on + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import copy +import inspect +import json +import logging +from typing import Any, Optional + +from ansible.module_utils.basic import AnsibleModule # type: ignore +from ansible.module_utils.connection import Connection # type: ignore +from ansible.module_utils.connection import ConnectionError as AnsibleConnectionError +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum + + +class Sender: + """ + # Summary + + An injected dependency for `RestSend` which implements the + `sender` interface. Responses are retrieved using the Ansible HttpApi plugin. + + For the `sender` interface definition, see `plugins/module_utils/protocol_sender.py`. + + ## Raises + + - `ValueError` if: + - `ansible_module` is not set. + - `path` is not set. + - `verb` is not set. + - `TypeError` if: + - `ansible_module` is not an instance of AnsibleModule. + - `payload` is not a `dict`. + - `response` is not a `dict`. + + ## Usage + + `ansible_module` is an instance of `AnsibleModule`. + + ```python + sender = Sender() + try: + sender.ansible_module = ansible_module + rest_send = RestSend() + rest_send.sender = sender + except (TypeError, ValueError) as error: + handle_error(error) + # etc... + # See rest_send.py for RestSend() usage. + ``` + """ + + def __init__( + self, + ansible_module: Optional[AnsibleModule] = None, + verb: Optional[HttpVerbEnum] = None, + path: Optional[str] = None, + payload: Optional[dict[str, Any]] = None, + ) -> None: + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"nd.{self.class_name}") + + self._ansible_module: Optional[AnsibleModule] = ansible_module + self._connection: Optional[Connection] = None + + self._path: Optional[str] = path + self._payload: Optional[dict[str, Any]] = payload + self._response: Optional[dict[str, Any]] = None + self._verb: Optional[HttpVerbEnum] = verb + + msg = "ENTERED Sender(): " + self.log.debug(msg) + + def _get_caller_name(self) -> str: + """ + # Summary + + Get the name of the method that called the current method. + + ## Raises + + None + + ## Returns + + - `str`: The name of the calling method + """ + return inspect.stack()[2][3] + + def commit(self) -> None: + """ + # Summary + + Send the request to the controller + + ## Raises + + - `ValueError` if there is an error with the connection to the controller. + + ## Properties read + + - `verb`: HTTP verb e.g. GET, POST, PATCH, PUT, DELETE + - `path`: HTTP path e.g. /api/v1/some_endpoint + - `payload`: Optional HTTP payload + + ## Properties written + + - `response`: raw response from the controller + """ + method_name = "commit" + caller = self._get_caller_name() + + if self._connection is None: + self._connection = Connection(self.ansible_module._socket_path) # pylint: disable=protected-access + self._connection.set_params(self.ansible_module.params) + + msg = f"{self.class_name}.{method_name}: " + msg += f"caller: {caller}. " + msg += "Calling Connection().send_request: " + msg += f"verb {self.verb.value}, path {self.path}" + try: + if self.payload is None: + self.log.debug(msg) + response = self._connection.send_request(self.verb.value, self.path) + else: + msg += ", payload: " + msg += f"{json.dumps(self.payload, indent=4, sort_keys=True)}" + self.log.debug(msg) + response = self._connection.send_request( + self.verb.value, + self.path, + json.dumps(self.payload), + ) + # Normalize response: if JSON parsing failed, DATA will be None + # and raw content will be in the "raw" key. Convert to consistent format. + response = self._normalize_response(response) + self.response = response + except AnsibleConnectionError as error: + msg = f"{self.class_name}.{method_name}: " + msg += f"ConnectionError occurred: {error}" + self.log.error(msg) + raise ValueError(msg) from error + except Exception as error: + msg = f"{self.class_name}.{method_name}: " + msg += f"Unexpected error occurred: {error}" + self.log.error(msg) + raise ValueError(msg) from error + + def _normalize_response(self, response: dict) -> dict: + """ + # Summary + + Normalize the HttpApi response to ensure consistent format. + + If the HttpApi plugin failed to parse the response as JSON, the + `DATA` key will be None and the raw response content will be in + the `raw` key. This method converts such responses to a consistent + format where `DATA` contains a dict with the raw content. + + ## Parameters + + - `response`: The response dict from the HttpApi plugin. + + ## Returns + + The normalized response dict. + """ + if response.get("DATA") is None and response.get("raw") is not None: + response["DATA"] = {"raw_response": response.get("raw")} + # If MESSAGE is just the HTTP reason phrase, enhance it + if response.get("MESSAGE") in ("OK", None): + response["MESSAGE"] = "Response could not be parsed as JSON" + return response + + @property + def ansible_module(self) -> AnsibleModule: + """ + # Summary + + The AnsibleModule instance to use for this sender. + + ## Raises + + - `ValueError` if ansible_module is not set. + """ + if self._ansible_module is None: + msg = f"{self.class_name}.ansible_module: " + msg += "ansible_module must be set before accessing ansible_module." + raise ValueError(msg) + return self._ansible_module + + @ansible_module.setter + def ansible_module(self, value: AnsibleModule): + self._ansible_module = value + + @property + def path(self) -> str: + """ + # Summary + + Endpoint path for the REST request. + + ## Raises + + - getter: `ValueError` if `path` is not set before accessing. + + ## Example + + ``/appcenter/cisco/ndfc/api/v1/...etc...`` + """ + if self._path is None: + msg = f"{self.class_name}.path: " + msg += "path must be set before accessing path." + raise ValueError(msg) + return self._path + + @path.setter + def path(self, value: str): + self._path = value + + @property + def payload(self) -> Optional[dict[str, Any]]: + """ + # Summary + + Return the payload to send to the controller + + ## Raises + - `TypeError` if value is not a `dict`. + """ + return self._payload + + @payload.setter + def payload(self, value: dict): + method_name = "payload" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be a dict. " + msg += f"Got type {type(value).__name__}, " + msg += f"value {value}." + raise TypeError(msg) + self._payload = value + + @property + def response(self) -> dict: + """ + # Summary + + The response from the controller. + + - getter: Return a deepcopy of `response` + - setter: Set `response` + + ## Raises + + - getter: `ValueError` if response is not set. + - setter: `TypeError` if value is not a `dict`. + """ + if self._response is None: + msg = f"{self.class_name}.response: " + msg += "response must be set before accessing response." + raise ValueError(msg) + return copy.deepcopy(self._response) + + @response.setter + def response(self, value: dict): + method_name = "response" + if not isinstance(value, dict): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be a dict. " + msg += f"Got type {type(value).__name__}, " + msg += f"value {value}." + raise TypeError(msg) + self._response = value + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + HTTP method for the REST request. + + ## Raises + + - getter: `ValueError` if verb is not set. + - setter: `TypeError` if value is not a `HttpVerbEnum`. + """ + if self._verb is None: + msg = f"{self.class_name}.verb: " + msg += "verb must be set before accessing verb." + raise ValueError(msg) + return self._verb + + @verb.setter + def verb(self, value: HttpVerbEnum): + method_name = "verb" + if value not in HttpVerbEnum.values(): + msg = f"{self.class_name}.{method_name}: " + msg += f"{method_name} must be one of {HttpVerbEnum.values()}. " + msg += f"Got {value}." + raise TypeError(msg) + self._verb = value diff --git a/tests/config.yml b/tests/config.yml new file mode 100644 index 00000000..7cf024ab --- /dev/null +++ b/tests/config.yml @@ -0,0 +1,3 @@ +modules: + # Limit Python version to control node Python versions + python_requires: controller diff --git a/tests/sanity/requirements.txt b/tests/sanity/requirements.txt index 8ea87eb9..2bc68e74 100644 --- a/tests/sanity/requirements.txt +++ b/tests/sanity/requirements.txt @@ -1,4 +1,4 @@ packaging # needed for update-bundled and changelog -sphinx ; python_version >= '3.5' # docs build requires python 3+ -sphinx-notfound-page ; python_version >= '3.5' # docs build requires python 3+ -straight.plugin ; python_version >= '3.5' # needed for hacking/build-ansible.py which will host changelog generation and requires python 3+ \ No newline at end of file +sphinx +sphinx-notfound-page +straight.plugin diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/module_utils/__init__.py b/tests/unit/module_utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/module_utils/common_utils.py b/tests/unit/module_utils/common_utils.py new file mode 100644 index 00000000..bc64b0d6 --- /dev/null +++ b/tests/unit/module_utils/common_utils.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Common utilities used by unit tests. +""" + +from __future__ import absolute_import, annotations, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name + +from contextlib import contextmanager + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.log import Log +from ansible_collections.cisco.nd.tests.unit.module_utils.fixtures.load_fixture import load_fixture +from ansible_collections.cisco.nd.tests.unit.module_utils.response_generator import ResponseGenerator +from ansible_collections.cisco.nd.tests.unit.module_utils.sender_file import Sender as SenderFile + +params = { + "state": "merged", + "config": {"switches": [{"ip_address": "172.22.150.105"}]}, + "check_mode": False, +} + + +# See the following for explanation of why fixtures are explicitely named +# https://pylint.pycqa.org/en/latest/user_guide/messages/warning/redefined-outer-name.html +# @pytest.fixture(name="controller_version") +# def controller_version_fixture(): +# """ +# return ControllerVersion instance. +# """ +# return ControllerVersion() +@pytest.fixture(name="sender_file") +def sender_file_fixture(): + """ + return Send() imported from sender_file.py + """ + + def responses(): + yield {} + + instance = SenderFile() + instance.gen = ResponseGenerator(responses()) + return instance + + +@pytest.fixture(name="log") +def log_fixture(): + """ + return Log instance + """ + return Log() + + +@contextmanager +def does_not_raise(): + """ + A context manager that does not raise an exception. + """ + yield + + +def responses_sender_file(key: str) -> dict[str, str]: + """ + Return data in responses_SenderFile.json + """ + response_file = "responses_SenderFile" + response = load_fixture(response_file).get(key) + print(f"responses_sender_file: {key} : {response}") + return response diff --git a/tests/unit/module_utils/fixtures/fixture_data/test_rest_send.json b/tests/unit/module_utils/fixtures/fixture_data/test_rest_send.json new file mode 100644 index 00000000..88aa460a --- /dev/null +++ b/tests/unit/module_utils/fixtures/fixture_data/test_rest_send.json @@ -0,0 +1,244 @@ +{ + "TEST_NOTES": [ + "Fixture data for test_rest_send.py tests", + "Provides mock controller responses for REST operations" + ], + "test_rest_send_00100a": { + "TEST_NOTES": ["Successful GET request response"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/endpoint", + "MESSAGE": "OK", + "DATA": { + "status": "success", + "result": "test data" + } + }, + "test_rest_send_00110a": { + "TEST_NOTES": ["Successful POST request response"], + "RETURN_CODE": 200, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/test/create", + "MESSAGE": "Created", + "DATA": { + "id": "12345", + "status": "created" + } + }, + "test_rest_send_00120a": { + "TEST_NOTES": ["Successful PUT request response"], + "RETURN_CODE": 200, + "METHOD": "PUT", + "REQUEST_PATH": "/api/v1/test/update/12345", + "MESSAGE": "Updated", + "DATA": { + "id": "12345", + "status": "updated" + } + }, + "test_rest_send_00130a": { + "TEST_NOTES": ["Successful DELETE request response"], + "RETURN_CODE": 200, + "METHOD": "DELETE", + "REQUEST_PATH": "/api/v1/test/delete/12345", + "MESSAGE": "Deleted", + "DATA": { + "id": "12345", + "status": "deleted" + } + }, + "test_rest_send_00200a": { + "TEST_NOTES": ["Failed request - 404 Not Found"], + "RETURN_CODE": 404, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/notfound", + "MESSAGE": "Not Found", + "DATA": { + "error": "Resource not found" + } + }, + "test_rest_send_00210a": { + "TEST_NOTES": ["Failed request - 400 Bad Request"], + "RETURN_CODE": 400, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/test/badrequest", + "MESSAGE": "Bad Request", + "DATA": { + "error": "Invalid payload" + } + }, + "test_rest_send_00220a": { + "TEST_NOTES": ["Failed request - 500 Internal Server Error"], + "RETURN_CODE": 500, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/servererror", + "MESSAGE": "Internal Server Error", + "DATA": { + "error": "Server error occurred" + } + }, + "test_rest_send_00300a": { + "TEST_NOTES": ["First response in retry sequence - failure"], + "RETURN_CODE": 500, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/retry", + "MESSAGE": "Internal Server Error", + "DATA": { + "error": "Temporary error" + } + }, + "test_rest_send_00300b": { + "TEST_NOTES": ["Second response in retry sequence - success"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/retry", + "MESSAGE": "OK", + "DATA": { + "status": "success", + "result": "data after retry" + } + }, + "test_rest_send_00400a": { + "TEST_NOTES": ["GET request successful response"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/endpoint", + "MESSAGE": "OK", + "DATA": { + "status": "success" + } + }, + "test_rest_send_00410a": { + "TEST_NOTES": ["POST request successful response"], + "RETURN_CODE": 200, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/test/create", + "MESSAGE": "OK", + "DATA": { + "status": "created" + } + }, + "test_rest_send_00420a": { + "TEST_NOTES": ["PUT request successful response"], + "RETURN_CODE": 200, + "METHOD": "PUT", + "REQUEST_PATH": "/api/v1/test/update/12345", + "MESSAGE": "OK", + "DATA": { + "status": "updated" + } + }, + "test_rest_send_00430a": { + "TEST_NOTES": ["DELETE request successful response"], + "RETURN_CODE": 200, + "METHOD": "DELETE", + "REQUEST_PATH": "/api/v1/test/delete/12345", + "MESSAGE": "OK", + "DATA": { + "status": "deleted" + } + }, + "test_rest_send_00500a": { + "TEST_NOTES": ["404 Not Found response"], + "RETURN_CODE": 404, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/notfound", + "MESSAGE": "Not Found", + "DATA": { + "error": "Resource not found" + } + }, + "test_rest_send_00510a": { + "TEST_NOTES": ["400 Bad Request response"], + "RETURN_CODE": 400, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/test/badrequest", + "MESSAGE": "Bad Request", + "DATA": { + "error": "Invalid request data" + } + }, + "test_rest_send_00520a": { + "TEST_NOTES": ["500 Internal Server Error response"], + "RETURN_CODE": 500, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/servererror", + "MESSAGE": "Internal Server Error", + "DATA": { + "error": "Server error occurred" + } + }, + "test_rest_send_00600a": { + "TEST_NOTES": ["First response - 500 error for retry test"], + "RETURN_CODE": 500, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/retry", + "MESSAGE": "Internal Server Error", + "DATA": { + "error": "Temporary error" + } + }, + "test_rest_send_00600b": { + "TEST_NOTES": ["Second response - success after retry"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/retry", + "MESSAGE": "OK", + "DATA": { + "status": "success" + } + }, + "test_rest_send_00600c": { + "TEST_NOTES": ["Multiple sequential requests - third"], + "RETURN_CODE": 200, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/test/multi/create", + "MESSAGE": "Created", + "DATA": { + "id": 3, + "name": "third", + "status": "created" + } + }, + "test_rest_send_00700a": { + "TEST_NOTES": ["First sequential GET"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/multi/1", + "MESSAGE": "OK", + "DATA": { + "id": 1 + } + }, + "test_rest_send_00700b": { + "TEST_NOTES": ["Second sequential GET"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/multi/2", + "MESSAGE": "OK", + "DATA": { + "id": 2 + } + }, + "test_rest_send_00700c": { + "TEST_NOTES": ["Third sequential POST"], + "RETURN_CODE": 200, + "METHOD": "POST", + "REQUEST_PATH": "/api/v1/test/multi/create", + "MESSAGE": "OK", + "DATA": { + "id": 3, + "status": "created" + } + }, + "test_rest_send_00900a": { + "TEST_NOTES": ["Response for deepcopy test"], + "RETURN_CODE": 200, + "METHOD": "GET", + "REQUEST_PATH": "/api/v1/test/endpoint", + "MESSAGE": "OK", + "DATA": { + "status": "success" + } + } +} diff --git a/tests/unit/module_utils/fixtures/load_fixture.py b/tests/unit/module_utils/fixtures/load_fixture.py new file mode 100644 index 00000000..ec5a84d3 --- /dev/null +++ b/tests/unit/module_utils/fixtures/load_fixture.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Function to load test inputs from JSON files. +""" + +from __future__ import absolute_import, annotations, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name + +import json +import os +import sys + +fixture_path = os.path.join(os.path.dirname(__file__), "fixture_data") + + +def load_fixture(filename): + """ + load test inputs from json files + """ + path = os.path.join(fixture_path, f"{filename}.json") + + try: + with open(path, encoding="utf-8") as file_handle: + data = file_handle.read() + except IOError as exception: + msg = f"Exception opening test input file {filename}.json : " + msg += f"Exception detail: {exception}" + print(msg) + sys.exit(1) + + try: + fixture = json.loads(data) + except json.JSONDecodeError as exception: + msg = "Exception reading JSON contents in " + msg += f"test input file {filename}.json : " + msg += f"Exception detail: {exception}" + print(msg) + sys.exit(1) + + return fixture diff --git a/tests/unit/module_utils/mock_ansible_module.py b/tests/unit/module_utils/mock_ansible_module.py new file mode 100644 index 00000000..d58397df --- /dev/null +++ b/tests/unit/module_utils/mock_ansible_module.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Mock AnsibleModule for unit testing. + +This module provides a mock implementation of Ansible's AnsibleModule +to avoid circular import issues between sender_file.py and common_utils.py. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + + +# Define base exception class +class AnsibleFailJson(Exception): + """ + Exception raised by MockAnsibleModule.fail_json() + """ + + +# Try to import AnsibleFailJson from ansible.netcommon if available +# This allows compatibility with tests that expect the netcommon version +try: + from ansible_collections.ansible.netcommon.tests.unit.modules.utils import AnsibleFailJson as _NetcommonFailJson + + # Use the netcommon version if available + AnsibleFailJson = _NetcommonFailJson # type: ignore[misc] +except ImportError: + # Use the local version defined above + pass + + +class MockAnsibleModule: + """ + # Summary + + Mock the AnsibleModule class for unit testing. + + ## Attributes + + - check_mode: Whether the module is running in check mode + - params: Module parameters dictionary + - argument_spec: Module argument specification + - supports_check_mode: Whether the module supports check mode + + ## Methods + + - fail_json: Raises AnsibleFailJson exception with the provided message + """ + + check_mode = False + + params = {"config": {"switches": [{"ip_address": "172.22.150.105"}]}} + argument_spec = { + "config": {"required": True, "type": "dict"}, + "state": {"default": "merged", "choices": ["merged", "deleted", "query"]}, + "check_mode": False, + } + supports_check_mode = True + + @staticmethod + def fail_json(msg, **kwargs) -> AnsibleFailJson: + """ + # Summary + + Mock the fail_json method. + + ## Parameters + + - msg: Error message + - kwargs: Additional keyword arguments (ignored) + + ## Raises + + - AnsibleFailJson: Always raised with the provided message + """ + raise AnsibleFailJson(msg) + + def public_method_for_pylint(self): + """ + # Summary + + Add one public method to appease pylint. + + ## Raises + + None + """ diff --git a/tests/unit/module_utils/response_generator.py b/tests/unit/module_utils/response_generator.py new file mode 100644 index 00000000..e96aad70 --- /dev/null +++ b/tests/unit/module_utils/response_generator.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Response generator for unit tests. +""" + +from __future__ import absolute_import, annotations, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name + + +class ResponseGenerator: + """ + Given a coroutine which yields dictionaries, return the yielded items + with each call to the next property + + For usage in the context of dcnm_image_policy unit tests, see: + test: test_image_policy_create_bulk_00037 + file: tests/unit/modules/dcnm/dcnm_image_policy/test_image_policy_create_bulk.py + + Simplified usage example below. + + def responses(): + yield {"key1": "value1"} + yield {"key2": "value2"} + + gen = ResponseGenerator(responses()) + + print(gen.next) # {"key1": "value1"} + print(gen.next) # {"key2": "value2"} + """ + + def __init__(self, gen): + self.gen = gen + + @property + def next(self): + """ + Return the next item in the generator + """ + return next(self.gen) + + @property + def implements(self): + """ + ### Summary + Used by Sender() classes to verify Sender().gen is a + response generator which implements the response_generator + interfacee. + """ + return "response_generator" + + def public_method_for_pylint(self): + """ + Add one public method to appease pylint + """ diff --git a/tests/unit/module_utils/sender_file.py b/tests/unit/module_utils/sender_file.py new file mode 100644 index 00000000..7060e8c0 --- /dev/null +++ b/tests/unit/module_utils/sender_file.py @@ -0,0 +1,293 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Sender module conforming to SenderProtocol for file-based mock responses. + +See plugins/module_utils/protocol_sender.py for the protocol definition. +""" + +from __future__ import absolute_import, annotations, division, print_function + +# pylint: disable=invalid-name +__metaclass__ = type +# pylint: enable=invalid-name + +import copy +import inspect +import logging +from typing import Any, Optional + +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.tests.unit.module_utils.mock_ansible_module import MockAnsibleModule +from ansible_collections.cisco.nd.tests.unit.module_utils.response_generator import ResponseGenerator + + +class Sender: + """ + # Summary + + An injected dependency for `RestSend` which implements the + `sender` interface. Responses are read from JSON files. + + ## Raises + + - `ValueError` if: + - `gen` is not set. + - `TypeError` if: + - `gen` is not an instance of ResponseGenerator() + + ## Usage + + - `gen` is an instance of `ResponseGenerator()` which yields simulated responses. + In the example below, `responses()` is a generator that yields dictionaries. + However, in practice, it would yield responses read from JSON files. + - `responses()` is a coroutine that yields controller responses. + In the example below, it yields to dictionaries. However, in + practice, it would yield responses read from JSON files. + + ```python + def responses(): + yield {"key1": "value1"} + yield {"key2": "value2"} + + sender = Sender() + sender.gen = ResponseGenerator(responses()) + + try: + rest_send = RestSend() + rest_send.sender = sender + except (TypeError, ValueError) as error: + handle_error(error) + # etc... + # See rest_send.py for RestSend() usage. + ``` + """ + + def __init__(self) -> None: + self.class_name = self.__class__.__name__ + + self.log = logging.getLogger(f"nd.{self.class_name}") + + self._ansible_module: Optional[MockAnsibleModule] = None + self._gen: Optional[ResponseGenerator] = None + self._path: Optional[str] = None + self._payload: Optional[dict[str, Any]] = None + self._response: Optional[dict[str, Any]] = None + self._verb: Optional[HttpVerbEnum] = None + + self._raise_method: Optional[str] = None + self._raise_exception: Optional[BaseException] = None + + msg = "ENTERED Sender(): " + self.log.debug(msg) + + def commit(self) -> None: + """ + # Summary + + - Simulate a commit to a controller (does nothing). + - Allows to simulate exceptions for testing error handling in RestSend by setting the `raise_exception` and `raise_method` properties. + + ## Raises + + - `ValueError` if `gen` is not set. + - `self.raise_exception` if set and + `self.raise_method` == "commit" + """ + method_name = "commit" + + if self.raise_method == method_name and self.raise_exception is not None: + msg = f"{self.class_name}.{method_name}: " + msg += f"Simulated {type(self.raise_exception).__name__}." + raise self.raise_exception + + caller = inspect.stack()[1][3] + msg = f"{self.class_name}.{method_name}: " + msg += f"caller {caller}" + self.log.debug(msg) + + @property + def ansible_module(self) -> Optional[MockAnsibleModule]: + """ + # Summary + + Mock ansible_module + """ + return self._ansible_module + + @ansible_module.setter + def ansible_module(self, value: Optional[MockAnsibleModule]): + self._ansible_module = value + + @property + def gen(self) -> ResponseGenerator: + """ + # Summary + + The `ResponseGenerator()` instance which yields simulated responses. + + ## Raises + + - `ValueError` if `gen` is not set. + - `TypeError` if value is not a class implementing the `response_generator` interface. + """ + if self._gen is None: + msg = f"{self.class_name}.gen: gen must be set to a class implementing the response_generator interface." + raise ValueError(msg) + return self._gen + + @gen.setter + def gen(self, value: ResponseGenerator) -> None: + method_name = inspect.stack()[0][3] + msg = f"{self.class_name}.{method_name}: " + msg += "Expected a class implementing the " + msg += "response_generator interface. " + msg += f"Got {value}." + try: + implements = value.implements + except AttributeError as error: + raise TypeError(msg) from error + if implements != "response_generator": + raise TypeError(msg) + self._gen = value + + @property + def path(self) -> str: + """ + # Summary + + Dummy path. + + ## Raises + + - getter: `ValueError` if `path` is not set before accessing. + + ## Example + + ``/appcenter/cisco/ndfc/api/v1/...etc...`` + """ + if self._path is None: + msg = f"{self.class_name}.path: path must be set before accessing." + raise ValueError(msg) + return self._path + + @path.setter + def path(self, value: str): + self._path = value + + @property + def payload(self) -> Optional[dict[str, Any]]: + """ + # Summary + + Dummy payload. + + ## Raises + + None + """ + return self._payload + + @payload.setter + def payload(self, value: Optional[dict[str, Any]]): + self._payload = value + + @property + def raise_exception(self) -> Optional[BaseException]: + """ + # Summary + + The exception to raise when calling the method specified in `raise_method`. + + ## Raises + + - `TypeError` if value is not a subclass of `BaseException`. + + ## Usage + + ```python + instance = Sender() + instance.raise_method = "commit" + instance.raise_exception = ValueError + instance.commit() # will raise a simulated ValueError + ``` + + ## Notes + + - No error checking is done on the input to this property. + """ + if self._raise_exception is not None and not issubclass(type(self._raise_exception), BaseException): + msg = f"{self.class_name}.raise_exception: " + msg += "raise_exception must be a subclass of BaseException. " + msg += f"Got {self._raise_exception} of type {type(self._raise_exception).__name__}." + raise TypeError(msg) + return self._raise_exception + + @raise_exception.setter + def raise_exception(self, value: Optional[BaseException]): + if value is not None and not issubclass(type(value), BaseException): + msg = f"{self.class_name}.raise_exception: " + msg += "raise_exception must be a subclass of BaseException. " + msg += f"Got {value} of type {type(value).__name__}." + raise TypeError(msg) + self._raise_exception = value + + @property + def raise_method(self) -> Optional[str]: + """ + ## Summary + + The method in which to raise exception `raise_exception`. + + ## Raises + + None + + ## Usage + + See `raise_exception`. + """ + return self._raise_method + + @raise_method.setter + def raise_method(self, value: Optional[str]) -> None: + self._raise_method = value + + @property + def response(self) -> dict[str, Any]: + """ + # Summary + + The simulated response from a file. + + Returns a deepcopy to prevent mutation of the response object. + + ## Raises + + None + """ + return copy.deepcopy(self.gen.next) + + @property + def verb(self) -> HttpVerbEnum: + """ + # Summary + + Dummy Verb. + + ## Raises + + - `ValueError` if verb is not set. + """ + if self._verb is None: + msg = f"{self.class_name}.verb: verb must be set before accessing." + raise ValueError(msg) + return self._verb + + @verb.setter + def verb(self, value: HttpVerbEnum) -> None: + self._verb = value diff --git a/tests/unit/module_utils/test_response_handler_nd.py b/tests/unit/module_utils/test_response_handler_nd.py new file mode 100644 index 00000000..f3250dbc --- /dev/null +++ b/tests/unit/module_utils/test_response_handler_nd.py @@ -0,0 +1,1496 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for response_handler_nd.py + +Tests the ResponseHandler class for handling ND controller responses. +""" + +# pylint: disable=unused-import +# pylint: disable=redefined-outer-name +# pylint: disable=protected-access +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=invalid-name +# pylint: disable=line-too-long +# pylint: disable=too-many-lines + +from __future__ import absolute_import, annotations, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.rest.response_handler_nd import ResponseHandler +from ansible_collections.cisco.nd.plugins.module_utils.rest.response_strategies.nd_v1_strategy import NdV1Strategy +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import does_not_raise + +# ============================================================================= +# Test: ResponseHandler initialization +# ============================================================================= + + +def test_response_handler_nd_00010(): + """ + # Summary + + Verify ResponseHandler initialization with default values. + + ## Test + + - Instance can be created + - _response defaults to None + - _result defaults to None + - _verb defaults to None + - _strategy defaults to NdV1Strategy instance + + ## Classes and Methods + + - ResponseHandler.__init__() + """ + with does_not_raise(): + instance = ResponseHandler() + assert instance._response is None + assert instance._result is None + assert instance._verb is None + assert isinstance(instance._strategy, NdV1Strategy) + + +def test_response_handler_nd_00015(): + """ + # Summary + + Verify validation_strategy getter returns the default NdV1Strategy and + setter accepts a valid strategy. + + ## Test + + - Default strategy is NdV1Strategy + - Setting a new NdV1Strategy instance is accepted + - Getter returns the newly set strategy + + ## Classes and Methods + + - ResponseHandler.validation_strategy (getter/setter) + """ + instance = ResponseHandler() + assert isinstance(instance.validation_strategy, NdV1Strategy) + + new_strategy = NdV1Strategy() + with does_not_raise(): + instance.validation_strategy = new_strategy + assert instance.validation_strategy is new_strategy + + +def test_response_handler_nd_00020(): + """ + # Summary + + Verify validation_strategy setter raises TypeError for invalid type. + + ## Test + + - Setting validation_strategy to a non-strategy object raises TypeError + + ## Classes and Methods + + - ResponseHandler.validation_strategy (setter) + """ + instance = ResponseHandler() + match = r"ResponseHandler\.validation_strategy:.*Expected ResponseValidationStrategy" + with pytest.raises(TypeError, match=match): + instance.validation_strategy = "not a strategy" # type: ignore[assignment] + + +# ============================================================================= +# Test: ResponseHandler.response property +# ============================================================================= + + +def test_response_handler_nd_00100(): + """ + # Summary + + Verify response getter raises ValueError when not set. + + ## Test + + - Accessing response before setting raises ValueError + + ## Classes and Methods + + - ResponseHandler.response (getter) + """ + instance = ResponseHandler() + match = r"ResponseHandler\.response:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + result = instance.response + + +def test_response_handler_nd_00110(): + """ + # Summary + + Verify response setter/getter with valid dict. + + ## Test + + - response can be set with a valid dict containing RETURN_CODE and MESSAGE + - response getter returns the set value + + ## Classes and Methods + + - ResponseHandler.response (setter/getter) + """ + instance = ResponseHandler() + response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {"key": "value"}} + with does_not_raise(): + instance.response = response + result = instance.response + assert result["RETURN_CODE"] == 200 + assert result["MESSAGE"] == "OK" + + +def test_response_handler_nd_00120(): + """ + # Summary + + Verify response setter raises TypeError for non-dict. + + ## Test + + - Setting response to a non-dict raises TypeError + + ## Classes and Methods + + - ResponseHandler.response (setter) + """ + instance = ResponseHandler() + match = r"ResponseHandler\.response.*must be a dict" + with pytest.raises(TypeError, match=match): + instance.response = "not a dict" # type: ignore[assignment] + + +def test_response_handler_nd_00130(): + """ + # Summary + + Verify response setter raises ValueError when MESSAGE key is missing. + + ## Test + + - Setting response without MESSAGE raises ValueError + + ## Classes and Methods + + - ResponseHandler.response (setter) + """ + instance = ResponseHandler() + match = r"ResponseHandler\.response:.*must have a MESSAGE key" + with pytest.raises(ValueError, match=match): + instance.response = {"RETURN_CODE": 200} + + +def test_response_handler_nd_00140(): + """ + # Summary + + Verify response setter raises ValueError when RETURN_CODE key is missing. + + ## Test + + - Setting response without RETURN_CODE raises ValueError + + ## Classes and Methods + + - ResponseHandler.response (setter) + """ + instance = ResponseHandler() + match = r"ResponseHandler\.response:.*must have a RETURN_CODE key" + with pytest.raises(ValueError, match=match): + instance.response = {"MESSAGE": "OK"} + + +# ============================================================================= +# Test: ResponseHandler.verb property +# ============================================================================= + + +def test_response_handler_nd_00200(): + """ + # Summary + + Verify verb getter raises ValueError when not set. + + ## Test + + - Accessing verb before setting raises ValueError + + ## Classes and Methods + + - ResponseHandler.verb (getter) + """ + instance = ResponseHandler() + match = r"ResponseHandler\.verb is not set" + with pytest.raises(ValueError, match=match): + result = instance.verb + + +def test_response_handler_nd_00210(): + """ + # Summary + + Verify verb setter/getter with valid HttpVerbEnum. + + ## Test + + - verb can be set and retrieved with HttpVerbEnum values + + ## Classes and Methods + + - ResponseHandler.verb (setter/getter) + """ + instance = ResponseHandler() + with does_not_raise(): + instance.verb = HttpVerbEnum.GET + result = instance.verb + assert result == HttpVerbEnum.GET + + with does_not_raise(): + instance.verb = HttpVerbEnum.POST + result = instance.verb + assert result == HttpVerbEnum.POST + + +# ============================================================================= +# Test: ResponseHandler.result property +# ============================================================================= + + +def test_response_handler_nd_00300(): + """ + # Summary + + Verify result getter raises ValueError when commit() not called. + + ## Test + + - Accessing result before calling commit() raises ValueError + + ## Classes and Methods + + - ResponseHandler.result (getter) + """ + instance = ResponseHandler() + match = r"ResponseHandler\.result:.*must be set before accessing.*commit" + with pytest.raises(ValueError, match=match): + result = instance.result + + +def test_response_handler_nd_00310(): + """ + # Summary + + Verify result setter raises TypeError for non-dict. + + ## Test + + - Setting result to non-dict raises TypeError + + ## Classes and Methods + + - ResponseHandler.result (setter) + """ + instance = ResponseHandler() + match = r"ResponseHandler\.result.*must be a dict" + with pytest.raises(TypeError, match=match): + instance.result = "not a dict" # type: ignore[assignment] + + +# ============================================================================= +# Test: ResponseHandler.commit() validation +# ============================================================================= + + +def test_response_handler_nd_00400(): + """ + # Summary + + Verify commit() raises ValueError when response is not set. + + ## Test + + - Calling commit() without setting response raises ValueError + + ## Classes and Methods + + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.verb = HttpVerbEnum.GET + match = r"ResponseHandler\.response:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_response_handler_nd_00410(): + """ + # Summary + + Verify commit() raises ValueError when verb is not set. + + ## Test + + - Calling commit() without setting verb raises ValueError + + ## Classes and Methods + + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + match = r"ResponseHandler\.verb is not set" + with pytest.raises(ValueError, match=match): + instance.commit() + + +# ============================================================================= +# Test: ResponseHandler._handle_get_response() +# ============================================================================= + + +def test_response_handler_nd_00500(): + """ + # Summary + + Verify GET response with 200 OK. + + ## Test + + - GET with RETURN_CODE 200 sets found=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00510(): + """ + # Summary + + Verify GET response with 201 Created. + + ## Test + + - GET with RETURN_CODE 201 sets found=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 201, "MESSAGE": "Created"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00520(): + """ + # Summary + + Verify GET response with 202 Accepted. + + ## Test + + - GET with RETURN_CODE 202 sets found=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 202, "MESSAGE": "Accepted"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00530(): + """ + # Summary + + Verify GET response with 204 No Content. + + ## Test + + - GET with RETURN_CODE 204 sets found=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 204, "MESSAGE": "No Content"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00535(): + """ + # Summary + + Verify GET response with 207 Multi-Status. + + ## Test + + - GET with RETURN_CODE 207 sets found=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 207, "MESSAGE": "Multi-Status"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00540(): + """ + # Summary + + Verify GET response with 404 Not Found. + + ## Test + + - GET with RETURN_CODE 404 sets found=False, success=True + - 404 is treated as "not found but not an error" + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 404, "MESSAGE": "Not Found"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is False + assert instance.result["success"] is True + + +def test_response_handler_nd_00550(): + """ + # Summary + + Verify GET response with 500 Internal Server Error. + + ## Test + + - GET with RETURN_CODE 500 sets found=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 500, "MESSAGE": "Internal Server Error"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00560(): + """ + # Summary + + Verify GET response with 400 Bad Request. + + ## Test + + - GET with RETURN_CODE 400 sets found=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 400, "MESSAGE": "Bad Request"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00570(): + """ + # Summary + + Verify GET response with 401 Unauthorized. + + ## Test + + - GET with RETURN_CODE 401 sets found=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 401, "MESSAGE": "Unauthorized"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00575(): + """ + # Summary + + Verify GET response with 405 Method Not Allowed. + + ## Test + + - GET with RETURN_CODE 405 sets found=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 405, "MESSAGE": "Method Not Allowed"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00580(): + """ + # Summary + + Verify GET response with 409 Conflict. + + ## Test + + - GET with RETURN_CODE 409 sets found=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_get_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 409, "MESSAGE": "Conflict"} + instance.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.commit() + assert instance.result["found"] is False + assert instance.result["success"] is False + + +# ============================================================================= +# Test: ResponseHandler._handle_post_put_delete_response() +# ============================================================================= + + +def test_response_handler_nd_00600(): + """ + # Summary + + Verify POST response with 200 OK (no errors). + + ## Test + + - POST with RETURN_CODE 200 and no errors sets changed=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {"status": "created"}} + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00610(): + """ + # Summary + + Verify PUT response with 200 OK. + + ## Test + + - PUT with RETURN_CODE 200 and no errors sets changed=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {"status": "updated"}} + instance.verb = HttpVerbEnum.PUT + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00620(): + """ + # Summary + + Verify DELETE response with 200 OK. + + ## Test + + - DELETE with RETURN_CODE 200 and no errors sets changed=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {}} + instance.verb = HttpVerbEnum.DELETE + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00630(): + """ + # Summary + + Verify POST response with 201 Created. + + ## Test + + - POST with RETURN_CODE 201 sets changed=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 201, "MESSAGE": "Created", "DATA": {}} + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00640(): + """ + # Summary + + Verify POST response with 202 Accepted. + + ## Test + + - POST with RETURN_CODE 202 sets changed=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 202, "MESSAGE": "Accepted", "DATA": {}} + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00650(): + """ + # Summary + + Verify DELETE response with 204 No Content. + + ## Test + + - DELETE with RETURN_CODE 204 sets changed=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 204, "MESSAGE": "No Content", "DATA": {}} + instance.verb = HttpVerbEnum.DELETE + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00655(): + """ + # Summary + + Verify POST response with 207 Multi-Status. + + ## Test + + - POST with RETURN_CODE 207 and no errors sets changed=True, success=True + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 207, "MESSAGE": "Multi-Status", "DATA": {"status": "partial"}} + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is True + assert instance.result["success"] is True + + +def test_response_handler_nd_00660(): + """ + # Summary + + Verify POST response with explicit ERROR key. + + ## Test + + - Response containing ERROR key sets changed=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "ERROR": "Something went wrong", + "DATA": {}, + } + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00670(): + """ + # Summary + + Verify POST response with DATA.error (ND error format). + + ## Test + + - Response with DATA containing error key sets changed=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": {"error": "ND error occurred"}, + } + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00680(): + """ + # Summary + + Verify POST response with 500 error status code. + + ## Test + + - POST with RETURN_CODE 500 sets changed=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 500, + "MESSAGE": "Internal Server Error", + "DATA": {}, + } + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00690(): + """ + # Summary + + Verify POST response with 400 Bad Request. + + ## Test + + - POST with RETURN_CODE 400 and no explicit errors sets changed=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 400, + "MESSAGE": "Bad Request", + "DATA": {}, + } + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00695(): + """ + # Summary + + Verify POST response with 405 Method Not Allowed. + + ## Test + + - POST with RETURN_CODE 405 sets changed=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 405, + "MESSAGE": "Method Not Allowed", + "DATA": {}, + } + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is False + assert instance.result["success"] is False + + +def test_response_handler_nd_00705(): + """ + # Summary + + Verify POST response with 409 Conflict. + + ## Test + + - POST with RETURN_CODE 409 sets changed=False, success=False + + ## Classes and Methods + + - ResponseHandler._handle_post_put_delete_response() + - ResponseHandler.commit() + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 409, + "MESSAGE": "Conflict", + "DATA": {"reason": "resource exists"}, + } + instance.verb = HttpVerbEnum.POST + with does_not_raise(): + instance.commit() + assert instance.result["changed"] is False + assert instance.result["success"] is False + + +# ============================================================================= +# Test: ResponseHandler.error_message property +# ============================================================================= + + +def test_response_handler_nd_00700(): + """ + # Summary + + Verify error_message returns None on successful response. + + ## Test + + - error_message is None when result indicates success + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {}} + instance.verb = HttpVerbEnum.GET + instance.commit() + assert instance.error_message is None + + +def test_response_handler_nd_00710(): + """ + # Summary + + Verify error_message returns None when commit() not called. + + ## Test + + - error_message is None when _result is None (commit not called) + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + assert instance.error_message is None + + +def test_response_handler_nd_00720(): + """ + # Summary + + Verify error_message for raw_response format (non-JSON response). + + ## Test + + - When DATA contains raw_response key, error_message indicates non-JSON response + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 500, + "MESSAGE": "Internal Server Error", + "DATA": {"raw_response": "Error"}, + } + instance.verb = HttpVerbEnum.GET + instance.commit() + assert instance.error_message is not None + assert "could not be parsed as JSON" in instance.error_message + + +def test_response_handler_nd_00730(): + """ + # Summary + + Verify error_message for code/message format. + + ## Test + + - When DATA contains code and message keys, error_message includes both + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 400, + "MESSAGE": "Bad Request", + "DATA": {"code": "INVALID_INPUT", "message": "Field X is required"}, + } + instance.verb = HttpVerbEnum.POST + instance.commit() + assert instance.error_message is not None + assert "INVALID_INPUT" in instance.error_message + assert "Field X is required" in instance.error_message + + +def test_response_handler_nd_00740(): + """ + # Summary + + Verify error_message for messages array format. + + ## Test + + - When DATA contains messages array with code/severity/message, + error_message includes all three fields + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 400, + "MESSAGE": "Bad Request", + "DATA": { + "messages": [ + { + "code": "ERR_001", + "severity": "ERROR", + "message": "Validation failed", + } + ] + }, + } + instance.verb = HttpVerbEnum.POST + instance.commit() + assert instance.error_message is not None + assert "ERR_001" in instance.error_message + assert "ERROR" in instance.error_message + assert "Validation failed" in instance.error_message + + +def test_response_handler_nd_00750(): + """ + # Summary + + Verify error_message for errors array format. + + ## Test + + - When DATA contains errors array, error_message includes the first error + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 400, + "MESSAGE": "Bad Request", + "DATA": {"errors": ["First error message", "Second error message"]}, + } + instance.verb = HttpVerbEnum.POST + instance.commit() + assert instance.error_message is not None + assert "First error message" in instance.error_message + + +def test_response_handler_nd_00760(): + """ + # Summary + + Verify error_message when DATA is None (connection failure). + + ## Test + + - When DATA is None, error_message includes REQUEST_PATH and MESSAGE + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 500, + "MESSAGE": "Connection refused", + "REQUEST_PATH": "/api/v1/some/endpoint", + } + instance.verb = HttpVerbEnum.GET + instance.commit() + assert instance.error_message is not None + assert "Connection failed" in instance.error_message + assert "/api/v1/some/endpoint" in instance.error_message + assert "Connection refused" in instance.error_message + + +def test_response_handler_nd_00770(): + """ + # Summary + + Verify error_message with non-dict DATA. + + ## Test + + - When DATA is a non-dict value, error_message includes stringified DATA + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 500, + "MESSAGE": "Internal Server Error", + "DATA": "Unexpected string error", + } + instance.verb = HttpVerbEnum.GET + instance.commit() + assert instance.error_message is not None + assert "Unexpected string error" in instance.error_message + + +def test_response_handler_nd_00780(): + """ + # Summary + + Verify error_message fallback for unknown dict format. + + ## Test + + - When DATA is a dict with no recognized error format, + error_message falls back to including RETURN_CODE + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 503, + "MESSAGE": "Service Unavailable", + "DATA": {"some_unknown_key": "some_value"}, + } + instance.verb = HttpVerbEnum.GET + instance.commit() + assert instance.error_message is not None + assert "503" in instance.error_message + + +def test_response_handler_nd_00790(): + """ + # Summary + + Verify error_message returns None when result success is True. + + ## Test + + - Even with error-like DATA, if result is success, error_message is None + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": {"errors": ["Some error"]}, + } + instance.verb = HttpVerbEnum.GET + instance.commit() + # For GET with 200, success is True regardless of DATA content + assert instance.result["success"] is True + assert instance.error_message is None + + +def test_response_handler_nd_00800(): + """ + # Summary + + Verify error_message for connection failure with no REQUEST_PATH. + + ## Test + + - When DATA is None and REQUEST_PATH is missing, error_message uses "unknown" + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 500, + "MESSAGE": "Connection timed out", + } + instance.verb = HttpVerbEnum.GET + instance.commit() + assert instance.error_message is not None + assert "unknown" in instance.error_message + assert "Connection timed out" in instance.error_message + + +def test_response_handler_nd_00810(): + """ + # Summary + + Verify error_message for messages array with empty array. + + ## Test + + - When DATA contains an empty messages array, messages format is skipped + and fallback is used + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 400, + "MESSAGE": "Bad Request", + "DATA": {"messages": []}, + } + instance.verb = HttpVerbEnum.POST + instance.commit() + assert instance.error_message is not None + assert "400" in instance.error_message + + +def test_response_handler_nd_00820(): + """ + # Summary + + Verify error_message for errors array with empty array. + + ## Test + + - When DATA contains an empty errors array, errors format is skipped + and fallback is used + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 400, + "MESSAGE": "Bad Request", + "DATA": {"errors": []}, + } + instance.verb = HttpVerbEnum.POST + instance.commit() + assert instance.error_message is not None + assert "400" in instance.error_message + + +# ============================================================================= +# Test: ResponseHandler._handle_response() routing +# ============================================================================= + + +def test_response_handler_nd_00900(): + """ + # Summary + + Verify _handle_response routes GET to _handle_get_response. + + ## Test + + - GET verb produces result with "found" key (not "changed") + + ## Classes and Methods + + - ResponseHandler._handle_response() + - ResponseHandler._handle_get_response() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + instance.verb = HttpVerbEnum.GET + instance.commit() + assert "found" in instance.result + assert "changed" not in instance.result + + +def test_response_handler_nd_00910(): + """ + # Summary + + Verify _handle_response routes POST to _handle_post_put_delete_response. + + ## Test + + - POST verb produces result with "changed" key (not "found") + + ## Classes and Methods + + - ResponseHandler._handle_response() + - ResponseHandler._handle_post_put_delete_response() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {}} + instance.verb = HttpVerbEnum.POST + instance.commit() + assert "changed" in instance.result + assert "found" not in instance.result + + +def test_response_handler_nd_00920(): + """ + # Summary + + Verify _handle_response routes PUT to _handle_post_put_delete_response. + + ## Test + + - PUT verb produces result with "changed" key (not "found") + + ## Classes and Methods + + - ResponseHandler._handle_response() + - ResponseHandler._handle_post_put_delete_response() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {}} + instance.verb = HttpVerbEnum.PUT + instance.commit() + assert "changed" in instance.result + assert "found" not in instance.result + + +def test_response_handler_nd_00930(): + """ + # Summary + + Verify _handle_response routes DELETE to _handle_post_put_delete_response. + + ## Test + + - DELETE verb produces result with "changed" key (not "found") + + ## Classes and Methods + + - ResponseHandler._handle_response() + - ResponseHandler._handle_post_put_delete_response() + """ + instance = ResponseHandler() + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {}} + instance.verb = HttpVerbEnum.DELETE + instance.commit() + assert "changed" in instance.result + assert "found" not in instance.result + + +# ============================================================================= +# Test: ResponseHandler with code/message + messages array in same response +# ============================================================================= + + +def test_response_handler_nd_01000(): + """ + # Summary + + Verify error_message prefers code/message format over messages array. + + ## Test + + - When DATA contains both code/message and messages array, + code/message takes priority + + ## Classes and Methods + + - ResponseHandler.error_message + """ + instance = ResponseHandler() + instance.response = { + "RETURN_CODE": 400, + "MESSAGE": "Bad Request", + "DATA": { + "code": "PRIMARY_ERROR", + "message": "Primary error message", + "messages": [ + { + "code": "SECONDARY", + "severity": "WARNING", + "message": "Secondary message", + } + ], + }, + } + instance.verb = HttpVerbEnum.POST + instance.commit() + assert instance.error_message is not None + assert "PRIMARY_ERROR" in instance.error_message + assert "Primary error message" in instance.error_message + + +# ============================================================================= +# Test: ResponseHandler commit() can be called multiple times +# ============================================================================= + + +def test_response_handler_nd_01100(): + """ + # Summary + + Verify commit() can be called with different responses. + + ## Test + + - First commit with 200 success + - Second commit with 500 error + - result reflects the most recent commit + + ## Classes and Methods + + - ResponseHandler.commit() + """ + instance = ResponseHandler() + + # First commit - success + instance.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + instance.verb = HttpVerbEnum.GET + instance.commit() + assert instance.result["success"] is True + assert instance.result["found"] is True + + # Second commit - failure + instance.response = {"RETURN_CODE": 500, "MESSAGE": "Internal Server Error"} + instance.verb = HttpVerbEnum.GET + instance.commit() + assert instance.result["success"] is False + assert instance.result["found"] is False diff --git a/tests/unit/module_utils/test_rest_send.py b/tests/unit/module_utils/test_rest_send.py new file mode 100644 index 00000000..ab1c499c --- /dev/null +++ b/tests/unit/module_utils/test_rest_send.py @@ -0,0 +1,1445 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for rest_send.py + +Tests the RestSend class for sending REST requests with retries +""" + +# pylint: disable=disallowed-name,protected-access,too-many-lines + +from __future__ import absolute_import, annotations, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name + +import inspect + +import pytest +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.rest.response_handler_nd import ResponseHandler +from ansible_collections.cisco.nd.plugins.module_utils.rest.rest_send import RestSend +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import does_not_raise +from ansible_collections.cisco.nd.tests.unit.module_utils.fixtures.load_fixture import load_fixture +from ansible_collections.cisco.nd.tests.unit.module_utils.mock_ansible_module import MockAnsibleModule +from ansible_collections.cisco.nd.tests.unit.module_utils.response_generator import ResponseGenerator +from ansible_collections.cisco.nd.tests.unit.module_utils.sender_file import Sender + + +def responses_rest_send(key: str): + """ + Load fixture data for rest_send tests + """ + return load_fixture("test_rest_send")[key] + + +# ============================================================================= +# Test: RestSend initialization +# ============================================================================= + + +def test_rest_send_00010(): + """ + # Summary + + Verify RestSend initialization with default values + + ## Test + + - Instance can be created with params dict + - check_mode defaults to False + - timeout defaults to 300 + - send_interval defaults to 5 + - unit_test defaults to False + + ## Classes and Methods + + - RestSend.__init__() + """ + params = {"check_mode": False, "state": "merged"} + with does_not_raise(): + instance = RestSend(params) + assert instance.check_mode is False + assert instance.timeout == 300 + assert instance.send_interval == 5 + assert instance.unit_test is False + + +def test_rest_send_00020(): + """ + # Summary + + Verify RestSend initialization with check_mode True + + ## Test + + - check_mode can be set via params + + ## Classes and Methods + + - RestSend.__init__() + """ + params = {"check_mode": True, "state": "merged"} + with does_not_raise(): + instance = RestSend(params) + assert instance.check_mode is True + + +def test_rest_send_00030(): + """ + # Summary + + Verify RestSend raises TypeError for invalid check_mode + + ## Test + + - check_mode setter raises TypeError if not bool + + ## Classes and Methods + + - RestSend.check_mode + """ + params = {"check_mode": False} + instance = RestSend(params) + match = r"RestSend\.check_mode:.*must be a boolean" + with pytest.raises(TypeError, match=match): + instance.check_mode = "invalid" # type: ignore[assignment] + + +# ============================================================================= +# Test: RestSend property setters/getters +# ============================================================================= + + +def test_rest_send_00100(): + """ + # Summary + + Verify path property getter/setter + + ## Test + + - path can be set and retrieved + - ValueError raised if accessed before being set + + ## Classes and Methods + + - RestSend.path + """ + params = {"check_mode": False} + instance = RestSend(params) + + # Test ValueError when accessing before setting + match = r"RestSend\.path:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + result = instance.path # pylint: disable=pointless-statement + + # Test setter/getter + with does_not_raise(): + instance.path = "/api/v1/test/endpoint" + result = instance.path + assert result == "/api/v1/test/endpoint" + + +def test_rest_send_00110(): + """ + # Summary + + Verify verb property getter/setter + + ## Test + + - verb can be set and retrieved with HttpVerbEnum + - verb has default value of HttpVerbEnum.GET + - TypeError raised if not HttpVerbEnum + + ## Classes and Methods + + - RestSend.verb + """ + params = {"check_mode": False} + instance = RestSend(params) + + # Test default value + with does_not_raise(): + result = instance.verb + assert result == HttpVerbEnum.GET + + # Test TypeError for invalid type + match = r"RestSend\.verb:.*must be an instance of HttpVerbEnum" + with pytest.raises(TypeError, match=match): + instance.verb = "GET" # type: ignore[assignment] + + # Test setter/getter with valid HttpVerbEnum + with does_not_raise(): + instance.verb = HttpVerbEnum.POST + result = instance.verb + assert result == HttpVerbEnum.POST + + +def test_rest_send_00120(): + """ + # Summary + + Verify payload property getter/setter + + ## Test + + - payload can be set and retrieved + - payload defaults to None + - TypeError raised if not dict + + ## Classes and Methods + + - RestSend.payload + """ + params = {"check_mode": False} + instance = RestSend(params) + + # Test default value + with does_not_raise(): + result = instance.payload + assert result is None + + # Test TypeError for invalid type + match = r"RestSend\.payload:.*must be a dict" + with pytest.raises(TypeError, match=match): + instance.payload = "invalid" # type: ignore[assignment] + + # Test setter/getter with dict + with does_not_raise(): + instance.payload = {"key": "value"} + result = instance.payload + assert result == {"key": "value"} + + +def test_rest_send_00130(): + """ + # Summary + + Verify timeout property getter/setter + + ## Test + + - timeout can be set and retrieved + - timeout defaults to 300 + - TypeError raised if not int + + ## Classes and Methods + + - RestSend.timeout + """ + params = {"check_mode": False} + instance = RestSend(params) + + # Test default value + assert instance.timeout == 300 + + # Test TypeError for boolean (bool is subclass of int) + match = r"RestSend\.timeout:.*must be an integer" + with pytest.raises(TypeError, match=match): + instance.timeout = True # type: ignore[assignment] + + # Test TypeError for string + with pytest.raises(TypeError, match=match): + instance.timeout = "300" # type: ignore[assignment] + + # Test setter/getter with int + with does_not_raise(): + instance.timeout = 600 + assert instance.timeout == 600 + + +def test_rest_send_00140(): + """ + # Summary + + Verify send_interval property getter/setter + + ## Test + + - send_interval can be set and retrieved + - send_interval defaults to 5 + - TypeError raised if not int + + ## Classes and Methods + + - RestSend.send_interval + """ + params = {"check_mode": False} + instance = RestSend(params) + + # Test default value + assert instance.send_interval == 5 + + # Test TypeError for boolean + match = r"RestSend\.send_interval:.*must be an integer" + with pytest.raises(TypeError, match=match): + instance.send_interval = False # type: ignore[assignment] + + # Test setter/getter with int + with does_not_raise(): + instance.send_interval = 10 + assert instance.send_interval == 10 + + +def test_rest_send_00150(): + """ + # Summary + + Verify unit_test property getter/setter + + ## Test + + - unit_test can be set and retrieved + - unit_test defaults to False + - TypeError raised if not bool + + ## Classes and Methods + + - RestSend.unit_test + """ + params = {"check_mode": False} + instance = RestSend(params) + + # Test default value + assert instance.unit_test is False + + # Test TypeError for non-bool + match = r"RestSend\.unit_test:.*must be a boolean" + with pytest.raises(TypeError, match=match): + instance.unit_test = "true" # type: ignore[assignment] + + # Test setter/getter with bool + with does_not_raise(): + instance.unit_test = True + assert instance.unit_test is True + + +def test_rest_send_00160(): + """ + # Summary + + Verify sender property getter/setter + + ## Test + + - sender must be set before accessing + - sender must implement SenderProtocol + - ValueError raised if accessed before being set + - TypeError raised if not SenderProtocol + + ## Classes and Methods + + - RestSend.sender + """ + params = {"check_mode": False} + instance = RestSend(params) + + # Test ValueError when accessing before setting + match = r"RestSend\.sender:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + result = instance.sender # pylint: disable=pointless-statement + + # Test TypeError for invalid type + match = r"RestSend\.sender:.*must implement SenderProtocol" + with pytest.raises(TypeError, match=match): + instance.sender = "invalid" # type: ignore[assignment] + + # Test setter/getter with valid Sender + def responses(): + yield {} + + gen_responses = ResponseGenerator(responses()) + sender = Sender() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + with does_not_raise(): + instance.sender = sender + result = instance.sender + assert result is sender + + +def test_rest_send_00170(): + """ + # Summary + + Verify response_handler property getter/setter + + ## Test + + - response_handler must be set before accessing + - response_handler must implement ResponseHandlerProtocol + - ValueError raised if accessed before being set + - TypeError raised if not ResponseHandlerProtocol + + ## Classes and Methods + + - RestSend.response_handler + """ + params = {"check_mode": False} + instance = RestSend(params) + + # Test ValueError when accessing before setting + match = r"RestSend\.response_handler:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + result = instance.response_handler # pylint: disable=pointless-statement + + # Test TypeError for invalid type + match = r"RestSend\.response_handler:.*must implement ResponseHandlerProtocol" + with pytest.raises(TypeError, match=match): + instance.response_handler = "invalid" # type: ignore[assignment] + + # Test setter/getter with valid ResponseHandler + def responses(): + yield {} + + gen_responses = ResponseGenerator(responses()) + sender = Sender() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + instance.sender = sender + + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + with does_not_raise(): + instance.response_handler = response_handler + result = instance.response_handler + assert result is response_handler + + +# ============================================================================= +# Test: RestSend save_settings() and restore_settings() +# ============================================================================= + + +def test_rest_send_00200(): + """ + # Summary + + Verify save_settings() and restore_settings() + + ## Test + + - save_settings() saves current check_mode and timeout + - restore_settings() restores saved values + + ## Classes and Methods + + - RestSend.save_settings() + - RestSend.restore_settings() + """ + params = {"check_mode": False} + instance = RestSend(params) + + # Set initial values + instance.check_mode = False + instance.timeout = 300 + + # Save settings + with does_not_raise(): + instance.save_settings() + + # Modify values + instance.check_mode = True + instance.timeout = 600 + + # Verify modified values + assert instance.check_mode is True + assert instance.timeout == 600 + + # Restore settings + with does_not_raise(): + instance.restore_settings() + + # Verify restored values + assert instance.check_mode is False + assert instance.timeout == 300 + + +def test_rest_send_00210(): + """ + # Summary + + Verify restore_settings() when save_settings() not called + + ## Test + + - restore_settings() does nothing if save_settings() not called + + ## Classes and Methods + + - RestSend.restore_settings() + """ + params = {"check_mode": False} + instance = RestSend(params) + + # Set values without saving + instance.check_mode = True + instance.timeout = 600 + + # Call restore_settings without prior save + with does_not_raise(): + instance.restore_settings() + + # Values should remain unchanged + assert instance.check_mode is True + assert instance.timeout == 600 + + +# ============================================================================= +# Test: RestSend commit() in check mode +# ============================================================================= + + +def test_rest_send_00300(): + """ + # Summary + + Verify commit() in check_mode for GET request + + ## Test + + - GET requests in check_mode return simulated success response + - response_current contains check mode indicator + - result_current shows success + + ## Classes and Methods + + - RestSend.commit() + - RestSend._commit_check_mode() + """ + params = {"check_mode": True} + + def responses(): + yield {} + + gen_responses = ResponseGenerator(responses()) + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + with does_not_raise(): + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.unit_test = True + instance.path = "/api/v1/test/checkmode" + instance.verb = HttpVerbEnum.GET + instance.commit() + + # Verify check mode response + assert instance.response_current["RETURN_CODE"] == 200 + assert instance.response_current["METHOD"] == HttpVerbEnum.GET + assert instance.response_current["REQUEST_PATH"] == "/api/v1/test/checkmode" + assert instance.response_current["CHECK_MODE"] is True + assert instance.result_current["success"] is True + assert instance.result_current["found"] is True + + +def test_rest_send_00310(): + """ + # Summary + + Verify commit() in check_mode for POST request + + ## Test + + - POST requests in check_mode return simulated success response + - changed flag is True for write operations + + ## Classes and Methods + + - RestSend.commit() + - RestSend._commit_check_mode() + """ + params = {"check_mode": True} + + def responses(): + yield {} + + gen_responses = ResponseGenerator(responses()) + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + with does_not_raise(): + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {}} + response_handler.verb = HttpVerbEnum.POST + response_handler.commit() + instance.response_handler = response_handler + instance.unit_test = True + instance.path = "/api/v1/test/create" + instance.verb = HttpVerbEnum.POST + instance.payload = {"name": "test"} + instance.commit() + + # Verify check mode response for write operation + assert instance.response_current["RETURN_CODE"] == 200 + assert instance.response_current["METHOD"] == HttpVerbEnum.POST + assert instance.response_current["CHECK_MODE"] is True + assert instance.result_current["success"] is True + assert instance.result_current["changed"] is True + + +# ============================================================================= +# Test: RestSend commit() in normal mode with successful responses +# ============================================================================= + + +def test_rest_send_00400(): + """ + # Summary + + Verify commit() with successful GET request + + ## Test + + - GET request returns successful response + - response_current and result_current are populated + - response and result lists contain the responses + + ## Classes and Methods + + - RestSend.commit() + - RestSend._commit_normal_mode() + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + def responses(): + # Provide an extra response entry for potential retry scenarios + yield responses_rest_send(key) + yield responses_rest_send(key) + + gen_responses = ResponseGenerator(responses()) + + params = {"check_mode": False} + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + with does_not_raise(): + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.unit_test = True + instance.path = "/api/v1/test/endpoint" + instance.verb = HttpVerbEnum.GET + instance.commit() + + # Verify response + assert instance.response_current["RETURN_CODE"] == 200 + assert instance.response_current["METHOD"] == "GET" + assert instance.response_current["DATA"]["status"] == "success" + + # Verify result (GET requests return "found", not "changed") + assert instance.result_current["success"] is True + assert instance.result_current["found"] is True + + # Verify response and result lists + assert len(instance.response) == 1 + assert len(instance.result) == 1 + + +def test_rest_send_00410(): + """ + # Summary + + Verify commit() with successful POST request + + ## Test + + - POST request with payload returns successful response + - changed flag is True for write operations + + ## Classes and Methods + + - RestSend.commit() + - RestSend._commit_normal_mode() + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + def responses(): + # Provide an extra response entry for potential retry scenarios + yield responses_rest_send(key) + yield responses_rest_send(key) + + gen_responses = ResponseGenerator(responses()) + + params = {"check_mode": False} + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + with does_not_raise(): + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.unit_test = True + instance.path = "/api/v1/test/create" + instance.verb = HttpVerbEnum.POST + instance.payload = {"name": "test"} + instance.commit() + + # Verify response + assert instance.response_current["RETURN_CODE"] == 200 + assert instance.response_current["DATA"]["status"] == "created" + + # Verify result + assert instance.result_current["success"] is True + assert instance.result_current["changed"] is True + + +def test_rest_send_00420(): + """ + # Summary + + Verify commit() with successful PUT request + + ## Test + + - PUT request returns successful response + + ## Classes and Methods + + - RestSend.commit() + - RestSend._commit_normal_mode() + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + def responses(): + # Provide an extra response entry for potential retry scenarios + yield responses_rest_send(key) + yield responses_rest_send(key) + + gen_responses = ResponseGenerator(responses()) + + params = {"check_mode": False} + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + with does_not_raise(): + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.unit_test = True + instance.path = "/api/v1/test/update/12345" + instance.verb = HttpVerbEnum.PUT + instance.payload = {"status": "updated"} + instance.commit() + + # Verify response + assert instance.response_current["RETURN_CODE"] == 200 + assert instance.response_current["DATA"]["status"] == "updated" + + # Verify result + assert instance.result_current["success"] is True + assert instance.result_current["changed"] is True + + +def test_rest_send_00430(): + """ + # Summary + + Verify commit() with successful DELETE request + + ## Test + + - DELETE request returns successful response + + ## Classes and Methods + + - RestSend.commit() + - RestSend._commit_normal_mode() + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + def responses(): + # Provide an extra response entry for potential retry scenarios + yield responses_rest_send(key) + yield responses_rest_send(key) + + gen_responses = ResponseGenerator(responses()) + + params = {"check_mode": False} + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + with does_not_raise(): + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.unit_test = True + instance.path = "/api/v1/test/delete/12345" + instance.verb = HttpVerbEnum.DELETE + instance.commit() + + # Verify response + assert instance.response_current["RETURN_CODE"] == 200 + assert instance.response_current["DATA"]["status"] == "deleted" + + # Verify result + assert instance.result_current["success"] is True + assert instance.result_current["changed"] is True + + +# ============================================================================= +# Test: RestSend commit() with failed responses +# ============================================================================= + + +def test_rest_send_00500(): + """ + # Summary + + Verify commit() with 404 Not Found response + + ## Test + + - Failed GET request returns 404 response + - result shows success=False + + ## Classes and Methods + + - RestSend.commit() + - RestSend._commit_normal_mode() + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + def responses(): + # Provide an extra response entry for potential retry scenarios + yield responses_rest_send(key) + yield responses_rest_send(key) + + gen_responses = ResponseGenerator(responses()) + + params = {"check_mode": False} + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + with does_not_raise(): + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.unit_test = True + instance.timeout = 1 + instance.path = "/api/v1/test/notfound" + instance.verb = HttpVerbEnum.GET + instance.commit() + + # Verify error response (GET with 404 returns "found": False) + assert instance.response_current["RETURN_CODE"] == 404 + assert instance.result_current["success"] is True + assert instance.result_current["found"] is False + + +def test_rest_send_00510(): + """ + # Summary + + Verify commit() with 400 Bad Request response + + ## Test + + - Failed POST request returns 400 response + - Loop retries until timeout is exhausted + + ## Classes and Methods + + - RestSend.commit() + - RestSend._commit_normal_mode() + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + def responses(): + # Provide responses for multiple retry attempts (60 retries * 5 second interval = 300 seconds) + for _ in range(60): + yield responses_rest_send(key) + + gen_responses = ResponseGenerator(responses()) + + params = {"check_mode": False} + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + with does_not_raise(): + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.unit_test = True + instance.timeout = 10 + instance.send_interval = 5 + instance.path = "/api/v1/test/badrequest" + instance.verb = HttpVerbEnum.POST + instance.payload = {"invalid": "data"} + instance.commit() + + # Verify error response + assert instance.response_current["RETURN_CODE"] == 400 + assert instance.result_current["success"] is False + + +def test_rest_send_00520(): + """ + # Summary + + Verify commit() with 500 Internal Server Error response + + ## Test + + - Failed GET request returns 500 response + - Loop retries until timeout is exhausted + + ## Classes and Methods + + - RestSend.commit() + - RestSend._commit_normal_mode() + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + def responses(): + # Provide responses for multiple retry attempts (60 retries * 5 second interval = 300 seconds) + for _ in range(60): + yield responses_rest_send(key) + + gen_responses = ResponseGenerator(responses()) + + params = {"check_mode": False} + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + with does_not_raise(): + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.unit_test = True + instance.timeout = 10 + instance.send_interval = 5 + instance.path = "/api/v1/test/servererror" + instance.verb = HttpVerbEnum.GET + instance.commit() + + # Verify error response + assert instance.response_current["RETURN_CODE"] == 500 + assert instance.result_current["success"] is False + + +# ============================================================================= +# Test: RestSend commit() with retry logic +# ============================================================================= + + +def test_rest_send_00600(): + """ + # Summary + + Verify commit() retries on failure then succeeds + + ## Test + + - First response is 500 error + - Second response is 200 success + - Final result is success + + ## Classes and Methods + + - RestSend.commit() + - RestSend._commit_normal_mode() + """ + method_name = inspect.stack()[0][3] + + def responses(): + # Retry test sequence: error then success + yield responses_rest_send(f"{method_name}a") + yield responses_rest_send(f"{method_name}a") + yield responses_rest_send(f"{method_name}b") + + gen_responses = ResponseGenerator(responses()) + + params = {"check_mode": False} + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + with does_not_raise(): + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.unit_test = True + instance.timeout = 10 + instance.send_interval = 1 + instance.path = "/api/v1/test/retry" + instance.verb = HttpVerbEnum.GET + instance.commit() + + # Verify final successful response + assert instance.response_current["RETURN_CODE"] == 200 + assert instance.response_current["DATA"]["status"] == "success" + assert instance.result_current["success"] is True + + +# ============================================================================= +# Test: RestSend multiple sequential commits +# ============================================================================= + + +def test_rest_send_00700(): + """ + # Summary + + Verify multiple sequential commit() calls + + ## Test + + - Multiple commits append to response and result lists + - Each commit populates response_current and result_current + + ## Classes and Methods + + - RestSend.commit() + """ + method_name = inspect.stack()[0][3] + + def responses(): + # 3 sequential commits + yield responses_rest_send(f"{method_name}a") + yield responses_rest_send(f"{method_name}b") + yield responses_rest_send(f"{method_name}c") + + gen_responses = ResponseGenerator(responses()) + + params = {"check_mode": False} + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.unit_test = True + + # First commit - GET + with does_not_raise(): + instance.path = "/api/v1/test/multi/1" + instance.verb = HttpVerbEnum.GET + instance.commit() + + assert instance.response_current["DATA"]["id"] == 1 + assert len(instance.response) == 1 + assert len(instance.result) == 1 + + # Second commit - GET + with does_not_raise(): + instance.path = "/api/v1/test/multi/2" + instance.verb = HttpVerbEnum.GET + instance.commit() + + assert instance.response_current["DATA"]["id"] == 2 + assert len(instance.response) == 2 + assert len(instance.result) == 2 + + # Third commit - POST + with does_not_raise(): + instance.path = "/api/v1/test/multi/create" + instance.verb = HttpVerbEnum.POST + instance.payload = {"name": "third"} + instance.commit() + + assert instance.response_current["DATA"]["id"] == 3 + assert instance.response_current["DATA"]["status"] == "created" + assert len(instance.response) == 3 + assert len(instance.result) == 3 + + +# ============================================================================= +# Test: RestSend error conditions +# ============================================================================= + + +def test_rest_send_00800(): + """ + # Summary + + Verify commit() raises ValueError when path not set + + ## Test + + - commit() raises ValueError if path not set + + ## Classes and Methods + + - RestSend.commit() + """ + params = {"check_mode": False} + + def responses(): + yield {} + + gen_responses = ResponseGenerator(responses()) + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.verb = HttpVerbEnum.GET + + # Don't set path - should raise ValueError + match = r"RestSend\.path:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_rest_send_00810(): + """ + # Summary + + Verify commit() raises ValueError when verb not set + + ## Test + + - commit() raises ValueError if verb not set + + ## Classes and Methods + + - RestSend.commit() + """ + params = {"check_mode": False} + + def responses(): + yield {} + + gen_responses = ResponseGenerator(responses()) + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.path = "/api/v1/test" + + # Reset verb to None to test ValueError + instance._verb = None # type: ignore[assignment] + + match = r"RestSend\.verb:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_rest_send_00820(): + """ + # Summary + + Verify commit() raises ValueError when sender not set + + ## Test + + - commit() raises ValueError if sender not set + + ## Classes and Methods + + - RestSend.commit() + """ + params = {"check_mode": False} + + instance = RestSend(params) + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.path = "/api/v1/test" + instance.verb = HttpVerbEnum.GET + + # Don't set sender - should raise ValueError + match = r"RestSend\.sender:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_rest_send_00830(): + """ + # Summary + + Verify commit() raises ValueError when response_handler not set + + ## Test + + - commit() raises ValueError if response_handler not set + + ## Classes and Methods + + - RestSend.commit() + """ + params = {"check_mode": False} + + def responses(): + # Stub responses (not consumed in this test) + yield {} + yield {} + + gen_responses = ResponseGenerator(responses()) + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + instance = RestSend(params) + instance.sender = sender + instance.path = "/api/v1/test" + instance.verb = HttpVerbEnum.GET + + # Don't set response_handler - should raise ValueError + match = r"RestSend\.response_handler:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + instance.commit() + + +# ============================================================================= +# Test: RestSend response and result properties +# ============================================================================= + + +def test_rest_send_00900(): + """ + # Summary + + Verify response and result properties return copies + + ## Test + + - response returns deepcopy of response list + - result returns deepcopy of result list + - Modifying returned values doesn't affect internal state + + ## Classes and Methods + + - RestSend.response + - RestSend.result + - RestSend.response_current + - RestSend.result_current + """ + method_name = inspect.stack()[0][3] + key = f"{method_name}a" + + def responses(): + # Provide an extra response entry for potential retry scenarios + yield responses_rest_send(key) + yield responses_rest_send(key) + + gen_responses = ResponseGenerator(responses()) + + params = {"check_mode": False} + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.unit_test = True + instance.path = "/api/v1/test/endpoint" + instance.verb = HttpVerbEnum.GET + instance.commit() + + # Get response and result + response_copy = instance.response + result_copy = instance.result + response_current_copy = instance.response_current + result_current_copy = instance.result_current + + # Modify copies + response_copy[0]["MODIFIED"] = True + result_copy[0]["MODIFIED"] = True + response_current_copy["MODIFIED"] = True + result_current_copy["MODIFIED"] = True + + # Verify original values unchanged + assert "MODIFIED" not in instance._response[0] + assert "MODIFIED" not in instance._result[0] + assert "MODIFIED" not in instance._response_current + assert "MODIFIED" not in instance._result_current + + +def test_rest_send_00910(): + """ + # Summary + + Verify failed_result property + + ## Test + + - failed_result returns a failure dict with changed=False + + ## Classes and Methods + + - RestSend.failed_result + """ + params = {"check_mode": False} + instance = RestSend(params) + + with does_not_raise(): + result = instance.failed_result + + assert result["failed"] is True + assert result["changed"] is False + + +# ============================================================================= +# Test: RestSend with sender exception simulation +# ============================================================================= + + +def test_rest_send_01000(): + """ + # Summary + + Verify commit() handles sender exceptions + + ## Test + + - Sender.commit() can raise exceptions + - RestSend.commit() propagates the exception + + ## Classes and Methods + + - RestSend.commit() + - Sender.commit() + - Sender.raise_exception + - Sender.raise_method + """ + params = {"check_mode": False} + + def responses(): + yield {} + + gen_responses = ResponseGenerator(responses()) + sender = Sender() + sender.ansible_module = MockAnsibleModule() + sender.gen = gen_responses + sender.path = "/api/v1/test" + sender.verb = HttpVerbEnum.GET + + # Configure sender to raise exception + sender.raise_method = "commit" + sender.raise_exception = ValueError("Simulated sender error") + + instance = RestSend(params) + instance.sender = sender + response_handler = ResponseHandler() + response_handler.response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + response_handler.verb = HttpVerbEnum.GET + response_handler.commit() + instance.response_handler = response_handler + instance.path = "/api/v1/test" + instance.verb = HttpVerbEnum.GET + + # commit() should raise ValueError + match = r"Simulated sender error" + with pytest.raises(ValueError, match=match): + instance.commit() diff --git a/tests/unit/module_utils/test_sender_nd.py b/tests/unit/module_utils/test_sender_nd.py new file mode 100644 index 00000000..5edd102f --- /dev/null +++ b/tests/unit/module_utils/test_sender_nd.py @@ -0,0 +1,906 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2026, Allen Robel (@arobel) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +Unit tests for sender_nd.py + +Tests the Sender class for sending REST requests via the Ansible HttpApi plugin. +""" + +# pylint: disable=unused-import +# pylint: disable=redefined-outer-name +# pylint: disable=protected-access +# pylint: disable=unused-argument +# pylint: disable=unused-variable +# pylint: disable=invalid-name +# pylint: disable=line-too-long +# pylint: disable=too-many-lines + +from __future__ import absolute_import, annotations, division, print_function + +__metaclass__ = type # pylint: disable=invalid-name + +from unittest.mock import MagicMock, patch + +import pytest +from ansible.module_utils.connection import ConnectionError as AnsibleConnectionError +from ansible_collections.cisco.nd.plugins.module_utils.enums import HttpVerbEnum +from ansible_collections.cisco.nd.plugins.module_utils.rest.sender_nd import Sender +from ansible_collections.cisco.nd.tests.unit.module_utils.common_utils import does_not_raise + +# ============================================================================= +# Test: Sender initialization +# ============================================================================= + + +def test_sender_nd_00010(): + """ + # Summary + + Verify Sender initialization with default values. + + ## Test + + - Instance can be created with no arguments + - All attributes default to None + + ## Classes and Methods + + - Sender.__init__() + """ + with does_not_raise(): + instance = Sender() + assert instance._ansible_module is None + assert instance._connection is None + assert instance._path is None + assert instance._payload is None + assert instance._response is None + assert instance._verb is None + + +def test_sender_nd_00020(): + """ + # Summary + + Verify Sender initialization with all parameters. + + ## Test + + - Instance can be created with all optional constructor arguments + + ## Classes and Methods + + - Sender.__init__() + """ + mock_module = MagicMock() + with does_not_raise(): + instance = Sender( + ansible_module=mock_module, + verb=HttpVerbEnum.GET, + path="/api/v1/test", + payload={"key": "value"}, + ) + assert instance._ansible_module is mock_module + assert instance._path == "/api/v1/test" + assert instance._payload == {"key": "value"} + assert instance._verb == HttpVerbEnum.GET + + +# ============================================================================= +# Test: Sender.ansible_module property +# ============================================================================= + + +def test_sender_nd_00100(): + """ + # Summary + + Verify ansible_module getter raises ValueError when not set. + + ## Test + + - Accessing ansible_module before setting raises ValueError + + ## Classes and Methods + + - Sender.ansible_module (getter) + """ + instance = Sender() + match = r"Sender\.ansible_module:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + result = instance.ansible_module + + +def test_sender_nd_00110(): + """ + # Summary + + Verify ansible_module setter/getter. + + ## Test + + - ansible_module can be set and retrieved + + ## Classes and Methods + + - Sender.ansible_module (setter/getter) + """ + instance = Sender() + mock_module = MagicMock() + with does_not_raise(): + instance.ansible_module = mock_module + result = instance.ansible_module + assert result is mock_module + + +# ============================================================================= +# Test: Sender.path property +# ============================================================================= + + +def test_sender_nd_00200(): + """ + # Summary + + Verify path getter raises ValueError when not set. + + ## Test + + - Accessing path before setting raises ValueError + + ## Classes and Methods + + - Sender.path (getter) + """ + instance = Sender() + match = r"Sender\.path:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + result = instance.path + + +def test_sender_nd_00210(): + """ + # Summary + + Verify path setter/getter. + + ## Test + + - path can be set and retrieved + + ## Classes and Methods + + - Sender.path (setter/getter) + """ + instance = Sender() + with does_not_raise(): + instance.path = "/api/v1/test/endpoint" + result = instance.path + assert result == "/api/v1/test/endpoint" + + +# ============================================================================= +# Test: Sender.verb property +# ============================================================================= + + +def test_sender_nd_00300(): + """ + # Summary + + Verify verb getter raises ValueError when not set. + + ## Test + + - Accessing verb before setting raises ValueError + + ## Classes and Methods + + - Sender.verb (getter) + """ + instance = Sender() + match = r"Sender\.verb:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + result = instance.verb + + +def test_sender_nd_00310(): + """ + # Summary + + Verify verb setter/getter with valid HttpVerbEnum. + + ## Test + + - verb can be set and retrieved with all HttpVerbEnum values + + ## Classes and Methods + + - Sender.verb (setter/getter) + """ + instance = Sender() + for verb in (HttpVerbEnum.GET, HttpVerbEnum.POST, HttpVerbEnum.PUT, HttpVerbEnum.DELETE): + with does_not_raise(): + instance.verb = verb + result = instance.verb + assert result == verb + + +def test_sender_nd_00320(): + """ + # Summary + + Verify verb setter raises TypeError for invalid value. + + ## Test + + - Setting verb to a value not in HttpVerbEnum.values() raises TypeError + + ## Classes and Methods + + - Sender.verb (setter) + """ + instance = Sender() + match = r"Sender\.verb:.*must be one of" + with pytest.raises(TypeError, match=match): + instance.verb = "INVALID" # type: ignore[assignment] + + +# ============================================================================= +# Test: Sender.payload property +# ============================================================================= + + +def test_sender_nd_00400(): + """ + # Summary + + Verify payload defaults to None. + + ## Test + + - payload is None by default + + ## Classes and Methods + + - Sender.payload (getter) + """ + instance = Sender() + with does_not_raise(): + result = instance.payload + assert result is None + + +def test_sender_nd_00410(): + """ + # Summary + + Verify payload setter/getter with valid dict. + + ## Test + + - payload can be set and retrieved + + ## Classes and Methods + + - Sender.payload (setter/getter) + """ + instance = Sender() + with does_not_raise(): + instance.payload = {"name": "test", "config": {"key": "value"}} + result = instance.payload + assert result == {"name": "test", "config": {"key": "value"}} + + +def test_sender_nd_00420(): + """ + # Summary + + Verify payload setter raises TypeError for non-dict. + + ## Test + + - Setting payload to a non-dict raises TypeError + + ## Classes and Methods + + - Sender.payload (setter) + """ + instance = Sender() + match = r"Sender\.payload:.*must be a dict" + with pytest.raises(TypeError, match=match): + instance.payload = "not a dict" # type: ignore[assignment] + + +def test_sender_nd_00430(): + """ + # Summary + + Verify payload setter raises TypeError for list. + + ## Test + + - Setting payload to a list raises TypeError + + ## Classes and Methods + + - Sender.payload (setter) + """ + instance = Sender() + match = r"Sender\.payload:.*must be a dict" + with pytest.raises(TypeError, match=match): + instance.payload = [1, 2, 3] # type: ignore[assignment] + + +# ============================================================================= +# Test: Sender.response property +# ============================================================================= + + +def test_sender_nd_00500(): + """ + # Summary + + Verify response getter raises ValueError when not set. + + ## Test + + - Accessing response before commit raises ValueError + + ## Classes and Methods + + - Sender.response (getter) + """ + instance = Sender() + match = r"Sender\.response:.*must be set before accessing" + with pytest.raises(ValueError, match=match): + result = instance.response + + +def test_sender_nd_00510(): + """ + # Summary + + Verify response getter returns deepcopy. + + ## Test + + - response getter returns a deepcopy of the internal response + + ## Classes and Methods + + - Sender.response (getter) + """ + instance = Sender() + instance._response = {"RETURN_CODE": 200, "MESSAGE": "OK", "DATA": {"key": "value"}} + result = instance.response + # Modify the copy + result["MODIFIED"] = True + # Verify original is unchanged + assert "MODIFIED" not in instance._response + + +def test_sender_nd_00520(): + """ + # Summary + + Verify response setter raises TypeError for non-dict. + + ## Test + + - Setting response to a non-dict raises TypeError + + ## Classes and Methods + + - Sender.response (setter) + """ + instance = Sender() + match = r"Sender\.response:.*must be a dict" + with pytest.raises(TypeError, match=match): + instance.response = "not a dict" # type: ignore[assignment] + + +def test_sender_nd_00530(): + """ + # Summary + + Verify response setter accepts valid dict. + + ## Test + + - response can be set with a valid dict + + ## Classes and Methods + + - Sender.response (setter/getter) + """ + instance = Sender() + response = {"RETURN_CODE": 200, "MESSAGE": "OK"} + with does_not_raise(): + instance.response = response + result = instance.response + assert result["RETURN_CODE"] == 200 + assert result["MESSAGE"] == "OK" + + +# ============================================================================= +# Test: Sender._normalize_response() +# ============================================================================= + + +def test_sender_nd_00600(): + """ + # Summary + + Verify _normalize_response with normal JSON response. + + ## Test + + - Response with valid DATA passes through unchanged + + ## Classes and Methods + + - Sender._normalize_response() + """ + instance = Sender() + response = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": {"status": "success"}, + } + result = instance._normalize_response(response) + assert result["DATA"] == {"status": "success"} + assert result["MESSAGE"] == "OK" + + +def test_sender_nd_00610(): + """ + # Summary + + Verify _normalize_response when DATA is None and raw is present. + + ## Test + + - When DATA is None and raw is present, DATA is populated with raw_response + - MESSAGE is set to indicate JSON parsing failure + + ## Classes and Methods + + - Sender._normalize_response() + """ + instance = Sender() + response = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": None, + "raw": "Not JSON", + } + result = instance._normalize_response(response) + assert result["DATA"] == {"raw_response": "Not JSON"} + assert result["MESSAGE"] == "Response could not be parsed as JSON" + + +def test_sender_nd_00620(): + """ + # Summary + + Verify _normalize_response when DATA is None, raw is present, + and MESSAGE is None. + + ## Test + + - When MESSAGE is None, it is set to indicate JSON parsing failure + + ## Classes and Methods + + - Sender._normalize_response() + """ + instance = Sender() + response = { + "RETURN_CODE": 200, + "MESSAGE": None, + "DATA": None, + "raw": "raw content", + } + result = instance._normalize_response(response) + assert result["DATA"] == {"raw_response": "raw content"} + assert result["MESSAGE"] == "Response could not be parsed as JSON" + + +def test_sender_nd_00630(): + """ + # Summary + + Verify _normalize_response when DATA is None and raw is also None. + + ## Test + + - When both DATA and raw are None, response is not modified + + ## Classes and Methods + + - Sender._normalize_response() + """ + instance = Sender() + response = { + "RETURN_CODE": 500, + "MESSAGE": "Internal Server Error", + "DATA": None, + } + result = instance._normalize_response(response) + assert result["DATA"] is None + assert result["MESSAGE"] == "Internal Server Error" + + +def test_sender_nd_00640(): + """ + # Summary + + Verify _normalize_response preserves non-OK MESSAGE when raw is present. + + ## Test + + - When DATA is None and raw is present, MESSAGE is only overwritten + if it was "OK" or None + + ## Classes and Methods + + - Sender._normalize_response() + """ + instance = Sender() + response = { + "RETURN_CODE": 500, + "MESSAGE": "Internal Server Error", + "DATA": None, + "raw": "raw error content", + } + result = instance._normalize_response(response) + assert result["DATA"] == {"raw_response": "raw error content"} + # MESSAGE is NOT overwritten because it's not "OK" or None + assert result["MESSAGE"] == "Internal Server Error" + + +# ============================================================================= +# Test: Sender.commit() with mocked Connection +# ============================================================================= + + +def test_sender_nd_00700(): + """ + # Summary + + Verify commit() with successful GET request (no payload). + + ## Test + + - commit() calls Connection.send_request with verb and path + - response is populated from the Connection response + + ## Classes and Methods + + - Sender.commit() + """ + mock_module = MagicMock() + mock_module._socket_path = "/tmp/test_socket" + mock_module.params = {"config": {}} + + mock_connection = MagicMock() + mock_connection.send_request.return_value = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": {"status": "success"}, + } + + instance = Sender() + instance.ansible_module = mock_module + instance.path = "/api/v1/test" + instance.verb = HttpVerbEnum.GET + + with patch( + "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + return_value=mock_connection, + ): + with does_not_raise(): + instance.commit() + + assert instance.response["RETURN_CODE"] == 200 + assert instance.response["DATA"]["status"] == "success" + mock_connection.send_request.assert_called_once_with("GET", "/api/v1/test") + + +def test_sender_nd_00710(): + """ + # Summary + + Verify commit() with POST request including payload. + + ## Test + + - commit() calls Connection.send_request with verb, path, and JSON payload + + ## Classes and Methods + + - Sender.commit() + """ + mock_module = MagicMock() + mock_module._socket_path = "/tmp/test_socket" + mock_module.params = {"config": {}} + + mock_connection = MagicMock() + mock_connection.send_request.return_value = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": {"status": "created"}, + } + + instance = Sender() + instance.ansible_module = mock_module + instance.path = "/api/v1/test/create" + instance.verb = HttpVerbEnum.POST + instance.payload = {"name": "test"} + + with patch( + "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + return_value=mock_connection, + ): + with does_not_raise(): + instance.commit() + + assert instance.response["RETURN_CODE"] == 200 + assert instance.response["DATA"]["status"] == "created" + mock_connection.send_request.assert_called_once_with( + "POST", + "/api/v1/test/create", + '{"name": "test"}', + ) + + +def test_sender_nd_00720(): + """ + # Summary + + Verify commit() raises ValueError on connection failure. + + ## Test + + - When Connection.send_request raises AnsibleConnectionError, + commit() re-raises as ValueError + + ## Classes and Methods + + - Sender.commit() + """ + mock_module = MagicMock() + mock_module._socket_path = "/tmp/test_socket" + mock_module.params = {"config": {}} + + mock_connection = MagicMock() + mock_connection.send_request.side_effect = AnsibleConnectionError("Connection refused") + + instance = Sender() + instance.ansible_module = mock_module + instance.path = "/api/v1/test" + instance.verb = HttpVerbEnum.GET + + with patch( + "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + return_value=mock_connection, + ): + match = r"Sender\.commit:.*ConnectionError occurred" + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_sender_nd_00730(): + """ + # Summary + + Verify commit() raises ValueError on unexpected exception. + + ## Test + + - When Connection.send_request raises an unexpected Exception, + commit() wraps it in ValueError + + ## Classes and Methods + + - Sender.commit() + """ + mock_module = MagicMock() + mock_module._socket_path = "/tmp/test_socket" + mock_module.params = {"config": {}} + + mock_connection = MagicMock() + mock_connection.send_request.side_effect = RuntimeError("Unexpected error") + + instance = Sender() + instance.ansible_module = mock_module + instance.path = "/api/v1/test" + instance.verb = HttpVerbEnum.GET + + with patch( + "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + return_value=mock_connection, + ): + match = r"Sender\.commit:.*Unexpected error occurred" + with pytest.raises(ValueError, match=match): + instance.commit() + + +def test_sender_nd_00740(): + """ + # Summary + + Verify commit() reuses existing connection on second call. + + ## Test + + - First commit creates a new Connection + - Second commit reuses the existing connection + - Connection constructor is called only once + + ## Classes and Methods + + - Sender.commit() + """ + mock_module = MagicMock() + mock_module._socket_path = "/tmp/test_socket" + mock_module.params = {"config": {}} + + mock_connection = MagicMock() + mock_connection.send_request.return_value = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": {}, + } + + with patch( + "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + return_value=mock_connection, + ) as mock_conn_class: + instance = Sender() + instance.ansible_module = mock_module + instance.path = "/api/v1/test" + instance.verb = HttpVerbEnum.GET + + instance.commit() + instance.commit() + + # Connection constructor should only be called once + mock_conn_class.assert_called_once() + # send_request should be called twice + assert mock_connection.send_request.call_count == 2 + + +def test_sender_nd_00750(): + """ + # Summary + + Verify commit() normalizes non-JSON responses. + + ## Test + + - When Connection returns DATA=None with raw content, + commit() normalizes the response + + ## Classes and Methods + + - Sender.commit() + - Sender._normalize_response() + """ + mock_module = MagicMock() + mock_module._socket_path = "/tmp/test_socket" + mock_module.params = {"config": {}} + + mock_connection = MagicMock() + mock_connection.send_request.return_value = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": None, + "raw": "Non-JSON response", + } + + instance = Sender() + instance.ansible_module = mock_module + instance.path = "/api/v1/test" + instance.verb = HttpVerbEnum.GET + + with patch( + "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + return_value=mock_connection, + ): + with does_not_raise(): + instance.commit() + + assert instance.response["DATA"] == {"raw_response": "Non-JSON response"} + assert instance.response["MESSAGE"] == "Response could not be parsed as JSON" + + +def test_sender_nd_00760(): + """ + # Summary + + Verify commit() with PUT request including payload. + + ## Test + + - commit() calls Connection.send_request with PUT verb, path, and JSON payload + + ## Classes and Methods + + - Sender.commit() + """ + mock_module = MagicMock() + mock_module._socket_path = "/tmp/test_socket" + mock_module.params = {"config": {}} + + mock_connection = MagicMock() + mock_connection.send_request.return_value = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": {"status": "updated"}, + } + + instance = Sender() + instance.ansible_module = mock_module + instance.path = "/api/v1/test/update/12345" + instance.verb = HttpVerbEnum.PUT + instance.payload = {"status": "active"} + + with patch( + "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + return_value=mock_connection, + ): + with does_not_raise(): + instance.commit() + + assert instance.response["RETURN_CODE"] == 200 + mock_connection.send_request.assert_called_once_with( + "PUT", + "/api/v1/test/update/12345", + '{"status": "active"}', + ) + + +def test_sender_nd_00770(): + """ + # Summary + + Verify commit() with DELETE request (no payload). + + ## Test + + - commit() calls Connection.send_request with DELETE verb and path + + ## Classes and Methods + + - Sender.commit() + """ + mock_module = MagicMock() + mock_module._socket_path = "/tmp/test_socket" + mock_module.params = {"config": {}} + + mock_connection = MagicMock() + mock_connection.send_request.return_value = { + "RETURN_CODE": 200, + "MESSAGE": "OK", + "DATA": {"status": "deleted"}, + } + + instance = Sender() + instance.ansible_module = mock_module + instance.path = "/api/v1/test/delete/12345" + instance.verb = HttpVerbEnum.DELETE + + with patch( + "ansible_collections.cisco.nd.plugins.module_utils.sender_nd.Connection", + return_value=mock_connection, + ): + with does_not_raise(): + instance.commit() + + assert instance.response["RETURN_CODE"] == 200 + mock_connection.send_request.assert_called_once_with("DELETE", "/api/v1/test/delete/12345")