Managing RESTful URLs in Django Rest Framework

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.

原文链接:Managing RESTful URLs in Django Rest Framework

© 版权声明
THE END
喜欢就支持一下吧
点赞12 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容