Skip to content

Add Smart Endpoints framework for ND API v1#186

Open
allenrobel wants to merge 21 commits intond42_integrationfrom
nd42_smart_endpoints
Open

Add Smart Endpoints framework for ND API v1#186
allenrobel wants to merge 21 commits intond42_integrationfrom
nd42_smart_endpoints

Conversation

@allenrobel
Copy link
Collaborator

@allenrobel allenrobel commented Mar 3, 2026

Summary

Adds the Smart Endpoints framework — a type-safe, Pydantic-validated approach to constructing ND REST API endpoint paths and query parameters. This PR is one of three companion PRs targeting nd42_integration:

PR Branch Contents Merge order
#185 nd42_rest_send RestSend framework, enums.py, shared test infrastructure 1st
#184 nd42_logging Log class and logging config 2nd
This PR nd42_smart_endpoints Smart Endpoints framework 3rd

Note: Tests depend on enums.py, pydantic_compat.py, and shared test infrastructure from nd42_rest_send (#185), and on log.py from nd42_logging (#184). Tests will not run until all three branches are merged into nd42_integration.


Plugin files added

Base classes (plugins/module_utils/endpoints/)

File Description
base_path.py ApiPath — string enum providing type-safe base API path constants shared across all endpoint versions
endpoint_mixins.py Reusable endpoint mixins (e.g. LoginIdMixin for endpoints requiring a login ID)
query_params.py EndpointQueryParams, LuceneQueryParams, CompositeQueryParams — Pydantic models for structured, validated query string construction

ND API v1 endpoints (plugins/module_utils/endpoints/v1/)

File Description
base_paths_infra.py Base path constants for the ND /api/v1/infra namespace
base_paths_manage.py Base path constants for the ND /api/v1/manage namespace
infra_aaa.py AAA (login/logout) endpoints
infra_clusterhealth.py Cluster health endpoints
infra_login.py Login endpoint (EpInfraLoginPost) for /api/v1/infra/login
manage_switches.py Switch management endpoint with Lucene query support

Unit tests added

File Covers
test_base_path.py ApiPath base class
test_base_paths_infra.py Infra base paths
test_base_paths_manage.py Manage base paths
test_endpoint_mixins.py LoginIdMixin and other mixins
test_endpoints_api_v1_infra_aaa.py AAA endpoints
test_endpoints_api_v1_infra_clusterhealth.py Cluster health endpoints
test_endpoints_api_v1_infra_login.py Login endpoint
test_endpoints_api_v1_manage_switches.py Switch management endpoint
test_query_params.py All query parameter models

Dependencies

Merge order

  1. Merge nd42_rest_send (Add RestSend framework, enums, and shared unit test infrastructure #185) → nd42_integration
  2. Merge nd42_logging (Add Log class and logging config #184) → nd42_integration
  3. Merge this PR → nd42_integration

Test plan

  • After merging all three PRs into nd42_integration: python -m pytest tests/unit/module_utils/endpoints/ passes cleanly
  • tox -e linters passes (black, isort, pylint, mypy)
  • ansible-test sanity --docker passes

🤖 Generated with Claude Code

Base classes:
- plugins/module_utils/ep/base_path.py: ApiPath base class for type-safe
  endpoint path construction
- plugins/module_utils/ep/base_paths_ndfc.py: NDFC base path constants
- plugins/module_utils/ep/endpoint_mixins.py: Reusable endpoint mixins
  (e.g. LoginIdMixin)
- plugins/module_utils/ep/query_params.py: EndpointQueryParams,
  LuceneQueryParams, CompositeQueryParams for structured query string building

ND API v1 endpoints:
- plugins/module_utils/ep/v1/base_paths_infra.py: Infra API base paths
- plugins/module_utils/ep/v1/base_paths_manage.py: Manage API base paths
- plugins/module_utils/ep/v1/ep_infra_aaa.py: AAA (login) endpoint
- plugins/module_utils/ep/v1/ep_infra_clusterhealth.py: Cluster health endpoint
- plugins/module_utils/ep/v1/ep_manage_switches.py: Switch management endpoint

Design documentation:
- plugins/module_utils/ep/ANALYSIS_COMPLEXITY.md
- plugins/module_utils/ep/ANALYSIS_ENDPOINT_DESIGN.md
- plugins/module_utils/ep/ANALYSIS_TYPE_SAFETY.md

Unit tests:
- tests/unit/module_utils/ep/test_base_path.py
- tests/unit/module_utils/ep/test_base_paths_infra.py
- tests/unit/module_utils/ep/test_base_paths_manage.py
- tests/unit/module_utils/ep/test_endpoint_mixins.py
- tests/unit/module_utils/ep/test_ep_api_v1_infra_aaa.py
- tests/unit/module_utils/ep/test_ep_api_v1_infra_clusterhealth.py
- tests/unit/module_utils/ep/test_ep_api_v1_manage_switches.py
- tests/unit/module_utils/ep/test_query_params.py

Note: tests depend on enums.py, pydantic_compat.py, and shared test
infrastructure (common_utils, etc.) from nd42_rest_send, and on log.py
from nd42_logging. Tests will not run until all branches are merged
into nd42_integration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
allenrobel and others added 10 commits March 3, 2026 07:30
1. Remove /mso, /appcenter/*, /login paths

TODO: Need to add /api/v1/infra/login under infra endpoints.
Removed the following from all files:

# -*- coding: utf-8 -*-
- plugins/module_utils/ep/ → plugins/module_utils/endpoints/
- v1/ep_infra_aaa.py → v1/infra_aaa.py
- v1/ep_infra_clusterhealth.py → v1/infra_clusterhealth.py
- v1/ep_manage_switches.py → v1/manage_switches.py
- tests/unit/module_utils/ep/ → tests/unit/module_utils/endpoints/
- test_ep_api_v1_*.py → test_endpoints_api_v1_*.py
- Update all internal imports to reflect new paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the test_ep_ prefix in all test function names within the
three ep-specific test files to match the renamed file and directory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ansible sanity tests use a regex to verify the __metaclass__ = type
declaration and do not expect an inline comment. Move the pylint
disable/enable directives to separate lines.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@@ -0,0 +1,89 @@
# Copyright: (c) 2026, Allen Robel (@arobel) <arobel@cisco.com>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need endpoint_ in this name can be mixins.py

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Akshay has mixins and others will have mixins so should we put them all in onen file called mixins.py or only put "common" things in mixins.py and use a module_name directory or some other way to separate mixin files?

Copy link
Collaborator Author

@allenrobel allenrobel Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to mixins.py with commit: 26cbd52

Endpoint mixins are associated with endpoints rather than than modules. So, I'd lean toward keeping them in one file (or, if we use multiple files, name them e.g. infra_mixins.py, manage_mixins.py, etc). I had Claude do an analysis of the query parameter usage in infra and manage where he read the schema and reported on the percentage sharing of query params across all endpoints, max query params used by endpoints, etc.

Claude found zero overlap between infra and manage, so these should probably be separate files:

https://github.com/CiscoDevNet/ansible-nd/blob/rest_send_integration/ANALYSIS_ENDPOINT_PARAMETERS.md

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another conclusion Claude made is that there are several mixins defined in mixins.py that are not used in either infra or manage schemas. These are probably legacy mixins from NDFC, so I'll open an issue to remove them. I'll double check these though...

"Key Finding: Only 3 of 11 defined mixins are actually used (LoginIdMixin, FabricNameMixin, ForceShowRunMixin). The 8 unused mixins (ClusterNameMixin, HealthCategoryMixin, InclAllMsdSwitchesMixin, LinkUuidMixin, NetworkNameMixin, NodeNameMixin, SwitchSerialNumberMixin, VrfNameMixin) have no corresponding parameters in either schema."

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed in the call I think for a bunch of the files such as mixins, enums, types, ... they should have a scope and we could have them at different scopes here these mixins seems to be endpoints scoped but if we see later the need to move them to a larger scope we could once they are needed elsewhere.

If we already think some will be used elsewhere we should move these into the higher scope sooner.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @lhercot and agree. I think the current scope for mixins.py seems appropriate for now.

allenrobel and others added 5 commits March 4, 2026 08:50
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pydantic_compat was moved into module_utils/commmon in nd42_rest_send branch.  nd42_smart_endpoints and nd42_rest_send will be merged into a common integration branch shortly, so am updating at least some of the imports that are known to have changed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Forgot to add this in the earlier commit.
allenrobel and others added 5 commits March 5, 2026 09:55
typing.Final was added in Python 3.8. Since all affected files already
use from __future__ import annotations (PEP 563), Final is only needed
during static type checking and never evaluated at runtime. Guarding the
import with TYPE_CHECKING avoids the ImportError on Python 3.7 with no
dependency on typing_extensions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adding for Ansible sanity tests.
Literal was added in Python 3.8. ansible-test sanity runs against
Python 3.7, so importing it directly causes ImportError. Since all
three files already have `from __future__ import annotations`, all
annotations are lazy strings at runtime and the TYPE_CHECKING guard
is safe.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ansible-test sanity requires all __init__.py files under plugins/ to be
completely empty. No imports are broken since all consumers already
import directly from submodules.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@@ -0,0 +1,89 @@
# Copyright: (c) 2026, Allen Robel (@arobel) <arobel@cisco.com>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed in the call I think for a bunch of the files such as mixins, enums, types, ... they should have a scope and we could have them at different scopes here these mixins seems to be endpoints scoped but if we see later the need to move them to a larger scope we could once they are needed elsewhere.

If we already think some will be used elsewhere we should move these into the higher scope sooner.

)


class QueryParams(ABC):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't it be used in L#83, L#145 and L#225 to make sure they all respect the interface?


@field_validator("sort")
@classmethod
def validate_sort(cls, value):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be an internal method? Do we expect to use it externally?


def is_empty(self) -> bool:
"""Check if any filter parameters are set."""
return all(v is None for v in self.model_dump().values())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't using the following be more efficient?

Suggested change
return all(v is None for v in self.model_dump().values())
return all(v is None for v in self.model_dump(exclude_none=True).values())

"""

def __init__(self) -> None:
self._param_groups: list[Union[EndpointQueryParams, LuceneQueryParams]] = []
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this not just list[QueryParams] as it defines your required interface while EndpointQueryParams and LuceneQueryParams are implementation of that interface?

This would require to be modified every time we add a QueryParams type which your interface definition is suppose to avoid.

COMMON_CONFIG = ConfigDict(validate_assignment=True)


class _EpInfraAaaLocalUsersBase(LoginIdMixin, BaseModel):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be inheriting from NDEndpointBaseModel similar to this from Gaspard's PR: https://github.com/gmicol/ansible-nd/blob/6e81ee0f97a0ebaf84b1a811d28ba27c32f909ed/plugins/module_utils/endpoints/base.py#L20 so we can define specific behavior we want globally. The set_identifier part can be omited for now but the inheritance to a class we control will allow this facility to be added more easily in Gaspard's PR.

This will also help force the definition of:

api_version: Literal["v1"] = Field(default="v1", description="ND API version for this endpoint")
    min_controller_version: str = Field(default="3.0.0", description="Minimum ND version supporting this endpoint")
    class_name: Literal["EpInfraLoginPost"] = Field(default="EpInfraLoginPost", description="Class name for backward compatibility")

So developer won't be able to write endpoints without defining them or getting a global definition when it makes sense.

return BasePath.nd_infra_aaa("localUsers")


class EpInfraAaaLocalUsersGet(_EpInfraAaaLocalUsersBase):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we keep v1 in the name? Is there places where we would import both v1 and v2 and have to rename the import or is this more an exception?

COMMON_CONFIG = ConfigDict(validate_assignment=True)


class ClusterHealthConfigEndpointParams(EndpointQueryParams):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this use composition using ClusterNameMixin?

Same comment for all following EndpointParams that use elements that are already defined in mixins.

API: Final = ApiPath.INFRA.value

@classmethod
def nd_infra(cls, *segments: str) -> str:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be a more generic method such as path or build_path.

Comment on lines +93 to +139
@classmethod
def nd_infra_aaa(cls, *segments: str) -> str:
"""
# Summary

Build ND infra AAA API path.

## Parameters

- segments: Path segments to append after aaa (e.g., "localUsers")

## Returns

- Complete ND infra AAA path

## Example

```python
path = BasePath.nd_infra_aaa("localUsers")
# Returns: /api/v1/infra/aaa/localUsers
```
"""
return cls.nd_infra("aaa", *segments)

@classmethod
def nd_infra_clusterhealth(cls, *segments: str) -> str:
"""
# Summary

Build ND infra clusterhealth API path.

## Parameters

- segments: Path segments to append after clusterhealth (e.g., "config", "status")

## Returns

- Complete ND infra clusterhealth path

## Example

```python
path = BasePath.nd_infra_clusterhealth("config")
# Returns: /api/v1/infra/clusterhealth/config
```
"""
return cls.nd_infra("clusterhealth", *segments)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be moved into their own BasePath class in a separate file infra_xxx.py with the current infra_xxx.py splitted by endpoint as mentioned in the other comment.

This will allow simpler imports and usage down the path as each endpoint will only have to import their parent path and call BasePath.build_path().
It will also allow better separation between each path in case changes need to be made and avoid having merge conflicts on base_paths_infra.py everytime we need to add a new path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants