mirror of
https://github.com/ansible/awx.git
synced 2026-02-05 03:24:50 -03:30
Fix OpenAPI schema enum values for CredentialType kind field (#16262)
The OpenAPI schema incorrectly showed all 12 credential type kinds as valid for POST/PUT/PATCH operations, when only 'cloud' and 'net' are allowed for custom credential types. This caused API clients and LLM agents to receive HTTP 400 errors when attempting to create credential types with invalid kind values. Add postprocessing hook to filter CredentialTypeRequest and PatchedCredentialTypeRequest schemas to only show 'cloud', 'net', and null as valid enum values, matching the existing validation logic. No API behavior changes - this is purely a documentation fix. Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
749735b941
commit
6a031158ce
@@ -9,6 +9,47 @@ from drf_spectacular.views import (
|
||||
)
|
||||
|
||||
|
||||
def filter_credential_type_schema(result, _generator, _request, _public):
|
||||
"""
|
||||
Postprocessing hook to filter CredentialType kind enum values.
|
||||
|
||||
For CredentialTypeRequest and PatchedCredentialTypeRequest schemas (POST/PUT/PATCH),
|
||||
filter the 'kind' enum to only show 'cloud' and 'net' values.
|
||||
|
||||
This ensures the OpenAPI schema accurately reflects that only 'cloud' and 'net'
|
||||
credential types can be created or modified via the API, matching the validation
|
||||
in CredentialTypeSerializer.validate().
|
||||
|
||||
Args:
|
||||
result: The OpenAPI schema dict to be modified
|
||||
_generator: Schema generator instance (required by drf-spectacular, unused)
|
||||
_request: Request object (required by drf-spectacular, unused)
|
||||
_public: Public schema flag (required by drf-spectacular, unused)
|
||||
|
||||
Returns:
|
||||
The modified OpenAPI schema dict
|
||||
"""
|
||||
schemas = result.get('components', {}).get('schemas', {})
|
||||
|
||||
# Filter CredentialTypeRequest (POST/PUT) - field is required
|
||||
if 'CredentialTypeRequest' in schemas:
|
||||
kind_prop = schemas['CredentialTypeRequest'].get('properties', {}).get('kind', {})
|
||||
if 'enum' in kind_prop:
|
||||
# Filter to only cloud and net (no None - field is required)
|
||||
kind_prop['enum'] = ['cloud', 'net']
|
||||
kind_prop['description'] = "* `cloud` - Cloud\\n* `net` - Network"
|
||||
|
||||
# Filter PatchedCredentialTypeRequest (PATCH) - field is optional
|
||||
if 'PatchedCredentialTypeRequest' in schemas:
|
||||
kind_prop = schemas['PatchedCredentialTypeRequest'].get('properties', {}).get('kind', {})
|
||||
if 'enum' in kind_prop:
|
||||
# Filter to only cloud and net (None allowed - field can be omitted in PATCH)
|
||||
kind_prop['enum'] = ['cloud', 'net', None]
|
||||
kind_prop['description'] = "* `cloud` - Cloud\\n* `net` - Network"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class CustomAutoSchema(AutoSchema):
|
||||
"""Custom AutoSchema to add swagger_topic to tags and handle deprecated endpoints."""
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import warnings
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
@@ -8,6 +9,7 @@ from awx.api.schema import (
|
||||
AuthenticatedSpectacularAPIView,
|
||||
AuthenticatedSpectacularSwaggerView,
|
||||
AuthenticatedSpectacularRedocView,
|
||||
filter_credential_type_schema,
|
||||
)
|
||||
|
||||
|
||||
@@ -271,3 +273,152 @@ class TestAuthenticatedSchemaViews:
|
||||
def test_authenticated_spectacular_redoc_view_requires_authentication(self):
|
||||
"""Test that AuthenticatedSpectacularRedocView requires authentication."""
|
||||
assert IsAuthenticated in AuthenticatedSpectacularRedocView.permission_classes
|
||||
|
||||
|
||||
class TestFilterCredentialTypeSchema:
|
||||
"""Unit tests for filter_credential_type_schema postprocessing hook."""
|
||||
|
||||
def test_filters_both_schemas_correctly(self):
|
||||
"""Test that both CredentialTypeRequest and PatchedCredentialTypeRequest schemas are filtered."""
|
||||
result = {
|
||||
'components': {
|
||||
'schemas': {
|
||||
'CredentialTypeRequest': {
|
||||
'properties': {
|
||||
'kind': {
|
||||
'enum': [
|
||||
'ssh',
|
||||
'vault',
|
||||
'net',
|
||||
'scm',
|
||||
'cloud',
|
||||
'registry',
|
||||
'token',
|
||||
'insights',
|
||||
'external',
|
||||
'kubernetes',
|
||||
'galaxy',
|
||||
'cryptography',
|
||||
None,
|
||||
],
|
||||
'type': 'string',
|
||||
}
|
||||
}
|
||||
},
|
||||
'PatchedCredentialTypeRequest': {
|
||||
'properties': {
|
||||
'kind': {
|
||||
'enum': [
|
||||
'ssh',
|
||||
'vault',
|
||||
'net',
|
||||
'scm',
|
||||
'cloud',
|
||||
'registry',
|
||||
'token',
|
||||
'insights',
|
||||
'external',
|
||||
'kubernetes',
|
||||
'galaxy',
|
||||
'cryptography',
|
||||
None,
|
||||
],
|
||||
'type': 'string',
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
returned = filter_credential_type_schema(result, None, None, None)
|
||||
|
||||
# POST/PUT schema: no None (required field)
|
||||
assert result['components']['schemas']['CredentialTypeRequest']['properties']['kind']['enum'] == ['cloud', 'net']
|
||||
assert result['components']['schemas']['CredentialTypeRequest']['properties']['kind']['description'] == "* `cloud` - Cloud\\n* `net` - Network"
|
||||
|
||||
# PATCH schema: includes None (optional field)
|
||||
assert result['components']['schemas']['PatchedCredentialTypeRequest']['properties']['kind']['enum'] == ['cloud', 'net', None]
|
||||
assert result['components']['schemas']['PatchedCredentialTypeRequest']['properties']['kind']['description'] == "* `cloud` - Cloud\\n* `net` - Network"
|
||||
|
||||
# Other properties should be preserved
|
||||
assert result['components']['schemas']['CredentialTypeRequest']['properties']['kind']['type'] == 'string'
|
||||
|
||||
# Function should return the result
|
||||
assert returned is result
|
||||
|
||||
def test_handles_empty_result(self):
|
||||
"""Test graceful handling when result dict is empty."""
|
||||
result = {}
|
||||
original = copy.deepcopy(result)
|
||||
|
||||
returned = filter_credential_type_schema(result, None, None, None)
|
||||
|
||||
assert result == original
|
||||
assert returned is result
|
||||
|
||||
def test_handles_missing_enum(self):
|
||||
"""Test that schemas without enum key are not modified."""
|
||||
result = {'components': {'schemas': {'CredentialTypeRequest': {'properties': {'kind': {'type': 'string', 'description': 'Some description'}}}}}}
|
||||
original = copy.deepcopy(result)
|
||||
|
||||
filter_credential_type_schema(result, None, None, None)
|
||||
|
||||
assert result == original
|
||||
|
||||
def test_filters_only_target_schemas(self):
|
||||
"""Test that only CredentialTypeRequest schemas are modified, not others."""
|
||||
result = {
|
||||
'components': {
|
||||
'schemas': {
|
||||
'CredentialTypeRequest': {'properties': {'kind': {'enum': ['ssh', 'cloud', 'net', None]}}},
|
||||
'OtherSchema': {'properties': {'kind': {'enum': ['option1', 'option2']}}},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
other_schema_before = copy.deepcopy(result['components']['schemas']['OtherSchema'])
|
||||
|
||||
filter_credential_type_schema(result, None, None, None)
|
||||
|
||||
# CredentialTypeRequest should be filtered (no None for required field)
|
||||
assert result['components']['schemas']['CredentialTypeRequest']['properties']['kind']['enum'] == ['cloud', 'net']
|
||||
|
||||
# OtherSchema should be unchanged
|
||||
assert result['components']['schemas']['OtherSchema'] == other_schema_before
|
||||
|
||||
def test_handles_only_one_schema_present(self):
|
||||
"""Test that function works when only one target schema is present."""
|
||||
result = {'components': {'schemas': {'CredentialTypeRequest': {'properties': {'kind': {'enum': ['ssh', 'cloud', 'net', None]}}}}}}
|
||||
|
||||
filter_credential_type_schema(result, None, None, None)
|
||||
|
||||
assert result['components']['schemas']['CredentialTypeRequest']['properties']['kind']['enum'] == ['cloud', 'net']
|
||||
|
||||
def test_handles_missing_properties(self):
|
||||
"""Test graceful handling when schema has no properties key."""
|
||||
result = {'components': {'schemas': {'CredentialTypeRequest': {}}}}
|
||||
original = copy.deepcopy(result)
|
||||
|
||||
filter_credential_type_schema(result, None, None, None)
|
||||
|
||||
assert result == original
|
||||
|
||||
def test_differentiates_required_vs_optional_fields(self):
|
||||
"""Test that CredentialTypeRequest excludes None but PatchedCredentialTypeRequest includes it."""
|
||||
result = {
|
||||
'components': {
|
||||
'schemas': {
|
||||
'CredentialTypeRequest': {'properties': {'kind': {'enum': ['ssh', 'vault', 'net', 'scm', 'cloud', 'registry', None]}}},
|
||||
'PatchedCredentialTypeRequest': {'properties': {'kind': {'enum': ['ssh', 'vault', 'net', 'scm', 'cloud', 'registry', None]}}},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filter_credential_type_schema(result, None, None, None)
|
||||
|
||||
# POST/PUT schema: no None (required field)
|
||||
assert result['components']['schemas']['CredentialTypeRequest']['properties']['kind']['enum'] == ['cloud', 'net']
|
||||
|
||||
# PATCH schema: includes None (optional field)
|
||||
assert result['components']['schemas']['PatchedCredentialTypeRequest']['properties']['kind']['enum'] == ['cloud', 'net', None]
|
||||
|
||||
@@ -1032,6 +1032,8 @@ SPECTACULAR_SETTINGS = {
|
||||
# Use our custom schema class that handles swagger_topic and deprecated views
|
||||
'DEFAULT_SCHEMA_CLASS': 'awx.api.schema.CustomAutoSchema',
|
||||
'COMPONENT_SPLIT_REQUEST': True,
|
||||
# Postprocessing hook to filter CredentialType enum values
|
||||
'POSTPROCESSING_HOOKS': ['awx.api.schema.filter_credential_type_schema'],
|
||||
'SWAGGER_UI_SETTINGS': {
|
||||
'deepLinking': True,
|
||||
'persistAuthorization': True,
|
||||
|
||||
Reference in New Issue
Block a user