From 17f7f70170911ac3745cf002fb2cca054581d5da Mon Sep 17 00:00:00 2001 From: Karl Hobley Date: Fri, 12 Jan 2018 13:16:46 +0000 Subject: [PATCH] Added "find" API view and ability to find pages by HTML path This implements a new "find" view for all endpoints which can be used for finding an individual object based on the URL parameters passed to it. If an object is found, the view will return a ``302`` redirect to detail page of that object. If not, the view will return a ``404`` response. For the pages endpoint, I've added a ``html_path`` parameter to this view, this allows finding a page by its path on the site. For example a GET request to ``/api/v2/pages/find/?html_path=/`` will always generate a 302 response to the detail view of the homepage. This uses Wagtail's internal routing mechanism so routable pages are supported as well. Fixes #4154 --- CHANGELOG.txt | 1 + docs/advanced_topics/api/v2/usage.rst | 12 +++++ docs/releases/2.1.rst | 8 +++- wagtail/api/v2/endpoints.py | 47 +++++++++++++++++++- wagtail/api/v2/serializers.py | 11 +---- wagtail/api/v2/tests/test_documents.py | 38 ++++++++++++++++ wagtail/api/v2/tests/test_images.py | 38 ++++++++++++++++ wagtail/api/v2/tests/test_pages.py | 61 ++++++++++++++++++++++++++ wagtail/api/v2/utils.py | 7 +++ 9 files changed, 212 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 3f64dbc7f..d5ade9a8b 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -5,6 +5,7 @@ Changelog ~~~~~~~~~~~~~~~~ * Add `HelpPanel` to add HTML within an edit form (Keving Chung) + * Added API endpoint for finding pages by HTML path (Karl Hobley) * Persist tab hash in URL to allow direct navigation to tabs in the admin interface (Ben Weatherman) * Animate the chevron icon when opening sub-menus in the admin (Carlo Ascani) * Look through the target link and target page slug (in addition to the old slug) when searching for redirects in the admin (Michael Harrison) diff --git a/docs/advanced_topics/api/v2/usage.rst b/docs/advanced_topics/api/v2/usage.rst index a338e46cb..69fc36f94 100644 --- a/docs/advanced_topics/api/v2/usage.rst +++ b/docs/advanced_topics/api/v2/usage.rst @@ -422,6 +422,18 @@ All exported fields will be returned in the response by default. You can use the For example: ``/api/v2/pages/1/?fields=_,title,body`` will return just the ``title`` and ``body`` of the page with the id of 1. + +.. _apiv2_finding_pages_by_path: + +Finding pages by HTML path +-------------------------- + +You can find an individual page by its HTML path using the ``/api/v2/pages/find/?html_path=`` view. + +This will return either a ``302`` redirect response to that page's detail view, or a ``404`` not found response. + +For example: ``/api/v2/pages/find/?html_path=/`` always redirects to the homepage of the site + Default endpoint fields ======================= diff --git a/docs/releases/2.1.rst b/docs/releases/2.1.rst index 9508c19ff..c0cf150fb 100644 --- a/docs/releases/2.1.rst +++ b/docs/releases/2.1.rst @@ -14,7 +14,13 @@ New ``HelpPanel`` ~~~~~~~~~~~~~~~~~ A new panel type ``HelpPanel`` allows to easily add HTML within an edit form. -This new feature was developed by Keving Chung. +This new feature was developed by Kevin Chung. + + +API lookup by page path +~~~~~~~~~~~~~~~~~~~~~~~ + +The API now includes an endpoint for finding pages by path; see :ref:`apiv2_finding_pages_by_path`. This feature was developed by Karl Hobley. Other features ~~~~~~~~~~~~~~ diff --git a/wagtail/api/v2/endpoints.py b/wagtail/api/v2/endpoints.py index a4453bd39..6b5bd7a03 100644 --- a/wagtail/api/v2/endpoints.py +++ b/wagtail/api/v2/endpoints.py @@ -3,6 +3,7 @@ from collections import OrderedDict from django.conf.urls import url from django.core.exceptions import FieldDoesNotExist from django.http import Http404 +from django.shortcuts import redirect from django.urls import reverse from modelcluster.fields import ParentalKey from rest_framework import status @@ -19,7 +20,7 @@ from .filters import ( from .pagination import WagtailPagination from .serializers import BaseSerializer, PageSerializer, get_serializer_class from .utils import ( - BadRequestError, filter_page_type, page_models_from_string, parse_fields_parameter) + BadRequestError, filter_page_type, page_models_from_string, parse_fields_parameter, get_object_detail_url) class BaseAPIEndpoint(GenericViewSet): @@ -76,6 +77,34 @@ class BaseAPIEndpoint(GenericViewSet): serializer = self.get_serializer(instance) return Response(serializer.data) + def find_view(self, request): + queryset = self.get_queryset() + + try: + obj = self.find_object(queryset, request) + + if obj is None: + raise self.model.DoesNotExist + + except self.model.DoesNotExist: + raise Http404("not found") + + # Generate redirect + url = get_object_detail_url(self.request.wagtailapi_router, request, self.model, obj.pk) + + if url is None: + # Shouldn't happen unless this endpoint isn't actually installed in the router + raise Exception("Cannot generate URL to detail view. Is '{}' installed in the API router?".format(self.__class__.__name__)) + + return redirect(url) + + def find_object(self, queryset, request): + """ + Override this to implement more find methods. + """ + if 'id' in request.GET: + return queryset.get(id=request.GET['id']) + def handle_exception(self, exc): if isinstance(exc, Http404): data = {'message': str(exc)} @@ -310,6 +339,7 @@ class BaseAPIEndpoint(GenericViewSet): return [ url(r'^$', cls.as_view({'get': 'listing_view'}), name='listing'), url(r'^(?P\d+)/$', cls.as_view({'get': 'detail_view'}), name='detail'), + url(r'^find/$', cls.as_view({'get': 'find_view'}), name='find'), ] @classmethod @@ -405,3 +435,18 @@ class PagesAPIEndpoint(BaseAPIEndpoint): def get_object(self): base = super().get_object() return base.specific + + def find_object(self, queryset, request): + if 'html_path' in request.GET and request.site is not None: + path = request.GET['html_path'] + path_components = [component for component in path.split('/') if component] + + try: + page, _, _ = request.site.root_page.specific.route(request, path_components) + except Http404: + return + + if queryset.filter(id=page.id).exists(): + return page + + return super().find_object(queryset, request) diff --git a/wagtail/api/v2/serializers.py b/wagtail/api/v2/serializers.py index 1efbef369..08425e61b 100644 --- a/wagtail/api/v2/serializers.py +++ b/wagtail/api/v2/serializers.py @@ -8,14 +8,7 @@ from taggit.managers import _TaggableManager from wagtail.core import fields as wagtailcore_fields -from .utils import get_full_url, pages_for_site - - -def get_object_detail_url(context, model, pk): - url_path = context['router'].get_object_detail_urlpath(model, pk) - - if url_path: - return get_full_url(context['request'], url_path) +from .utils import get_object_detail_url, pages_for_site class TypeField(Field): @@ -42,7 +35,7 @@ class DetailUrlField(Field): "detail_url": "http://api.example.com/v1/images/1/" """ def get_attribute(self, instance): - url = get_object_detail_url(self.context, type(instance), instance.pk) + url = get_object_detail_url(self.context['router'], self.context['request'], type(instance), instance.pk) if url: return url diff --git a/wagtail/api/v2/tests/test_documents.py b/wagtail/api/v2/tests/test_documents.py index 368878cad..53a29a32b 100644 --- a/wagtail/api/v2/tests/test_documents.py +++ b/wagtail/api/v2/tests/test_documents.py @@ -485,6 +485,44 @@ class TestDocumentDetail(TestCase): self.assertEqual(content, {'message': "'title' does not support nested fields"}) +class TestDocumentFind(TestCase): + fixtures = ['demosite.json'] + + def get_response(self, **params): + return self.client.get(reverse('wagtailapi_v2:documents:find'), params) + + def test_without_parameters(self): + response = self.get_response() + + self.assertEqual(response.status_code, 404) + self.assertEqual(response['Content-type'], 'application/json') + + # Will crash if the JSON is invalid + content = json.loads(response.content.decode('UTF-8')) + + self.assertEqual(content, { + 'message': 'not found' + }) + + def test_find_by_id(self): + response = self.get_response(id=5) + + self.assertRedirects(response, 'http://localhost' + reverse('wagtailapi_v2:documents:detail', args=[5]), fetch_redirect_response=False) + + def test_find_by_id_nonexistent(self): + response = self.get_response(id=1234) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response['Content-type'], 'application/json') + + # Will crash if the JSON is invalid + content = json.loads(response.content.decode('UTF-8')) + + self.assertEqual(content, { + 'message': 'not found' + }) + + @override_settings( WAGTAILFRONTENDCACHE={ 'varnish': { diff --git a/wagtail/api/v2/tests/test_images.py b/wagtail/api/v2/tests/test_images.py index 5d3911e29..840c22958 100644 --- a/wagtail/api/v2/tests/test_images.py +++ b/wagtail/api/v2/tests/test_images.py @@ -479,6 +479,44 @@ class TestImageDetail(TestCase): self.assertEqual(content, {'message': "'title' does not support nested fields"}) +class TestImageFind(TestCase): + fixtures = ['demosite.json'] + + def get_response(self, **params): + return self.client.get(reverse('wagtailapi_v2:images:find'), params) + + def test_without_parameters(self): + response = self.get_response() + + self.assertEqual(response.status_code, 404) + self.assertEqual(response['Content-type'], 'application/json') + + # Will crash if the JSON is invalid + content = json.loads(response.content.decode('UTF-8')) + + self.assertEqual(content, { + 'message': 'not found' + }) + + def test_find_by_id(self): + response = self.get_response(id=5) + + self.assertRedirects(response, 'http://localhost' + reverse('wagtailapi_v2:images:detail', args=[5]), fetch_redirect_response=False) + + def test_find_by_id_nonexistent(self): + response = self.get_response(id=1234) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response['Content-type'], 'application/json') + + # Will crash if the JSON is invalid + content = json.loads(response.content.decode('UTF-8')) + + self.assertEqual(content, { + 'message': 'not found' + }) + + @override_settings( WAGTAILFRONTENDCACHE={ 'varnish': { diff --git a/wagtail/api/v2/tests/test_pages.py b/wagtail/api/v2/tests/test_pages.py index f714e52d7..b76e62a13 100644 --- a/wagtail/api/v2/tests/test_pages.py +++ b/wagtail/api/v2/tests/test_pages.py @@ -1038,6 +1038,67 @@ class TestPageDetail(TestCase): self.assertEqual(content, {'message': "'title' does not support nested fields"}) +class TestPageFind(TestCase): + fixtures = ['demosite.json'] + + def get_response(self, **params): + return self.client.get(reverse('wagtailapi_v2:pages:find'), params) + + def test_without_parameters(self): + response = self.get_response() + + self.assertEqual(response.status_code, 404) + self.assertEqual(response['Content-type'], 'application/json') + + # Will crash if the JSON is invalid + content = json.loads(response.content.decode('UTF-8')) + + self.assertEqual(content, { + 'message': 'not found' + }) + + def test_find_by_id(self): + response = self.get_response(id=5) + + self.assertRedirects(response, 'http://localhost' + reverse('wagtailapi_v2:pages:detail', args=[5]), fetch_redirect_response=False) + + def test_find_by_id_nonexistent(self): + response = self.get_response(id=1234) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response['Content-type'], 'application/json') + + # Will crash if the JSON is invalid + content = json.loads(response.content.decode('UTF-8')) + + self.assertEqual(content, { + 'message': 'not found' + }) + + def test_find_by_html_path(self): + response = self.get_response(html_path='/events-index/event-1/') + + self.assertRedirects(response, 'http://localhost' + reverse('wagtailapi_v2:pages:detail', args=[8]), fetch_redirect_response=False) + + def test_find_by_html_path_with_start_and_end_slashes_removed(self): + response = self.get_response(html_path='events-index/event-1') + + self.assertRedirects(response, 'http://localhost' + reverse('wagtailapi_v2:pages:detail', args=[8]), fetch_redirect_response=False) + + def test_find_by_html_path_nonexistent(self): + response = self.get_response(html_path='/foo') + + self.assertEqual(response.status_code, 404) + self.assertEqual(response['Content-type'], 'application/json') + + # Will crash if the JSON is invalid + content = json.loads(response.content.decode('UTF-8')) + + self.assertEqual(content, { + 'message': 'not found' + }) + + class TestPageDetailWithStreamField(TestCase): fixtures = ['test.json'] diff --git a/wagtail/api/v2/utils.py b/wagtail/api/v2/utils.py index ba0c48b6e..84bcee8a0 100644 --- a/wagtail/api/v2/utils.py +++ b/wagtail/api/v2/utils.py @@ -25,6 +25,13 @@ def get_full_url(request, path): return base_url + path +def get_object_detail_url(router, request, model, pk): + url_path = router.get_object_detail_urlpath(model, pk) + + if url_path: + return get_full_url(request, url_path) + + def pages_for_site(site): pages = Page.objects.public().live() pages = pages.descendant_of(site.root_page, inclusive=True)