1

I noticed that sites such as SO and Reddit use the url structure <basename>/<pk>/<slug>/ to route to the detail view of their posts, which makes me think that this should be the standard. Is there a way to accomplish this with django-rest-framework using DefaultRouter and ModelViewset?

example views.py:

class PostViewSet(viewsets.ModelViewSet):
    ...
    lookup_fields = ['pk', 'slug']

example urls.py :

router = DefaultRouter()
router.register('posts', PostViewSet, basename='post')

app_name = 'api'
urlpatterns = [
    path('', include(router.urls)),
]

URL routes:

/api/post/   api.views.PostViewSet  api:post-list
/api/post/<pk>/<slug>/    api.views.PostViewSet  api:post-detail
/api/post/<pk>/<slug>\.<format>/  api.views.PostViewSet  api:post-detail
/api/post\.<format>/ api.views.PostViewSet  api:post-list
timotaoh
  • 312
  • 5
  • 11
  • Does this answer your question? [Multiple lookup\_fields for django rest framework](https://stackoverflow.com/questions/38461366/multiple-lookup-fields-for-django-rest-framework) – sytech Mar 16 '22 at 01:07
  • There also appears to be an example given for this use case [in the documentation](https://www.django-rest-framework.org/api-guide/generic-views/#creating-custom-mixins). – sytech Mar 16 '22 at 01:08
  • These solutions look like they allow your lookup_field to be either a slug or pk. I'm trying to find a way to incorporate both into one URL – timotaoh Mar 16 '22 at 01:22
  • 1
    They would work for both in a single URL. You just need to add the url to your urls.py -- the default router won't do it for you. Unfortunately, there's also [drawbacks with `@action` methods](https://stackoverflow.com/q/68568523/5747944) that I've personally run into with this. – sytech Mar 16 '22 at 01:26

2 Answers2

1

You can use the MultipleLookupField mixin strategy and define a custom get_object method on your viewset for this.

class PostViewSet(viewsets.ModelViewSet):
    lookup_fields = ['pk', 'slug']
    # ...
    def get_object(self):
        if all(arg in self.kwargs for arg in self.lookup_fields):
            # detected the custom URL pattern; return the specified object
            qs = self.get_queryset()
            qs_filter = {field: self.kwargs[field] for field in self.lookup_fields}
            obj = get_object_or_404(qs, **qs_filter)
            self.check_object_permissions(self.request, obj)
            return obj
        else: # missing lookup fields
            raise InvalidQueryError("Missing fields")

class InvalidQueryError(APIException):
    status_code = status.HTTP_400_BAD_REQUEST

In this case, I just override get_object directly on the viewset. But you can make this a mixin class instead in order to easily include it into other viewsets.

The default router, however, will not automatically add this URL pattern to your app, so you'll have to do that manually.

urlpatterns = [
    ...,
    path('api/post/<int:pk>/<str:slug>', views.PostViewSet.as_view()),
]
sytech
  • 29,298
  • 3
  • 45
  • 86
0

I dove into the source code of DefaultRouter and came up with a solution that passed my unittests.

DRF Routers source code

create a file routers.py

from rest_framework.routers import DefaultRouter

class CustomRouter(DefaultRouter):

def get_lookup_regex(self, viewset, lookup_prefix=''):
    combined_lookup_field = getattr(viewset, 'combined_lookup_field', None)

    if combined_lookup_field:
        multi_base_regex_list = []
        
        for lookup_field in combined_lookup_field:
            base_regex = '(?P<{lookup_prefix}>{lookup_value})'
            lookup_value = getattr(viewset, 'lookup_value_regex', '[^/.]+')
            multi_base_regex_list.append(
                base_regex.format(
                    lookup_prefix=lookup_field,
                    lookup_value=lookup_value
                )
            )

        return'/'.join(multi_base_regex_list)

    return super().get_lookup_regex(viewset, lookup_prefix)

replace lookup_field or add the following in views.py

class PostViewSet(viewsets.ModelViewSet):
    ...
    combined_lookup_field = ['pk', 'slug']

replace DefaultRouter with CustomRouter in urls.py

from .routers import CustomRouter

router = CustomRouter()
router.register('posts', PostViewSet, basename='post')

app_name = 'api'
urlpatterns = [
    path('', include(router.urls)),
]

And that's it! It still incorporates all other functionality of DefaultRouter and will only use the added logic on views that have combined_lookup_field defined. It also supports @action functionality in the views.

timotaoh
  • 312
  • 5
  • 11