Skip to content

Commit e72923b

Browse files
authored
Add IntOrUUIDConverter URL converter (#801)
Ref: https://docs.djangoproject.com/en/4.2/topics/http/urls/#registering-custom-path-converters ## Description This PR adds a new `IntOrUUIDConverter` URL converter that can handle both integer and UUID values in Django URL patterns. This converter provides a flexible way to accept either integers or UUIDs in a single URL parameter, which is useful for APIs that need to support both legacy integer IDs and newer UUID-based identifiers. - **What is being changed?** Adding a new URL converter class `IntOrUUIDConverter` in `ansible_base.lib.utils.converters` - **Why is this change needed?** To support URL patterns that can accept either integer or UUID parameters - **How does this change address the issue?** The converter uses regex matching and type conversion to handle both data types seamlessly ## Type of Change - [x] New feature (non-breaking change which adds functionality) ## Self-Review Checklist - [x] I have performed a self-review of my code - [x] I have added relevant comments to complex code sections - [x] I have updated documentation where needed - [x] I have considered the security impact of these changes - [x] I have considered performance implications - [x] I have thought about error handling and edge cases - [x] I have tested the changes in my local environment ## Testing Instructions ### Prerequisites - Django development environment set up - pytest installed ### Steps to Test 1. Import the converter: `from ansible_base.lib.utils.converters import IntOrUUIDConverter` 2. Test integer conversion: `converter.to_python("123")` should return `int(123)` 3. Test UUID conversion: `converter.to_python("550e8400-e29b-41d4-a716-446655440000")` should return a `UUID` object 4. Test invalid input: `converter.to_python("invalid")` should raise `ValueError` 5. Run the test suite: `pytest test_app/tests/lib/utils/test_converters.py` ### Expected Results - Integer strings are converted to Python `int` objects - UUID strings are converted to Python `UUID` objects - Invalid inputs raise `ValueError` with descriptive message - The `to_url()` method converts both types back to string representation - All tests in the comprehensive test suite pass ## Additional Context ### Implementation Details - Uses a regex pattern that matches both integers (`[0-9]+`) and standard UUID format - Prioritizes integer conversion (checks `isdigit()` first) for performance - Handles edge cases like uppercase UUIDs, very large integers, and invalid formats - Includes comprehensive test coverage (169 lines of tests for 23 lines of code) ### Required Actions - [ ] Requires documentation updates - [ ] Requires downstream repository changes - [ ] Requires infrastructure/deployment changes - [ ] Requires coordination with other teams - [ ] Blocked by PR/MR: #XXX
1 parent 5abe950 commit e72923b

File tree

3 files changed

+184
-5
lines changed

3 files changed

+184
-5
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""
2+
Reference:
3+
- https://docs.djangoproject.com/en/4.2/topics/http/urls/#registering-custom-path-converters
4+
- https://github.com/django/django/blob/fda3c1712a1eb7b20dfc91e6c9abae32bd64d081/django/urls/converters.py
5+
"""
6+
7+
import uuid
8+
9+
10+
class IntOrUUIDConverter:
11+
regex = "([0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"
12+
13+
def to_python(self, value):
14+
# Try int first (simpler check)
15+
if value.isdigit():
16+
return int(value)
17+
# Otherwise try UUID
18+
try:
19+
return uuid.UUID(value)
20+
except ValueError:
21+
raise ValueError(f"'{value}' is not a valid integer or UUID")
22+
23+
def to_url(self, value):
24+
return str(value)

ansible_base/rbac/urls.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
from django.urls import include, path
1+
from django.urls import include, path, register_converter
22

3+
from ansible_base.lib.utils.converters import IntOrUUIDConverter
34
from ansible_base.rbac.api.router import router
45
from ansible_base.rbac.api.views import RoleMetadataView, TeamAccessAssignmentViewSet, TeamAccessViewSet, UserAccessAssignmentViewSet, UserAccessViewSet
56
from ansible_base.rbac.apps import AnsibleRBACConfig
@@ -11,18 +12,19 @@
1112
user_access_assignment_view = UserAccessAssignmentViewSet.as_view({'get': 'list'})
1213
team_access_assignment_view = TeamAccessAssignmentViewSet.as_view({'get': 'list'})
1314

15+
register_converter(IntOrUUIDConverter, "int_or_uuid")
1416
api_version_urls = [
1517
path('', include(router.urls)),
1618
path(r'role_metadata/', RoleMetadataView.as_view(), name="role-metadata"),
17-
path('role_user_access/<str:model_name>/<int:pk>/', user_access_view, name="role-user-access"),
18-
path('role_team_access/<str:model_name>/<int:pk>/', team_access_view, name="role-team-access"),
19+
path('role_user_access/<str:model_name>/<int_or_uuid:pk>/', user_access_view, name="role-user-access"),
20+
path('role_team_access/<str:model_name>/<int_or_uuid:pk>/', team_access_view, name="role-team-access"),
1921
path(
20-
'role_user_access/<str:model_name>/<int:pk>/<str:actor_pk>/',
22+
'role_user_access/<str:model_name>/<int_or_uuid:pk>/<str:actor_pk>/',
2123
user_access_assignment_view,
2224
name='role-user-access-assignments',
2325
),
2426
path(
25-
'role_team_access/<str:model_name>/<int:pk>/<str:actor_pk>/',
27+
'role_team_access/<str:model_name>/<int_or_uuid:pk>/<str:actor_pk>/',
2628
team_access_assignment_view,
2729
name='role-team-access-assignments',
2830
),
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import uuid
2+
3+
import pytest
4+
5+
from ansible_base.lib.utils.converters import IntOrUUIDConverter
6+
7+
8+
class TestIntOrUUIDConverter:
9+
"""Test cases for the IntOrUUIDConverter class."""
10+
11+
def test_regex_pattern(self):
12+
"""Test that the regex pattern is correctly defined."""
13+
converter = IntOrUUIDConverter()
14+
assert converter.regex == "([0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"
15+
16+
@pytest.mark.parametrize(
17+
"value,expected_type,expected_value",
18+
[
19+
# Integer cases
20+
("1", int, 1),
21+
("123", int, 123),
22+
("999999", int, 999999),
23+
("0", int, 0),
24+
# UUID cases
25+
("550e8400-e29b-41d4-a716-446655440000", uuid.UUID, uuid.UUID("550e8400-e29b-41d4-a716-446655440000")),
26+
("6ba7b810-9dad-11d1-80b4-00c04fd430c8", uuid.UUID, uuid.UUID("6ba7b810-9dad-11d1-80b4-00c04fd430c8")),
27+
("6ba7b811-9dad-11d1-80b4-00c04fd430c8", uuid.UUID, uuid.UUID("6ba7b811-9dad-11d1-80b4-00c04fd430c8")),
28+
# UUID without dashes (valid format for uuid.UUID)
29+
("550e8400e29b41d4a716446655440000", uuid.UUID, uuid.UUID("550e8400e29b41d4a716446655440000")),
30+
],
31+
)
32+
def test_to_python_valid_values(self, value, expected_type, expected_value):
33+
"""Test that valid integers and UUIDs are correctly converted."""
34+
converter = IntOrUUIDConverter()
35+
result = converter.to_python(value)
36+
assert isinstance(result, expected_type)
37+
assert result == expected_value
38+
39+
@pytest.mark.parametrize(
40+
"invalid_value",
41+
[
42+
# Invalid integers (negative or with non-digits)
43+
"-1",
44+
"12.34",
45+
"1.0",
46+
"12a",
47+
"a12",
48+
# Invalid UUIDs
49+
"not-a-uuid",
50+
"550e8400-e29b-41d4-a716", # Too short
51+
"550e8400-e29b-41d4-a716-446655440000-extra", # Too long
52+
"550e8400-e29b-41d4-a716-44665544000z", # Invalid character
53+
# Note: "550e8400e29b41d4a716446655440000" (missing dashes) is actually valid for uuid.UUID()
54+
# Empty/None cases
55+
"",
56+
" ",
57+
# Mixed invalid cases
58+
"name",
59+
"name surname",
60+
"123-456",
61+
"uuid-like-string",
62+
"12345-67890-abcde",
63+
],
64+
)
65+
def test_to_python_invalid_values(self, invalid_value):
66+
"""Test that invalid values raise ValueError."""
67+
converter = IntOrUUIDConverter()
68+
with pytest.raises(ValueError) as exc_info:
69+
converter.to_python(invalid_value)
70+
assert f"'{invalid_value}' is not a valid integer or UUID" in str(exc_info.value)
71+
72+
def test_to_python_large_integer(self):
73+
"""Test that very large integers are handled correctly."""
74+
converter = IntOrUUIDConverter()
75+
large_int_str = "999999999999999999999"
76+
result = converter.to_python(large_int_str)
77+
assert isinstance(result, int)
78+
assert result == int(large_int_str)
79+
80+
@pytest.mark.parametrize(
81+
"input_value,expected_output",
82+
[
83+
# Integer inputs
84+
(1, "1"),
85+
(123, "123"),
86+
(0, "0"),
87+
(999999, "999999"),
88+
# UUID inputs
89+
(uuid.UUID("550e8400-e29b-41d4-a716-446655440000"), "550e8400-e29b-41d4-a716-446655440000"),
90+
(uuid.UUID("6ba7b810-9dad-11d1-80b4-00c04fd430c8"), "6ba7b810-9dad-11d1-80b4-00c04fd430c8"),
91+
# String inputs (should work with str() call)
92+
("test", "test"),
93+
("123", "123"),
94+
],
95+
)
96+
def test_to_url(self, input_value, expected_output):
97+
"""Test that various types are correctly converted to URL strings."""
98+
converter = IntOrUUIDConverter()
99+
result = converter.to_url(input_value)
100+
assert result == expected_output
101+
assert isinstance(result, str)
102+
103+
def test_to_python_int_priority(self):
104+
"""Test that numeric strings are treated as integers, not UUIDs."""
105+
converter = IntOrUUIDConverter()
106+
# This string could theoretically be interpreted as a UUID segment,
107+
# but should be treated as an integer since it's all digits
108+
result = converter.to_python("12345678")
109+
assert isinstance(result, int)
110+
assert result == 12345678
111+
112+
def test_uuid_case_handling(self):
113+
"""Test UUID case handling - uppercase UUIDs are actually valid in uuid.UUID()."""
114+
converter = IntOrUUIDConverter()
115+
116+
# Test with uppercase UUID - uuid.UUID() accepts uppercase
117+
uppercase_uuid = "550E8400-E29B-41D4-A716-446655440000"
118+
result = converter.to_python(uppercase_uuid)
119+
assert isinstance(result, uuid.UUID)
120+
# UUID string representation is always lowercase
121+
assert str(result) == "550e8400-e29b-41d4-a716-446655440000"
122+
123+
# Test with lowercase UUID (should work)
124+
lowercase_uuid = "550e8400-e29b-41d4-a716-446655440000"
125+
result = converter.to_python(lowercase_uuid)
126+
assert isinstance(result, uuid.UUID)
127+
assert str(result) == lowercase_uuid
128+
129+
def test_uuid_validation_error_handling(self):
130+
"""Test that UUID validation errors are properly caught and re-raised."""
131+
converter = IntOrUUIDConverter()
132+
133+
# Test with a string that's not digits but also not a valid UUID
134+
invalid_uuid = "this-is-not-a-uuid-at-all"
135+
with pytest.raises(ValueError) as exc_info:
136+
converter.to_python(invalid_uuid)
137+
assert f"'{invalid_uuid}' is not a valid integer or UUID" in str(exc_info.value)
138+
139+
def test_roundtrip_conversion(self):
140+
"""Test that to_python and to_url work correctly together."""
141+
converter = IntOrUUIDConverter()
142+
143+
# Test integer roundtrip
144+
int_str = "12345"
145+
int_val = converter.to_python(int_str)
146+
url_str = converter.to_url(int_val)
147+
assert url_str == int_str
148+
149+
# Test UUID roundtrip
150+
uuid_str = "550e8400-e29b-41d4-a716-446655440000"
151+
uuid_val = converter.to_python(uuid_str)
152+
url_str = converter.to_url(uuid_val)
153+
assert url_str == uuid_str

0 commit comments

Comments
 (0)