Routing in Django from the second version of the framework received a wonderful tool - converters. With the addition of this tool, it became possible not only to flexibly configure the parameters in the routes, but also to separate the areas of responsibility of the components.
My name is Alexander Ivanov, I am a mentor at Yandex.Practicum at the back -end development faculty and a lead developer at the Computer Modeling Laboratory. In this article, I'll walk you through Django's route converters and show you the benefits of using them. The first thing to start with is the limits of applicability:
- Django version 2.0+;
- registration of routes should be done with
django.urls.path
.
So, when a request arrives at the Django server, it first goes through the middleware chain, and then the URLResolver ( algorithm ) is turned on . The task of the latter is to find a suitable one in the list of registered routes.
For a substantive analysis, I propose to consider the following situation: there are several endpoints that should generate different reports for a certain date. Let's assume the endpoints look like this:
users/21/reports/2021-01-31/
teams/4/reports/2021-01-31/
What would the routes in
urls.py
? For example, like this:
path('users/<id>/reports/<date>/', user_report, name='user_report'),
path('teams/<id>/reports/<date>/', team_report, name='team_report'),
Each item in
< >
is a request parameter and will be passed to the handler.
Important: the name of the parameter when registering the route and the name of the parameter in the handler must match.
Then each handler would have something like this (pay attention to type annotations):
def user_report(request, id: str, date: str):
try:
id = int(id)
date = datetime.strptime(date, '%Y-%m-%d')
except ValueError:
raise Http404()
# ...
But this is not a royal business - to copy-paste such a block of code for each handler. It is reasonable to move this code into an auxiliary function:
def validate_params(id: str, date: str) -> (int, datetime):
try:
id = int(id)
date = datetime.strptime(date, '%Y-%m-%d')
except ValueError:
raise Http404('Not found')
return id, date
And in each handler then there will be a simple call to this helper function:
def user_report(request, id: str, date: str):
id, date = validate_params(id, date)
# ...
In general, this is already digestible. The helper function will either return the correct parameters of the required types, or abort the handler. Everything seems to be fine.
But in fact, here's what I did: I shifted some of the responsibility for deciding whether this handler should run for this route or not, from the URLResolver to the handler itself. It turns out that URLResolver did its job poorly, and my handlers not only have to do useful work, but also decide if they should do it at all. This is a clear violation of the SOLID principle of sole responsibility . This will not work. We need to improve.
Standard converters
Django provides standard route converters . It is a mechanism for determining whether a portion of the route is appropriate or not by the URLResolver itself. A nice bonus: the converter can change the type of the parameter, which means that the type we need can immediately come to the handler, and not the string.
Converters are specified before the parameter name in the route, separated by a colon. In fact, all parameters have a converter, if it is not specified explicitly, then the converter is used by default
str
.
Beware: some converters look like types in Python, so it might seem like they are normal casts, but they are not - for example, there are no standard convertersfloat
orbool
. Later I will show you what a converter is.
After looking at the standard converters, it becomes obvious what to
id
use the converter for
int
:
path('users/<int:id>/reports/<date>/', user_report, name='user_report'),
path('teams/<int:id>/reports/<date>/', team_report, name='team_report'),
But what about the date? There is no standard converter for it.
You can, of course, dodge and do this:
'users/<int:id>/reports/<int:year>-<int:month>-<int:day>/'
Indeed, some of the problems were eliminated, because now it is guaranteed that the date will be displayed in three numbers separated by hyphens. However, you still have to handle problem cases in the handler if the client sends an incorrect date, for example 2021-02-29 or 100-100-100 in general. This means that this option is not suitable.
We create our own converter
Django, in addition to standard converters, provides the ability to create your own converter and describe the conversion rules as you like.
To do this, you need to take two steps:
- Describe the class of the converter.
- Register the converter.
A converter class is a class with a certain set of attributes and methods described in the documentation (in my opinion, it is somewhat strange that the developers did not make a base abstract class). The requirements themselves:
- There must be an attribute
regex
describing the regular expression to quickly find the required subsequence. I'll show you how it is used later. - Implement a method
def to_python(self, value: str)
for converting from a string (after all, the transmitted route is always a string) into a python object, which will eventually be passed to the handler. - Implement a method
def to_url(self, value) -> str
to convert back from a python object to a string (used when invokingdjango.urls.reverse
or taggingurl
).
The class for converting the date will look like this:
class DateConverter:
regex = r'[0-9]{4}-[0-9]{2}-[0-9]{2}'
def to_python(self, value: str) -> datetime:
return datetime.strptime(value, '%Y-%m-%d')
def to_url(self, value: datetime) -> str:
return value.strftime('%Y-%m-%d')
I am against duplication, so I will put the date format in an attribute - itโs easier to maintain the converter if I suddenly want (or need) to change the date format:
class DateConverter:
regex = r'[0-9]{4}-[0-9]{2}-[0-9]{2}'
format = '%Y-%m-%d'
def to_python(self, value: str) -> datetime:
return datetime.strptime(value, self.format)
def to_url(self, value: datetime) -> str:
return value.strftime(self.format)
The class is described, so it's time to register it as a converter. This is done very simply: in the function
register_converter
you need to specify the described class and the name of the converter in order to use it in routes:
from django.urls import register_converter
register_converter(DateConverter, 'date')
Now you can describe the routes in
urls.py
(I deliberately changed the name of the parameter to
dt
so as not to confuse the entry
date:date
):
path('users/<int:id>/reports/<date:dt>/', user_report, name='user_report'),
path('teams/<int:id>/reports/<date:dt>/', team_report, name='team_report'),
Now it is guaranteed that the handlers will be called only if the converter works correctly, which means that the parameters of the required type will come to the handler:
def user_report(request, id: int, dt: datetime):
#
#
Looks awesome! And this is so, you can check.
Under the hood
If you look closely, an interesting question arises: nowhere is there a check that the date is correct. Yes, there is a regular season, but an incorrect date is also suitable for it, for example 2021-01-77, which means there
to_python
must be an error in it. Why does it work?
About this I say: "Play by the rules of the framework, and it will play for you." Frameworks take on a number of common tasks. If the framework cannot do something, then a good framework provides an opportunity to expand its functionality. Therefore, you should not engage in bicycle building, it is better to see how the framework offers to improve its own capabilities.
Django has a routing subsystem with the ability to add converters that takes care of the method call
to_python
and catching errors
ValueError
.
Here is the code from the Django routing subsystem without changes (version 3.1, file
django/urls/resolvers.py
, class
RoutePattern
, method
match
):
match = self.regex.search(path)
if match:
# RoutePattern doesn't allow non-named groups so args are ignored.
kwargs = match.groupdict()
for key, value in kwargs.items():
converter = self.converters[key]
try:
kwargs[key] = converter.to_python(value)
except ValueError:
return None
return path[match.end():], (), kwargs
return None
The first step is to search for matches in the route transmitted from the client using a regular expression. The one
regex
that is defined in the converter class is involved in the formation
self.regex
, namely, it is substituted instead of the expression in angle brackets
<>
in the route.
For example,
turn intousers/<int:id>/reports/<date:dt>/
^users/(?P<id>[0-9]+)/reports/(?P<dt>[0-9]{4}-[0-9]{2}-[0-9]{2})/$
In the end, just the same regular from
DateConverter
.
This is a quick search, superficial. If no match is found, then the route is definitely not suitable, but if found, then it is a potentially suitable route. This means that you need to start the next stage of verification.
Each parameter has its own converter, which is used to call the method
to_python
. And here's the most interesting thing: the call is
to_python
wrapped in
try/except
, and type errors are caught
ValueError
. That is why the converter works even in the case of an incorrect date: an error falls
ValueError
, and this is considered so that the route does not fit.
So in the case of
DateConverter
, we can say, lucky: in case of an incorrect date, an error of the required type falls. If there is an error of another type, then Django will return a 500 response.
Don't stop
It seems that everything is fine, the converters are working, the necessary types immediately come to the handlers ... Or not right away?
path('users/<int:id>/reports/<date:dt>/', user_report, name='user_report'),
In the handler for generating a report, you probably need it
User
, and not it
id
(although this may be the case). In my hypothetical situation, just an object is needed to create a report
User
. What then turns out, again twenty-five?
def user_report(request, id: int, dt: datetime):
user = get_object_or_404(User, id=id)
# ...
Shifting responsibilities to the handler again.
But now it is clear what to do with it: write your own converter! It will make sure the object exists
User
and will pass it to the handler.
class UserConverter:
regex = r'[0-9]+'
def to_python(self, value: str) -> User:
try:
return User.objects.get(id=value)
except User.DoesNotExist:
raise ValueError('not exists') # ValueError
def to_url(self, value: User) -> str:
return str(value.id)
After describing the class, I register it:
register_converter(UserConverter, 'user')
Finally, I describe the route:
path('users/<user:u>/reports/<date:dt>/', user_report, name='user_report'),
That's better:
def user_report(request, u: User, dt: datetime):
# ...
Converters for models can be used often, so it is convenient to make the base class of such a converter (at the same time, I added a check for the existence of all attributes):
class ModelConverter:
regex: str = None
queryset: QuerySet = None
model_field: str = None
def __init__(self):
if None in (self.regex, self.queryset, self.model_field):
raise AttributeError('ModelConverter attributes are not set')
def to_python(self, value: str) -> models.Model:
try:
return self.queryset.get(**{self.model_field: value})
except ObjectDoesNotExist:
raise ValueError('not exists')
def to_url(self, value) -> str:
return str(getattr(value, self.model_field))
Then the description of the new converter to the model will be reduced to a declarative description:
class UserConverter(ModelConverter):
regex = r'[0-9]+'
queryset = User.objects.all()
model_field = 'id'
Outcome
Route converters are a powerful mechanism that helps you make your code cleaner. But this mechanism appeared only in the second version of Django - before that we had to do without it. This is where auxiliary functions of the type
get_object_or_404
came from; without this mechanism, cool libraries like DRF are made.
But this does not mean that converters should not be used at all. This means that (yet) it will not be possible to use them everywhere. But where possible, I urge you not to neglect them.
I will leave one caveat: here it is important not to overdo it and not drag the blanket in the other direction - you do not need to take the business logic into the converter. It is necessary to answer the question: if such a route is in principle impossible, then this is the converter's area of โโresponsibility; if such a route is possible, but under certain circumstances it is not processed, then this is already the responsibility of the handler, serializer or someone else, but definitely not the converter.
PS In practice, I have made and used only a converter for dates, just the one shown in the article, since I almost always use DRF or GraphQL. Tell us if you use route converters and, if so, which ones?