Django: write a custom URL path converter to match given strings

Here’s a little tip based on some recent work. The project has a URL pattern where the first part of the URL matches the current role the user is viewing the site as. Let’s say the roles are “chef”, “gourmand”, and “foodie”—example URLs might look like this:
/chef/dashboard/
/gourmand/dashboard/
/foodie/dashboard/
/chef/dish/1/
/gourmand/dish/1/
/foodie/dish/1/
Most views can be accessed by all roles, with restrictions applied within the view code where appropriate.
To match the role parts of the URL, you could define each URL pattern individually:
from django.urls import path
from example import views
urlpatterns = [
path("chef/dashboard/", views.dashboard),
path("gourmand/dashboard/", views.dashboard),
path("foodie/dashboard/", views.dashboard),
path("chef/dish/<int:id>/", views.dish),
# ...
]
However, that gets tiresome quickly and doesn’t really scale to larger numbers of roles or views. Additionally, it slows down URL resolution, as Django must check each pattern in turn.
Some scalable alternatives would be:
Using Django’s
strpath converter, as in:path("<str:role>/dashboard/", views.dashboard)
However, this matches arbitrary strings, requiring extra validation in the view and care to avoid capturing other URLs.
Reaching for
re_path()to define the URL as a regular expression, like:re_path(r"^(chef|gourmand|foodie)/dashboard/$", views.dashboard)
But regular expression syntax is more complex, especially for matching parameters.
Rather than those approaches, you can use a custom path converter that matches only the valid role strings, per Django’s tutorial. Path converters are the things that the path() syntax uses, for example, <int:id> uses the int path converter.
The example below shows the definition and usage of a custom path converter to match only valid role names, as strings.
from django.urls import path, register_converter
class RoleConverter:
regex = "chef|gourmand|foodie"
def to_python(self, value):
return value
def to_url(self, value):
return value
register_converter(RoleConverter, "role")
urlpatterns = [
path("<role:role>/dashboard/", views.dashboard),
path("<role:role>/meal/<int:id>/", views.meal),
path("<role:role>/dish/<int:id>/", views.dish),
]
This version allows for a shorter URL list while still matching only valid roles.
Enum edition
The above is fine, though the stringly-typed nature may not be to your taste. If you use a type checker, you’ll need to use typing.Literal in your view to define the allowed values of role:
from typing import Literal
from django.http import HttpRequest, HttpResponse
def dashboard(
request: HttpRequest, role: Literal["chef", "gourmand", "foodie"]
) -> HttpResponse: ...
That’s a little bit duplicative of the converter.
It’s typically more convenient to use an enum to contain such lists of values:
from enum import StrEnum
class Role(StrEnum):
CHEF = "chef"
GOURMAND = "gourmand"
FOODIE = "foodie"
With such an enum defined, the converter class can use it as the source of valid values:
import re
from django.urls import path, register_converter
from example import views
from example.enums import Role
class RoleConverter:
regex = "|".join(re.escape(role.value) for role in Role)
def to_python(self, value: str) -> Role:
return Role(value)
def to_url(self, value):
if isinstance(value, Role):
return value.value
elif isinstance(value, str) and value in Role:
return value
raise ValueError(f"{value!r} is not a valid Role")
register_converter(RoleConverter, "role")
urlpatterns = [
path("<role:role>/dashboard/", views.dashboard),
path("<role:role>/dish/<int:id>/", views.dish),
]
This converter is a little bit more complex:
regexis now defined from the enum values.- The
to_python()method converts the string value to the enum type. - The
to_url()method converts the enum value back to a string, or allows matching strings.
However, it does give you all the ergonomics of an enum within your view code:
from django.http import HttpRequest, HttpResponse
from example.enums import Role
def dashboard(request: HttpRequest, role: Role) -> HttpResponse: ...
You can even use exhaustiveness checking to ensure that all enum values are handled in your code, which is a nice bonus.
Here are some tests for the enum-based converter:
from django.test import SimpleTestCase
from django.urls import NoReverseMatch, path, reverse
from example.enums import Role
# Import to force registration of RoleConverter
from example.urls import RoleConverter # noqa: F401
urlpatterns = [
path(
"<role:role>/",
lambda *args, **kwargs: None, # Dummy view
name="example",
)
]
example_path = urlpatterns[0]
class RoleConverterTests(SimpleTestCase):
def test_valid_roles(self):
result = example_path.resolve("chef/")
assert result.kwargs["role"] == Role.CHEF
result = example_path.resolve("gourmand/")
assert result.kwargs["role"] == Role.GOURMAND
result = example_path.resolve("foodie/")
assert result.kwargs["role"] == Role.FOODIE
def test_invalid_role(self):
result = example_path.resolve("unknown/")
assert result is None
def test_reverse_enum(self):
result = reverse("example", urlconf=__name__, kwargs={"role": Role.CHEF})
assert result == "/chef/"
def test_reverse_string(self):
result = reverse("example", urlconf=__name__, kwargs={"role": "chef"})
assert result == "/chef/"
def test_reverse_invalid(self):
with self.assertRaises(NoReverseMatch):
reverse("example", urlconf=__name__, kwargs={"role": "unknown"})
I think it’s quite neat that you can test converters in real paths using path() and reverse() like this.
😸😸😸 Check out my new book on using GitHub effectively, Boost Your GitHub DX! 😸😸😸
One summary email a week, no spam, I pinky promise.
Related posts:
- Django: split
ModelAdmin.get_queryset()by view - Django: iterate through all registered URL patterns
- Django: launch pdb when a given SQL query runs
Tags: django