13 Dec 2019

Wagtail API - how to customize the detail URL

Customize the Wagtail API endpoints to suit your needs.

Dan Braghis

Dan Braghis

Developer, Torchbox

A while ago, a member of the Wagtail community wanted to customize the PagesAPIEndpoint to access the specific page detail view via its slug (/api/v2/pages/the-page-slug, rather than id (/api/v2/pages/123)

The Wagtail API builds on the Django REST Framework (DRF), so the natural place to check was the DRF docs. The generic views documentation page points to changing lookup_field, however that does not work because BaseAPIEndpoint.get_object_detail_urlpath from which PagesAPIEndpoint is derived uses pk explicitly. The next logical place was to override the detail_url method for the model serializer (ref: BaseSerializer and DetailUrlField, with no success.

Digging further into the Wagtail API implementation internals reveals that the API router gets the URL information from each endpoint via get_urlpatterns and the BaseAPIEndpoint defines them as

# https://github.com/wagtail/wagtail/blob/v2.7/wagtail/api/v2/endpoints.py#L340
return [
    url(r'^$', cls.as_view({'get': 'listing_view'}), name='listing'),
    url(r'^(?P<pk>\d+)/$', cls.as_view({'get': 'detail_view'}), name='detail'),
    url(r'^find/$', cls.as_view({'get': 'find_view'}), name='find'),
]

With that in hand, we can then define our own endpoint that can handle both id and slug as parameters for the detail view.

# api.py
from wagtail.api.v2.endpoints import PagesAPIEndpoint
from wagtail.api.v2.router import WagtailAPIRouter


class MyPagesAPIEndpoint(PagesAPIEndpoint):
    """
    Our custom Pages API endpoint that allows finding pages by pk or slug
    """

    def detail_view(self, request, pk=None, slug=None):
        param = pk
        if slug is not None:
            self.lookup_field = 'slug'
            param = slug
        return super().detail_view(request, param)

    @classmethod
    def get_urlpatterns(cls):
        """
        This returns a list of URL patterns for the endpoint
        """
        return [
            path('', cls.as_view({'get': 'listing_view'}), name='listing'),
            path('<int:pk>/', cls.as_view({'get': 'detail_view'}), name='detail'),
            path('<slug:slug>/', cls.as_view({'get': 'detail_view'}), name='detail'),
            path('find/', cls.as_view({'get': 'find_view'}), name='find'),
        ]

# Create the router. “wagtailapi” is the URL namespace
api_router = WagtailAPIRouter('wagtailapi')

api_router.register_endpoint('pages', MyPagesAPIEndpoint)

While the above works, slugs are only unique within a parent in Wagtail. It is, therefore, possible to have multiple pages with the same slug, but in different sections of the site (e.g.our-team in /about/our-team and /blog/our-team). This would lead to a MultipleObjectsReturned exception. To account for that, you need to do some defensive programming:

from django.core.exceptions import MultipleObjectsReturned
from django.shortcuts import redirect
from django.urls import reverse, path

from wagtail.api.v2.endpoints import PagesAPIEndpoint
from wagtail.api.v2.router import WagtailAPIRouter


class MyPagesAPIEndpoint(PagesAPIEndpoint):
    """
    Our custom Pages API endpoint that allows finding pages by pk or slug
    """

    def detail_view(self, request, pk=None, slug=None):
        param = pk
        if slug is not None:
            self.lookup_field = 'slug'
            param = slug
        try:
            return super().detail_view(request, param)
        except MultipleObjectsReturned:
            # Redirect to the listing view, filtered by the relevant slug
            # The router is registered with the `wagtailapi` namespace,
            # `pages` is our endpoint namespace and `listing` is the listing view url name.
            return redirect(
                reverse('wagtailapi:pages:listing') + f'?{self.lookup_field}={param}'
            )

    @classmethod
    def get_urlpatterns(cls):
        """
        This returns a list of URL patterns for the endpoint
        """
        return [
            path('', cls.as_view({'get': 'listing_view'}), name='listing'),
            path('<int:pk>/', cls.as_view({'get': 'detail_view'}), name='detail'),
            path('<slug:slug>/', cls.as_view({'get': 'detail_view'}), name='detail'),
            path('find/', cls.as_view({'get': 'find_view'}), name='find'),
        ]

# Create the router. “wagtailapi” is the URL namespace
api_router = WagtailAPIRouter('wagtailapi')

api_router.register_endpoint('pages', MyPagesAPIEndpoint)

Using this technique we can provide additional endpoint URL patterns and make the Wagtail API cater for even more project specific requirements.

Happy coding!