DEV Community

Ori Roza
Ori Roza

Posted on

Creating API Actions in Django Rest Framework

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.

Photo by Faisal on Unsplash

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:

  1. Creating a Python Package: This involves building a Python package with Django apps and later developing a separate web service utilizing this package.

  2. 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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

In order to do that, we need to:

  1. Create our own decorator.
  2. Inject our function arguments into the method.
  3. 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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Let’s try it out!

>>> res = api.dummy(request=None, pk=1)
ret = func(self, request, **kw)
# (output)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

And when trying again:

>>> res = api.dummy(request=None, pk=1)
>>> {'id': 1, 'dummy_int': 1}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Inject serializer_class to the ViewSet kwargs:
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)
Enter fullscreen mode Exit fullscreen mode
  1. Override get_serializer method of GenericViewSet:
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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)