loading...

Managing RESTful URLs in Django Rest Framework

enether profile image Stanislav Kozlovski ・5 min read

We've all been taught about RESTful API design. It does not take much to realize that these endpoints

POST /products/1/delete
POST /products/1/update
GET /products/1

are inferior to

DELETE /products/1
PUT /products/1
GET /products/1

You could also imagine that these multiple URLs per object stack up quickly and if we were to introduce more objects like /merchants/, /shops/ we'd quickly be managing a lot of URLs which can get confusing. Nobody wants to read a 100-line urls.py file.

But Django Rest Framework does not support mapping the same URL to different class-views based on the request method. How could we map this one URL with different methods in our urls.py file?

Let's first create the initial project with our bad URLs

Initial Project

We'll have a dead simple project which allows us to interact with product objects. We want to be able to update a product, get information about it and delete it.

models.py

from django.db import models
from rest_framework import serializers


class Product(models.Model):
    name = models.CharField(max_length=500)
    price = models.DecimalField(decimal_places=2, max_digits=5)
    stock = models.IntegerField()


class ProductSerializer(serializers.ModelSerializer):
    class Meta:
        model = Product
        fields = '__all__'

views.py

from rest_framework.generics import DestroyAPIView, UpdateAPIView, RetrieveAPIView

from restful_example.models import Product, ProductSerializer


class ProductDestroyView(DestroyAPIView):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer


class ProductUpdateView(UpdateAPIView):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer


class ProductDetailsView(RetrieveAPIView):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer

and our ugly urls.py

from django.conf.urls import url
from django.contrib import admin
from restful_example.views import ProductDestroyView, ProductUpdateView, ProductDetailsView

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    url(r'^products/(?P<pk>\d+)/delete$', ProductDestroyView.as_view()),
    url(r'^products/(?P<pk>\d+)/update$', ProductUpdateView.as_view()),
    url(r'^products/(?P<pk>\d+)$', ProductDetailsView.as_view()),
]

Wait, don't forget tests!

tests.py

from rest_framework.test import APITestCase

from restful_example.models import Product, ProductSerializer


class ProductTests(APITestCase):
    def test_can_get_product_details(self):
        product = Product.objects.create(name='Apple Watch', price=500, stock=3)
        response = self.client.get(f'/products/{product.id}')
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.data, ProductSerializer(instance=product).data)

    def test_can_delete_product(self):
        product = Product.objects.create(name='Apple Watch', price=500, stock=3)
        response = self.client.delete(f'/products/{product.id}/delete')
        self.assertEqual(response.status_code, 204)
        self.assertEqual(Product.objects.count(), 0)

    def test_can_update_product(self):
        product = Product.objects.create(name='Apple Watch', price=500, stock=3)
        response = self.client.patch(f'/products/{product.id}/update', data={'name': 'Samsung Watch'})
        product.refresh_from_db()
        self.assertEqual(response.status_code, 200)
        self.assertEqual(product.name, 'Samsung Watch')

Run our tests and you'd see that this works.

Becoming more RESTful

Okay, now it's time to fix our URLs into more sensible ones. As we said, we want all of these views to point to one exact URL and differ only by the method they allow.

The idea here is to have some sort of base view to which the request on the url gets sent to. This view will have the job to figure out which view should handle the request given its method and send it there.

class BaseManageView(APIView):
    """
    The base class for ManageViews
        A ManageView is a view which is used to dispatch the requests to the appropriate views
        This is done so that we can use one URL with different methods (GET, PUT, etc)
    """
    def dispatch(self, request, *args, **kwargs):
        if not hasattr(self, 'VIEWS_BY_METHOD'):
            raise Exception('VIEWS_BY_METHOD static dictionary variable must be defined on a ManageView class!')
        if request.method in self.VIEWS_BY_METHOD:
            return self.VIEWS_BY_METHOD[request.method]()(request, *args, **kwargs)

        return Response(status=405)

This simple class requires us to inherit it and define a class variable named VIEWS_BY_METHOD. A dictionary which will hold our method names and their appropriate handlers.
Using this base class, creating the ManageView class for our Product model is trivial:

class ProductManageView(BaseManageView):
    VIEWS_BY_METHOD = {
        'DELETE': ProductDestroyView.as_view,
        'GET': ProductDetailsView.as_view,
        'PUT': ProductUpdateView.as_view,
        'PATCH': ProductUpdateView.as_view
    }

It is worth mentioning here that any permission classes must be defined in the separate views and will not work if put in the ManageView

We need to quickly edit our urls.py's urlpatterns

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    url(r'^products/(?P<pk>\d+)$', ProductManageView.as_view()),  # this now points to the manage view
]

Let's test this new view as well
tests.py

class ProductManageViewTests(APITestCase):
    def test_method_pairing(self):
        self.assertEqual(len(ProductManageView.VIEWS_BY_METHOD.keys()), 4)  # we only support 4 methods
        self.assertEqual(ProductManageView.VIEWS_BY_METHOD['DELETE'], ProductDestroyView.as_view)
        self.assertEqual(ProductManageView.VIEWS_BY_METHOD['GET'], ProductDetailsView.as_view)
        self.assertEqual(ProductManageView.VIEWS_BY_METHOD['PUT'], ProductUpdateView.as_view)
        self.assertEqual(ProductManageView.VIEWS_BY_METHOD['PATCH'], ProductUpdateView.as_view)

    def test_non_supported_method_returns_405(self):
        product = Product.objects.create(name='Apple Watch', price=500, stock=3)
        response = self.client.post(f'/products/{product.id}')
        self.assertEqual(response.status_code, 405)

We change the previous tests' urls to use the new one and we can see that they pass

Creating test database for alias 'default'...
...
----------------------------------------------------------------------
Ran 3 tests in 0.037s

