Wagtail API - how to customize the detail URL

Note: originally posted on 13 December 2019 at https://wagtail.org/blog/wagtail-api-how-customize-detail-url/

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 BaseAPIViewSet.get_urlpatterns which defines them as:

# https://github.com/wagtail/wagtail/blob/v4.2/wagtail/api/v2/views.py#L386-L393

class BaseAPIViewSet(GenericViewSet):
    ...

    @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("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.views import PagesAPIViewSet
from wagtail.api.v2.router import WagtailAPIRouter


class MyPagesAPIViewSet(PagesAPIViewSet):
    """
    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", MyPagesAPIViewSet)

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:

# api.py
from django.core.exceptions import MultipleObjectsReturned
from wagtail.api.v2.views import PagesAPIViewSet
from wagtail.api.v2.router import WagtailAPIRouter


class MyPagesAPIViewSet(PagesAPIViewSet):
    """
    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", MyPagesAPIViewSet)

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

Happy coding!