A few weeks back, my R&D manager asked me to create a Python package that would eventually evolve into a standalone web service. We use Django, a widely used web framework in Python, particularly known for its rest framework.
Django has an impressive track record, with over 70,000 stars on GitHub and being utilized by big names like Instagram, National Geographic, Mozilla, Spotify, and Pinterest.
Approaches
To achieve our goal, I had a couple of approaches in mind:
Creating a Python Package: This involves building a Python package with Django apps and later developing a separate web service utilizing this package.
Custom Action Decorator: Another option was to craft the action decorator, allowing API functions to be explicitly called from the ViewSet. I opted for the second choice to avoid managing two separate projects, and surprisingly, it brought additional benefits.
Action Decorator
Understanding the Action Decorator
Letโs start with a quick overview of the action decorator.
According to the official documentation:
"If you have ad-hoc methods that should be routable, you can mark them as such with the @action decorator."
For example:
class DummyAPIViewSet(ModelViewSet):
# ... (previous code)
@action(detail=True, methods=["get"], serializer_class=DummySerializer)
def dummy(self, request, **kwargs):
serializer = self.get_serializer(instance=self.get_object())
return Response(data=serializer.data, status=status.HTTP_200_OK)
@action(detail=False, methods=["post"], serializer_class=GetDummyByIntSerializer)
def by_dummy_int(self, request, **kwargs):
# ... (rest of the code)
This decorator is very straightforward. It defines a route in a ViewSet and usually requires the following arguments:
-
detail
: whether the call expects to get PK or not. -
methods
: which methods the call supports. -
serializer_class
: the serializer that handles the specific view.
Custom Action Decorator
I chose to customize this decorator because it is frequently used and easy to replace with a new one.
def action_api(methods=None, detail=None, url_path=None, url_name=None, **kwargs):
def decorator(func):
return action(
methods=methods,
detail=detail,
url_path=url_path,
url_name=url_name,
serializer_class=serializer_class,
**kwargs
)(func)
Now, we want to call by_dummy_int
explicitly from the ViewSet:
>>> api = DummyAPIViewSet()
>>> api.dummy(request=None, pk=1)
>>> {'id': 1, 'dummy_int': 1}
In order to do that, we need to:
- Create our own decorator.
- Inject our function arguments into the method.
- Inject our serializer into the view.
Create Our Own Decorator
In the action implementation, we see that the following decorator gets the following arguments:
action(methods=None, detail=None, url_path=None, url_name=None, **kwargs)
So we will create the same:
def action_api(methods=None, detail=None, url_path=None, url_name=None, **kwargs):
def decorator(func):
return action(
methods=methods,
detail=detail,
url_path=url_path,
url_name=url_name,
serializer_class=serializer_class,
**kwargs
)(func)
Now we want to distinguish between an API call and a REST call. We can achieve that by indicating whether the func
argument request
is None
or not:
def action_api(methods=None, detail=None, url_path=None, url_name=None, **kwargs):
def decorator(func):
def route_call(self, request, **params):
if request:
return func(self, request, **params)
else:
pass
return action(
methods=methods,
detail=detail,
url_path=url_path,
url_name=url_name,
serializer_class=serializer_class,
**kwargs
)(route_call)
The route_call
mocks func
method with the same arguments:
self
request
kwargs
If you run this app as a server, it will behave the same.
Inject Function Arguments into the Request
As mentioned before, a ViewSet method has 3 arguments:
self
request
kwargs
self
we get from the instance, so we need to mock a request object. We want to pass the function arguments into the request data
/query_params
.
According to the DRF code, the Request object has two relevant properties:
-
data
: POST payload -
query_params
: GET query parameters
class CustomRequest:
"""
Mock for a custom request
"""
def __init__(self, data, query_params):
self.data = data
self.query_params = query_params
def action_api(methods=None, detail=None, url_path=None, url_name=None, **kwargs):
def decorator(func):
def route_call(self, request, **params):
if request:
return func(self, request, **params)
else:
# injecting our custom request
request = CustomRequest(params, params)
return func(self, request, **params)
return action(
methods=methods,
detail=detail,
url_path=url_path,
url_name=url_name,
serializer_class=serializer_class,
**kwargs
)(route_call)
Letโs try it out!
>>> res = api.dummy(request=None, pk=1)
ret = func(self, request, **kw)
# (output)
It means that the ViewSet also has a request attribute! Letโs add it:
class CustomRequest:
"""
Mock for a custom request
"""
def __init__(self, data, query_params):
self.data = data
self.query_params = query_params
def action_api(methods=None, detail=None, url_path=None, url_name=None, **kwargs):
def decorator(func):
def route_call(self, request, **params):
if request:
return func(self, request, **params)
else:
# injecting our custom request
request = CustomRequest(params, params)
self.request = request
return func(self, request, **params)
return action(
methods=methods,
detail=detail,
url_path=url_path,
url_name=url_name,
serializer_class=serializer_class,
**kwargs
)(route_call)
And when trying again:
>>> res = api.dummy(request=None, pk=1)
>>> {'id': 1, 'dummy_int': 1}
We made it! but itโs enough, we want each method to know the serializer_class
and stop using the general one.
Inject Our Serializer into the View
We want to inject each method with its corresponding serializer. To achieve it we need to do
two things that are connected:
- Inject
serializer_class
to the ViewSetkwargs
:
class CustomRequest:
"""
Mock for a custom request
"""
def __init__(self, data, query_params):
self.data = data
self.query_params = query_params
def action_api(methods=None, detail=None, url_path=None, url_name=None, **kwargs):
# popping `serializer_class` out
serializer_class = kwargs.pop("serializer_class")
def decorator(func):
def route_call(self, request, **params):
if request:
return func(self, request, **params)
else:
request = CustomRequest(params, params)
self.request = request
# add `serializer_class` to ViewSet `kwargs`
params.update({"serializer_class": serializer_class})
self.kwargs = params
return func(self, request, **params)
return action(
methods=methods,
detail=detail,
url_path=url_path,
url_name=url_name,
serializer_class=serializer_class,
**kwargs
)(route_call)
- Override
get_serializer
method ofGenericViewSet
:
class APIRestMixin(viewsets.GenericViewSet):
"""
creates our custom get_serializer in order to use our serializer injection
"""
def get_serializer(self, request_or_query_set=None, *args, **kwargs):
serializer_class = self.kwargs.get("serializer_class", self.serializer_class)
return serializer_class(*args, **kwargs)
Letโs connect everything together:
class CustomRequest:
"""
Mock for a custom request
"""
def __init__(self, data, query_params):
self.data = data
self.query_params = query_params
def action_api(methods=None, detail=None, url_path=None, url_name=None, **kwargs):
def decorator(func):
def route_call(self, request, **params):
if request:
return func(self, request, **params)
else:
# injecting our custom request
request = CustomRequest(params, params)
self.request = request
# add `serializer_class` to ViewSet `kwargs`
params.update({"serializer_class": serializer_class})
self.kwargs = params
return func(self, request, **params)
return action(
methods=methods,
detail=detail,
url_path=url_path,
url_name=url_name,
serializer_class=serializer_class,
**kwargs
)(route_call)
class APIRestMixin(viewsets.GenericViewSet):
"""
creates our custom get_serializer in order to use our serializer injection
"""
def get_serializer(self, request_or_query_set=None, *args, **kwargs):
serializer_class = self.kwargs.get("serializer_class", self.serializer_class)
return serializer_class(*args, **kwargs)
class DummyAPIViewSet(APIRestMixin, ModelViewSet):
queryset = DummyModel.objects.all()
serializer_class = DummySerializer
def get_serializer_context(self):
return {
"request": self.request,
"view": self,
"pk": self.kwargs.get("pk"),
}
@action_api(detail=True, methods=["get"], serializer_class=DummySerializer)
def dummy(self, request, **kwargs):
serializer = self.get_serializer(instance=self.get_object())
return Response(data=serializer.data, status=status.HTTP_200_OK)
@action_api(detail=False, methods=["post"], serializer_class=GetDummyByIntSerializer)
def by_dummy_int(self, request, **kwargs):
self.get_serializer(data=request.data).is_valid(raise_exception=True)
queryset = DummyModel.objects.filter(dummy_int=request.data["dummy_int"]).order_by("id")
serializer = self.get_serializer(queryset, many=True)
return Response(data=serializer.data, status=status.HTTP_200_OK)
Conclusion
In this article, we explored using the Django rest framework for API functions, delving into its internals, and adapting it to our needs. Leveraging rest frameworks offers various advantages such as:
- Clear separation between function signature and business logic
- Makes Django DB models accessible in other libraries/web services
- Arguments validation provided by serializers
- Internal pagination mechanism
- And many more!
I hope you found this article insightful and feel motivated to implement these practices in your projects! The full code and drf-api-action project are available here.
Donโt forget to give it a star if you find it helpful! ๐
Top comments (0)