OK
Destroying test database for alias 'default'...

And voila, we have the same functionality but in one url!

Summary

What we did was create a main view which dispatches requests to the appropriate views given the request method. I believe that this is the right way to handle multiple methods per URL in DRF when you want to have different class-based views handling each method.

This, however, is not the optimal case with our dead simple example. For views which are somewhat to extremely generic (like ours, no custom logic inside) or function views, there are Routers and ViewSets

Bonus - Routers and ViewSets

Since DRF is awesome, they provide us with the ability to define all the basic operations on a model in just a few lines.
With a ViewSet class, we can define our create, read, update and destroy logic without writing any code.

views.py

from rest_framework import viewsets
class ProductViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer

and we need to define the URL in our urls.py using a Router class

from rest_framework.routers import DefaultRouter
router = DefaultRouter(trailing_slash=False)
router.register(r'products', restful_views.ProductViewSet)

urlpatterns = [
    url(r'^admin/', admin.site.urls),
]

urlpatterns += router.urls

This viewset, believe it or not, has all the functionality we implemented above plus some additional like creating a Product(POST /products) or getting a list of all Products(GET /products)

ViewSets also allow you to override the views and create custom ones. This allows us to create function views for one URL but not class-based views.
They are absolutely amazing for our example and other simple projects, but sub-par for managing multiple class-based non-generic views with custom logic inside them.

Discussion

markdown guide
 

Thanks, your solution was very helpful to me.

In your example, I received 500 errors if a method does not allow

AssertionError: .accepted_renderer not set on Response

I replaced the Response method on the parent method call, where I got normal behaviour and error 405.

def dispatch(self, request, *args, **kwargs):
    if not hasattr(self, 'VIEWS_BY_METHOD'):
        raise Exception('VIEWS_BY_METHOD static dictionary variable must be defined on a ManageView class!')
    if request.method in self.VIEWS_BY_METHOD:
        return self.VIEWS_BY_METHOD[request.method]()(request, *args, **kwargs)

    return super().dispatch(request, *args, **kwargs)
 

This now results in this error

AssertionError: Cannot apply DjangoModelPermissionsOrAnonReadOnly on a view that does not set .queryset or have a .get_queryset() method

This is what I ended up using:

    def dispatch(self, request, *args, **kwargs):
        if not hasattr(self, 'VIEWS_BY_METHOD'):
            raise Exception(
                'VIEWS_BY_METHOD static dictinary must be defined')
        if request.method in self.VIEWS_BY_METHOD:  # pylint: disable=E1101
            return self.VIEWS_BY_METHOD[  # pylint: disable=E1101
                request.method
            ]()(request, *args, **kwargs)

        response = Response(status=status.HTTP_405_METHOD_NOT_ALLOWED)
        if not getattr(request, 'accepted_renderer', None):
            neg = self.perform_content_negotiation(request, force=True)
            request.accepted_renderer, request.accepted_media_type = neg

        response.accepted_renderer = request.accepted_renderer
        response.accepted_media_type = request.accepted_media_type
        response.renderer_context = self.get_renderer_context()
        return response
 

I had the same issue and found that using a predefined Response type instead of the general Response class fixed it.

E.g.

    from django.http import JsonResponse

    def dispatch(self, request, *args, **kwargs):
        if not hasattr(self, 'VIEWS_BY_METHOD'):
            raise Exception(
                'VIEWS_BY_METHOD static dictinary must be defined')
        if request.method in self.VIEWS_BY_METHOD:  # pylint: disable=E1101
            return self.VIEWS_BY_METHOD[  # pylint: disable=E1101
                request.method
            ]()(request, *args, **kwargs)

        return JsonResponse(data={}, status=status.HTTP_405_METHOD_NOT_ALLOWED)

I haven't tested others, but I assume this would work equally well with an HttpResponse or whichever.

 

Thanks! Nice post and nice idea. Unfortunately the issues are starting when you're starting to add some security or permissions.

For example you need to make GET method public and protect others. If you add permissions to the classes you've mapped, they simply would skip the permissions check from your classes as and would apply the permissions from ProductManageView only.

Still the idea of yours is nice!

P.S. Correct me if I'm mistaken about permissions :)

 

Fortunately, you are mistaken. You can very simply add a permission class to the view you want to protect and it works how you'd expect it.
Say we want only authorized users to delete our products. We'd simply add the IsAuthorized permission class to the delete view

from rest_framework.permissions import IsAuthenticated


class ProductDestroyView(DestroyAPIView):
    permission_classes = (IsAuthenticated, )
    queryset = Product.objects.all()
    serializer_class = ProductSerializer

Our new test

def test_destroy_view_requires_authentication(self):
        product = Product.objects.create(name='Apple Watch', price=500, stock=3)
        response = self.client.delete(f'/products/{product.id}')
        self.assertEqual(response.status_code, 403)
        self.assertEqual(Product.objects.count(), 1)  # assert not deleted

Passes!

Stanislavs-iMac:restful_drf stanislavkozlovski$ python3.6 manage.py test restful_example.tests.ProductTests.test_destroy_view_requires_authentication
Creating test database for alias 'default'...
.
----------------------------------------------------------------------
Ran 1 test in 0.014s

OK
Destroying test database for alias 'default'...
 

I'm so glad I've asked about it! :) Thank you. Please, consider to add the information about permissions to the main article, it's very useful. Thank you!

 

Hi there,
I have one query with HTTP method calling on Url. I have created a class which is inherited from Viewset.
It has different functions to perform different CRUD operations. But when I call two HTTP methods on single URL it doesn't reflect on Options and always go for GET method

 

Thanks a lot, I think this is not Restful, why? you have the verb (delete,update) directly in the url, for Restfull the verb must be performed with http verbs.