Skip to content

Commit 162d155

Browse files
huffmancaclaude
andauthored
AAP-56282: Include x-ai-description in generated spec (ansible#879)
Adds `x-ai-description` field for every endpoint in the generated openapi spec with a series of processing hooks to ensure its presence In order: 1. Respect any explicitly defined `x-ai-description` values on the endpoint 2. Use a new `resource_purpose` field that can be defined on ViewSets 3. Use contextual details from the operation and resource --------- Co-authored-by: Claude <[email protected]>
1 parent cbd07f2 commit 162d155

File tree

21 files changed

+2682
-0
lines changed

21 files changed

+2682
-0
lines changed

ansible_base/activitystream/views.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ class EntryReadOnlyViewSet(ReadOnlyModelViewSet, AnsibleBaseDjangoAppApiView):
3737
API endpoint that allows for read-only access to activity stream entries.
3838
"""
3939

40+
resource_purpose = "audit trail entries for tracking system changes and user actions"
41+
4042
queryset = Entry.objects.prefetch_related('created_by', 'content_type').all()
4143
serializer_class = EntrySerializer
4244
filter_backends = calculate_filter_backends()

ansible_base/api_documentation/postprocessing_hooks.py

Lines changed: 455 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import logging
2+
from typing import Any, Optional
3+
4+
from ansible_base.lib.utils.api_path_utils import parse_path_segments
5+
6+
logger = logging.getLogger('ansible_base.api_documentation.preprocessing_hooks')
7+
8+
# Spec generation is single-threaded, so the following globals should be safe
9+
10+
# Global storage for skip_ai_description operation ID prefixes
11+
# Maps operation_id prefix (e.g. "teams") -> True for views with skip_ai_description
12+
# This is shared with postprocessing_hooks.py
13+
SKIP_AI_DESCRIPTION_PREFIXES = set()
14+
15+
# Global storage for resource_purpose values
16+
# Maps ViewSet class name -> resource_purpose string
17+
# This is shared with postprocessing_hooks.py
18+
RESOURCE_PURPOSE_MAP = {}
19+
20+
# Global storage for ViewSet class names by operation_id prefix
21+
# Maps operation_id prefix (e.g. "authenticators") -> (ViewSet class name, path_parts_count, path_parts)
22+
# This is shared with postprocessing_hooks.py
23+
# We store the count to resolve collisions: fewer path parts = main resource, gets simple prefix
24+
# We store path_parts to generate correct compound prefixes when resolving collisions
25+
OPERATION_CLASS_MAP = {}
26+
27+
28+
def _get_view_class(view: Any) -> type:
29+
"""Extract the ViewSet class from a view, handling DRF's view wrapping."""
30+
return getattr(view, 'cls', view.__class__)
31+
32+
33+
def _extract_prefix_from_path(path: str) -> tuple[Optional[str], Optional[list[str]], Optional[int]]:
34+
"""Extract the operation_id prefix from a URL path."""
35+
path_parts = parse_path_segments(path)
36+
if not path_parts:
37+
return None, None, None
38+
39+
prefix = path_parts[-1]
40+
path_parts_count = len(path_parts)
41+
return prefix, path_parts, path_parts_count
42+
43+
44+
def _create_compound_prefix(path_parts: list[str], fallback_prefix: str) -> str:
45+
"""Create compound prefix from path parts (e.g., 'orgs_teams') to resolve collisions."""
46+
if len(path_parts) >= 2:
47+
return '_'.join(path_parts[-2:])
48+
return fallback_prefix
49+
50+
51+
def _handle_prefix_collision(
52+
prefix: str, class_name: str, path_parts_count: int, path_parts: list[str], operation_class_map: dict[str, tuple[str, int, list[str]]]
53+
) -> str:
54+
"""
55+
Handle collision when multiple ViewSets use the same operation_id prefix.
56+
ViewSet with fewer path parts gets simple prefix; the other gets compound prefix.
57+
"""
58+
existing_class, existing_count, existing_path_parts = operation_class_map[prefix]
59+
60+
# Same ViewSet class - no collision
61+
if existing_class == class_name:
62+
return prefix
63+
64+
# Different ViewSet - resolve collision
65+
if path_parts_count < existing_count:
66+
# Current is main resource - move existing to compound prefix
67+
# Use the existing entry's path_parts to create the correct compound prefix
68+
compound_prefix = _create_compound_prefix(existing_path_parts, prefix)
69+
operation_class_map[compound_prefix] = (existing_class, existing_count, existing_path_parts)
70+
operation_class_map[prefix] = (class_name, path_parts_count, path_parts)
71+
logger.debug(f"Resource collision: {class_name} (main, {path_parts_count} parts) owns '{prefix}', {existing_class} moved to '{compound_prefix}'")
72+
return prefix
73+
else:
74+
# Existing is main resource - current gets compound prefix
75+
compound_prefix = _create_compound_prefix(path_parts, prefix)
76+
operation_class_map[compound_prefix] = (class_name, path_parts_count, path_parts)
77+
logger.debug(f"Resource collision: {existing_class} (main, {existing_count} parts) keeps '{prefix}', {class_name} stored at '{compound_prefix}'")
78+
return compound_prefix
79+
80+
81+
def _register_skip_ai_description(view_class: type, class_name: str, prefix: str) -> None:
82+
"""Register a ViewSet that should skip AI description generation."""
83+
if getattr(view_class, 'skip_ai_description', False):
84+
SKIP_AI_DESCRIPTION_PREFIXES.add(prefix)
85+
logger.info(f"View class {class_name} (prefix: {prefix}) has skip_ai_description=True")
86+
87+
88+
def _register_resource_purpose(view_class: type, class_name: str, prefix: str) -> None:
89+
"""Register a ViewSet's resource_purpose for description generation."""
90+
resource_purpose = getattr(view_class, 'resource_purpose', None)
91+
if resource_purpose:
92+
RESOURCE_PURPOSE_MAP[class_name] = resource_purpose
93+
logger.debug(f"View class {class_name} (prefix: {prefix}) has resource_purpose: {resource_purpose[:50]}{'...' if len(resource_purpose) > 50 else ''}")
94+
95+
96+
def collect_ai_description_metadata(endpoints: Optional[list[tuple[str, str, str, Any]]], **kwargs) -> Optional[list[tuple[str, str, str, Any]]]:
97+
"""
98+
Preprocessing hook for drf-spectacular that collects metadata from ViewSets for AI description generation.
99+
100+
This hook runs before OpenAPI schema generation and inspects all registered ViewSets/APIViews
101+
to collect metadata that will be used by the postprocessing hook to generate x-ai-description fields.
102+
103+
The hook collects three types of metadata:
104+
1. skip_ai_description flags - ViewSets that have opted out of AI description generation
105+
2. resource_purpose values - Custom purpose strings for template-based description generation
106+
3. Operation ID mappings - Relationships between operation prefixes and ViewSet classes
107+
108+
When multiple ViewSets share the same operation prefix (e.g., nested resources), the hook
109+
automatically resolves naming collisions by giving the ViewSet with fewer path parts the
110+
simple prefix, and assigning compound prefixes to nested resources.
111+
112+
The collected metadata is stored in global variables (SKIP_AI_DESCRIPTION_PREFIXES,
113+
RESOURCE_PURPOSE_MAP, OPERATION_CLASS_MAP) that are shared with the postprocessing hook.
114+
115+
Args:
116+
endpoints: List of endpoint tuples (path, path_regex, method, view)
117+
**kwargs: Additional keyword arguments (unused)
118+
119+
Returns:
120+
The unmodified endpoints list
121+
122+
Side effects:
123+
- Clears and repopulates SKIP_AI_DESCRIPTION_PREFIXES set
124+
- Clears and repopulates RESOURCE_PURPOSE_MAP dict
125+
- Clears and repopulates OPERATION_CLASS_MAP dict
126+
"""
127+
global SKIP_AI_DESCRIPTION_PREFIXES, RESOURCE_PURPOSE_MAP, OPERATION_CLASS_MAP
128+
SKIP_AI_DESCRIPTION_PREFIXES.clear()
129+
RESOURCE_PURPOSE_MAP.clear()
130+
OPERATION_CLASS_MAP.clear()
131+
132+
if endpoints:
133+
for path, path_regex, method, view in endpoints:
134+
try:
135+
# Extract ViewSet class from the view
136+
view_class = _get_view_class(view)
137+
class_name = view_class.__name__
138+
139+
# Extract operation_id prefix from path
140+
prefix, path_parts, path_parts_count = _extract_prefix_from_path(path)
141+
if prefix is None:
142+
continue
143+
144+
# Store or handle collision for this prefix
145+
if prefix not in OPERATION_CLASS_MAP:
146+
# First time seeing this prefix - store it
147+
OPERATION_CLASS_MAP[prefix] = (class_name, path_parts_count, path_parts)
148+
else:
149+
# Handle collision (different ViewSet with same prefix)
150+
prefix = _handle_prefix_collision(prefix, class_name, path_parts_count, path_parts, OPERATION_CLASS_MAP)
151+
152+
# Register ViewSet attributes for AI description generation
153+
_register_skip_ai_description(view_class, class_name, prefix)
154+
_register_resource_purpose(view_class, class_name, prefix)
155+
156+
except Exception as e:
157+
logger.debug(f"Error checking view metadata for {path} {method}: {e}")
158+
continue
159+
160+
return endpoints

ansible_base/authentication/views/authenticator.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ class AuthenticatorViewSet(AnsibleBaseDjangoAppApiView, ModelViewSet):
1616
API endpoint that allows authenticators to be viewed or edited.
1717
"""
1818

19+
resource_purpose = "authentication types for configuring user login methods (LDAP, SAML, OAuth)"
20+
1921
queryset = Authenticator.objects.all()
2022
serializer_class = AuthenticatorSerializer
2123
allow_service_token = True

ansible_base/authentication/views/authenticator_map.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ class AuthenticatorMapViewSet(AnsibleBaseDjangoAppApiView, ModelViewSet):
1010
API endpoint that allows authenticator maps to be viewed or edited.
1111
"""
1212

13+
resource_purpose = "conditional rule for granting access, membership or roles based on user attributes or groups"
14+
1315
queryset = AuthenticatorMap.objects.all().order_by("id")
1416
serializer_class = AuthenticatorMapSerializer
1517
allow_service_token = True

ansible_base/feature_flags/views.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ class FeatureFlagsStateListView(AnsibleBaseView):
1313
A view class for displaying feature flags
1414
"""
1515

16+
resource_purpose = "feature flag configurations for controlling platform capabilities"
17+
1618
serializer_class = FeatureFlagSerializer
1719
filter_backends = []
1820
name = _('Feature Flags')

ansible_base/lib/dynamic_config/settings_logic.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@
1717
'VERSION': 'v1',
1818
'SCHEMA_PATH_PREFIX': '/api/v1/',
1919
'COMPONENT_NO_READ_ONLY_REQUIRED': True,
20+
'PREPROCESSING_HOOKS': [
21+
'ansible_base.api_documentation.preprocessing_hooks.collect_ai_description_metadata',
22+
],
23+
'POSTPROCESSING_HOOKS': [
24+
'ansible_base.api_documentation.postprocessing_hooks.add_x_ai_description',
25+
],
2026
}
2127
DEFAULT_ANSIBLE_BASE_AUTH = "ansible_base.authentication.backend.AnsibleBaseAuth"
2228
DEFAULT_ANSIBLE_BASE_JWT_CONSUMER_APP_NAME = "ansible_base.jwt_consumer"
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""
2+
Utility functions for parsing API paths and operation IDs.
3+
4+
This module provides shared utilities used by both preprocessing and
5+
postprocessing hooks to ensure consistent path parsing logic.
6+
"""
7+
8+
import re
9+
from typing import List
10+
11+
12+
def parse_path_segments(path: str) -> List[str]:
13+
"""
14+
Parse URL path into segments, excluding placeholders.
15+
Handles both Django (<pk>) and OpenAPI ({id}) formats.
16+
Examples:
17+
>>> parse_path_segments('/api/v1/teams/')
18+
['api', 'v1', 'teams']
19+
>>> parse_path_segments('/api/v1/teams/{id}/users/')
20+
['api', 'v1', 'teams', 'users']
21+
"""
22+
return [p for p in path.split('/') if p and not p.startswith(('<', '{'))]
23+
24+
25+
def extract_operation_prefix(operation_id: str) -> str:
26+
"""
27+
Extract resource prefix from operation_id (everything before final action).
28+
Handles special case of 'partial_update'.
29+
"""
30+
if '_partial_update' in operation_id:
31+
return operation_id.rsplit('_partial_update', 1)[0]
32+
elif '_' in operation_id:
33+
return operation_id.rsplit('_', 1)[0]
34+
else:
35+
return operation_id
36+
37+
38+
def extract_operation_action(operation_id: str) -> str:
39+
"""Extract action from operation_id. Handles 'partial_update' special case."""
40+
if '_partial_update' in operation_id:
41+
return 'partial_update'
42+
elif '_' in operation_id:
43+
return operation_id.split('_')[-1]
44+
else:
45+
return operation_id
46+
47+
48+
def filter_api_prefixes(path_segments: List[str]) -> List[str]:
49+
"""
50+
Remove API prefixes (everything up to and including version string like 'v1').
51+
Uses last version pattern found if multiple exist.
52+
"""
53+
version_index = None
54+
for i, segment in enumerate(path_segments):
55+
if re.match(r'^v\d+$', segment):
56+
version_index = i
57+
58+
if version_index is not None:
59+
return path_segments[version_index + 1 :]
60+
61+
return path_segments

ansible_base/lib/utils/schema.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""
2+
This module provides optional decorators that gracefully handle missing dependencies.
3+
"""
4+
5+
6+
def extend_schema_if_available(**kwargs):
7+
"""
8+
Decorator that wraps drf_spectacular's extend_schema if available.
9+
10+
If drf_spectacular is not installed, this decorator becomes a no-op,
11+
allowing code to use extend_schema without requiring drf_spectacular
12+
as a hard dependency.
13+
14+
Args:
15+
**kwargs: Arguments to pass to extend_schema if available
16+
17+
Returns:
18+
Decorated function with schema extensions if drf_spectacular is available,
19+
otherwise returns the original function unchanged
20+
"""
21+
try:
22+
from drf_spectacular.utils import extend_schema
23+
24+
return extend_schema(**kwargs)
25+
except ImportError:
26+
# If drf_spectacular is not available, return a no-op decorator
27+
return lambda func: func

ansible_base/rbac/api/views.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ class RoleDefinitionViewSet(AnsibleBaseDjangoAppApiView, ModelViewSet):
116116
but can be assigned to users.
117117
"""
118118

119+
resource_purpose = "RBAC roles defining permissions that can be managed and assigned to users and teams"
120+
119121
queryset = RoleDefinition.objects.prefetch_related('created_by', 'modified_by', 'content_type', 'permissions', 'resource')
120122
serializer_class = RoleDefinitionSerializer
121123
permission_classes = try_add_oauth2_scope_permission([RoleDefinitionPermissions])
@@ -224,6 +226,8 @@ class RoleTeamAssignmentViewSet(BaseAssignmentViewSet):
224226
remove those permissions.
225227
"""
226228

229+
resource_purpose = "RBAC role grants assigning permissions to teams for specific resources"
230+
227231
serializer_class = RoleTeamAssignmentSerializer
228232
prefetch_related = ('team__resource',)
229233
filter_backends = BaseAssignmentViewSet.filter_backends + [
@@ -244,6 +248,8 @@ class RoleUserAssignmentViewSet(BaseAssignmentViewSet):
244248
remove those permissions.
245249
"""
246250

251+
resource_purpose = "RBAC role grants assigning permissions to users for specific resources"
252+
247253
serializer_class = RoleUserAssignmentSerializer
248254
prefetch_related = ('user__resource',)
249255
filter_backends = BaseAssignmentViewSet.filter_backends + [

0 commit comments

Comments
 (0)