browniebroke.com

Nested ViewSets with Django REST Framework

May 05, 2023 • 6 min read
Edit on Github

I’ve been using Django REST Framework for a while now but mainly in old projects where lots of code have been written. In most cases, these projects didn’t make good use of ViewSets, due to a variety of reasons. It may be due to the original author not knowing about them (that was me at some point), or because the operation behind each HTTP verb needs to be specialised or simply that a single operation is needed. However, in a recent project, I got the opportunity to start a lot of new code and make the most of them. So despite having used DRF for years, I feel like I’m learning a lot of new tricks.

The problem

In a project, I needed to add multiple HTTP verbs to a nested resource in a ViewSet. Let’s take an example to illustrate. Let’s imagine an application to manage some galleries of photos. The galleries need to be only accessible to the user that ows them. Assuming the models look like this (simplified for brevity):

class Gallery(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)


class Photo(models.Model):
    gallery = models.ForeignKey(Gallery, on_delete=models.CASCADE)

I needed the following API endpoints:

GET    /galleries/
POST   /galleries/
GET    /galleries/:id/
GET    /galleries/:id/photos/
POST   /galleries/:id/photos/
GET    /galleries/:id/photos/:photoId/
PUT    /galleries/:id/photos/:photoId/
PATCH  /galleries/:id/photos/:photoId/
DELETE /galleries/:id/photos/:photoId/

I already had a ViewSet for the getting and creating galleries, meaning the first 3 endpoints existed. I now needed to add photos management to it. I tried to look for how to nest a ViewSet inside another, but I couldn’t find anything supported out of the box.

The first solution

I knew about the @action decorator, and while looking closer at the DRF docs, I found out about how to route multiple HTTP verbs to an existing action, which I didn’t before. This seemed pretty clean, so I decided to give it a go. I ended up with the following:

class GalleryViewSet(viewsets.ModelViewSet):
    queryset = Gallery.objects.all()
    serializer_class = GallerySerializer
    permission_classes = (IsAuthenticated,)

    def get_queryset(self):
        return self.queryset.filter(user=self.request.user)

    @action(
        detail=True,
        methods=['get'],
        url_path='photos',
        url_name='photos-list',
        serializer_class=PhotoSerializer
    )
    def list_photos(self, request):
        gallery = self.get_object()
        photos_qs = gallery.photos.all()
        serializer = self.get_serializer(photos_qs, many=True)
        return Response(serializer.data)

    @list_photos.mapping.post
    def create_photo(self, request):
        ...

    @action(
        detail=True,
        methods=['get'],
        url_path='photos/(?P<photo_id>[^/.]+)',
        url_name='photos-detail',
        serializer_class=PhotoSerializer
    )
    def get_photo(self, request, pk=None):
        ...

    @get_photo.mapping.patch
    def update_photo(self, request, pk=None):
        ...

    @get_photo.mapping.delete
    def delete_photo(self, request, pk=None):
        ...

I liked how it was declarative and how each HTTP verb was separated into its own method. However, it’s quite a lot of code, with some bits duplicated, especially each method body. I also needed to check ownership of the gallery. It felt like I was re-implementing all the code for a photo ViewSet as individual methods.

Also, I didn’t realise at the time, but all these actions are using the QuerySet from the base ViewSet. This is a simplified example, but in my case, the base ViewSet had several select_related and prefetch_related to optimise the fetching of the gallery, but these were not needed for the photo management part. So I was fetching a lot of data that I didn’t need.

The second solution

After a couple of days, while not being happy with my solution, I decided to give it another go. I noticed while implementing my initial solution that I could add extra URL parameters in the url_path of the get_photo method, which made me think that maybe I could do the same when registering the ViewSet, and sure enough, I could! So I ended up with the following:

# views.py
class GalleryViewSet(viewsets.ModelViewSet):
    queryset = Gallery.objects.all()
    serializer_class = GallerySerializer
    permission_classes = (IsAuthenticated,)
    lookup_url_kwarg = "post_id"
    def get_queryset(self):
        return self.queryset.filter(user=self.request.user)

    # Note: Removed all the actions methods for photos


class PhotoViewSet(viewsets.ModelViewSet):    queryset = Photo.objects.all()    serializer_class = PhotoSerializer    permission_classes = (IsAuthenticated,)    lookup_url_kwarg = "photo_id"
    def get_queryset(self):        return self.queryset.filter(gallery_id=self.kwargs["gallery_id"])
    def perform_create(self, serializer):        serializer.save(gallery_id=self.kwargs["gallery_id"])


# routers.py
router = SimpleRouter()
router.register(
    "galleries",
    GalleryViewSet,
    basename="gallery",
)
router.register(    "galleries/(?P<gallery_id>[^/.]+)/photos",    PhotoViewSet,    basename="gallery-photo",)

As a bonus, I added a lookup_url_kwarg to both ViewSets, to make the URL parameters more consistent and self-explanatory in the API docs. This solution is much more DRY and use the full power of DRF. I was pretty satisfied with it.

Bug: access control

However, I soon realised that this solution had a bug. The get_queryset method of the PhotoViewSet was no longer checking the ownership of the gallery! This meant that a user could access photos from galleries that they don’t own, and even create photos in them! This was a big no-no. I tried to write a simple method to fetch the gallery and check ownership, but I wanted to do it pretty early and for all methods, including create(). I thought of overriding the dispatch method, but it felt like there wasn’t a good hook for that.

After a bit of thinking, I realised that I needed to control the user had permissions to access the gallery, and DRF has a built-in solution for that, with permissions classes. I implemented my own permission class, which looked like this:

class UserOwnsGallery(permissions.BasePermission):

    def has_permission(self, request, view):
        gallery_id = view.kwargs.get("gallery_id")
        if gallery_id is None:
            return False

        get_object_or_404(
            Gallery,
            id=gallery_id,
            user_id=request.user.id,
        )
        return True

If the user doesn’t own the gallery, I’m returning a HTTP 404 as I think it’s a security best practice. If I were to return “403 Forbidden” instead, I would reveal some information to a potential attacker, that the gallery with the ID exists. It’s probably not a big risk in our application, but it’s best to be safe.

And here is how I use it on my ViewSet:

class PhotoViewSet(viewsets.ModelViewSet):
    queryset = Photo.objects.all()
    serializer_class = PhotoSerializer
    permission_classes = (
        IsAuthenticated,
        UserOwnsGallery,    )
    lookup_url_kwarg = "photo_id"

Conclusion

I’m pretty happy with the final solution. It’s DRY, it’s using the full power of DRF, and it’s secure. I’m not sure if it’s the best solution, but it’s probably very close from it. I hope this article will help you if you’re in a similar situation.

Liked it? Please share it!

© 2024, Built with Gatsby