Wagtail API - how to customize the detail URL
Tags: wagtail, api
Published: 2019-12-13 15:42:15

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](https://docs.wagtail.io/en/v2.7/advanced_topics/api/v2/configuration.html) builds on the [Django REST Framework](https://www.django-rest-framework.org) (DRF), so the natural place to check was the DRF docs. The [generic views documentation page](https://www.django-rest-framework.org/api-guide/generic-views/#get_objectself) 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](https://github.com/wagtail/wagtail/blob/v2.7/wagtail/api/v2/serializers.py#L240) and [DetailUrlField](https://github.com/wagtail/wagtail/blob/v2.7/wagtail/api/v2/serializers.py#L30), 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

```python
# 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.

```python
# 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:

```python
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!