diff --git a/docs/usage.md b/docs/usage.md index e172df47..b9d89ecf 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -33,7 +33,7 @@ REST_FRAMEWORK = { ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( - 'rest_framework_json_api.backends.JSONAPIOrderingFilter', + 'rest_framework_json_api.filters.JSONAPIOrderingFilter', ), 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', @@ -98,7 +98,7 @@ _This is the first of several anticipated JSON:API-specific filter backends._ `JSONAPIOrderingFilter` implements the [JSON:API `sort`](http://jsonapi.org/format/#fetching-sorting) and uses DRF's [ordering filter](http://django-rest-framework.readthedocs.io/en/latest/api-guide/filtering/#orderingfilter). -Per the JSON:API, "If the server does not support sorting as specified in the query parameter `sort`, +Per the JSON:API specification, "If the server does not support sorting as specified in the query parameter `sort`, it **MUST** return `400 Bad Request`." For example, for `?sort=`abc,foo,def` where `foo` is a valid field name and the other two are not valid: ```json @@ -118,6 +118,20 @@ field name and the other two are not valid: If you want to silently ignore bad sort fields, just use `rest_framework.filters.OrderingFilter` and set `ordering_param` to `sort`. +#### Configuring Filter Backends + +You can configure the filter backends either by setting the `REST_FRAMEWORK['DEFAULT_FILTER_BACKENDS']` as shown +in the [preceding](#configuration) example or individually add them as `.filter_backends` View attributes: + + ```python +from rest_framework_json_api import filters + +class MyViewset(ModelViewSet): + queryset = MyModel.objects.all() + serializer_class = MyModelSerializer + filter_backends = (filters.JSONAPIOrderingFilter,) +``` + ### Performance Testing diff --git a/example/settings/dev.py b/example/settings/dev.py index e8ed4094..6856a91b 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -89,9 +89,8 @@ ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( - 'rest_framework_json_api.backends.JSONAPIOrderingFilter', + 'rest_framework_json_api.filters.JSONAPIOrderingFilter', ), - 'ORDERING_PARAM': 'sort', 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', ), diff --git a/example/tests/test_backends.py b/example/tests/test_backends.py deleted file mode 100644 index 0721f780..00000000 --- a/example/tests/test_backends.py +++ /dev/null @@ -1,54 +0,0 @@ -from rest_framework.reverse import reverse -from rest_framework.test import APITestCase - -from ..models import Blog, Entry - - -class DJATestParameters(APITestCase): - """ - tests of JSON:API backends - """ - fixtures = ('blogentry',) - - def setUp(self): - self.entries = Entry.objects.all() - self.blogs = Blog.objects.all() - self.url = reverse('nopage-entry-list') - - def test_sort(self): - """ - test sort - """ - response = self.client.get(self.url, data={'sort': 'headline'}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) - dja_response = response.json() - headlines = [c['attributes']['headline'] for c in dja_response['data']] - sorted_headlines = [c['attributes']['headline'] for c in dja_response['data']] - sorted_headlines.sort() - self.assertEqual(headlines, sorted_headlines) - - def test_sort_reverse(self): - """ - confirm switching the sort order actually works - """ - response = self.client.get(self.url, data={'sort': '-headline'}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) - dja_response = response.json() - headlines = [c['attributes']['headline'] for c in dja_response['data']] - sorted_headlines = [c['attributes']['headline'] for c in dja_response['data']] - sorted_headlines.sort() - self.assertNotEqual(headlines, sorted_headlines) - - def test_sort_invalid(self): - """ - test sort of invalid field - """ - response = self.client.get(self.url, - data={'sort': 'nonesuch,headline,-not_a_field'}) - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) - dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "invalid sort parameters: nonesuch,-not_a_field") diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py new file mode 100644 index 00000000..2b18b5f3 --- /dev/null +++ b/example/tests/test_filters.py @@ -0,0 +1,105 @@ +from rest_framework.reverse import reverse +from rest_framework.test import APITestCase + +from ..models import Blog, Entry + + +class DJATestParameters(APITestCase): + """ + tests of JSON:API backends + """ + fixtures = ('blogentry',) + + def setUp(self): + self.entries = Entry.objects.all() + self.blogs = Blog.objects.all() + self.url = reverse('nopage-entry-list') + + def test_sort(self): + """ + test sort + """ + response = self.client.get(self.url, data={'sort': 'headline'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + headlines = [c['attributes']['headline'] for c in dja_response['data']] + sorted_headlines = sorted(headlines) + self.assertEqual(headlines, sorted_headlines) + + def test_sort_reverse(self): + """ + confirm switching the sort order actually works + """ + response = self.client.get(self.url, data={'sort': '-headline'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + headlines = [c['attributes']['headline'] for c in dja_response['data']] + sorted_headlines = sorted(headlines) + self.assertNotEqual(headlines, sorted_headlines) + + def test_sort_double_negative(self): + """ + what if they provide multiple `-`'s? It's OK. + """ + response = self.client.get(self.url, data={'sort': '--headline'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + headlines = [c['attributes']['headline'] for c in dja_response['data']] + sorted_headlines = sorted(headlines) + self.assertNotEqual(headlines, sorted_headlines) + + def test_sort_invalid(self): + """ + test sort of invalid field + """ + response = self.client.get(self.url, + data={'sort': 'nonesuch,headline,-not_a_field'}) + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid sort parameters: nonesuch,-not_a_field") + + def test_sort_camelcase(self): + """ + test sort of camelcase field name + """ + response = self.client.get(self.url, data={'sort': 'bodyText'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + blog_ids = [(c['attributes']['bodyText'] or '') for c in dja_response['data']] + sorted_blog_ids = sorted(blog_ids) + self.assertEqual(blog_ids, sorted_blog_ids) + + def test_sort_underscore(self): + """ + test sort of underscore field name + Do we allow this notation in a search even if camelcase is in effect? + "Be conservative in what you send, be liberal in what you accept" + -- https://en.wikipedia.org/wiki/Robustness_principle + """ + response = self.client.get(self.url, data={'sort': 'body_text'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + blog_ids = [(c['attributes']['bodyText'] or '') for c in dja_response['data']] + sorted_blog_ids = sorted(blog_ids) + self.assertEqual(blog_ids, sorted_blog_ids) + + def test_sort_related(self): + """ + test sort via related field using jsonapi path `.` and django orm `__` notation. + ORM relations must be predefined in the View's .ordering_fields attr + """ + for datum in ('blog__id', 'blog.id'): + response = self.client.get(self.url, data={'sort': datum}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + blog_ids = [c['relationships']['blog']['data']['id'] for c in dja_response['data']] + sorted_blog_ids = sorted(blog_ids) + self.assertEqual(blog_ids, sorted_blog_ids) diff --git a/example/views.py b/example/views.py index a42a80ae..36026b17 100644 --- a/example/views.py +++ b/example/views.py @@ -90,6 +90,7 @@ class NoPagination(PageNumberPagination): class NonPaginatedEntryViewSet(EntryViewSet): pagination_class = NoPagination + ordering_fields = ('headline', 'body_text', 'blog__name', 'blog__id') class AuthorViewSet(ModelViewSet): diff --git a/rest_framework_json_api/backends.py b/rest_framework_json_api/filters.py similarity index 51% rename from rest_framework_json_api/backends.py rename to rest_framework_json_api/filters.py index e6fda16b..748b18bf 100644 --- a/rest_framework_json_api/backends.py +++ b/rest_framework_json_api/filters.py @@ -10,27 +10,35 @@ class JSONAPIOrderingFilter(OrderingFilter): if any sort field is invalid. If you prefer *not* to report 400 errors for invalid sort fields, just use OrderingFilter with `ordering_param='sort'` - TODO: Add sorting based upon relationships (sort=relname.fieldname) + Also applies DJA format_value() to convert (e.g. camelcase) to underscore. + (See JSON_API_FORMAT_FIELD_NAMES in docs/usage.md) """ ordering_param = 'sort' def remove_invalid_fields(self, queryset, fields, view, request): - """ - overrides remove_invalid_fields to raise a 400 exception instead of - silently removing them. set `ignore_bad_sort_fields = True` to not - do this validation. - """ valid_fields = [ item[0] for item in self.get_valid_fields(queryset, view, {'request': request}) ] bad_terms = [ term for term in fields - if format_value(term.lstrip('-'), "underscore") not in valid_fields + if format_value(term.replace(".", "__").lstrip('-'), "underscore") not in valid_fields ] if bad_terms: raise ValidationError('invalid sort parameter{}: {}'.format( ('s' if len(bad_terms) > 1 else ''), ','.join(bad_terms))) + # this looks like it duplicates code above, but we want the ValidationError to report + # the actual parameter supplied while we want the fields passed to the super() to + # be correctly rewritten. + # The leading `-` has to be stripped to prevent format_value from turning it into `_`. + underscore_fields = [] + for item in fields: + item_rewritten = item.replace(".", "__") + if item_rewritten.startswith('-'): + underscore_fields.append( + '-' + format_value(item_rewritten.lstrip('-'), "underscore")) + else: + underscore_fields.append(format_value(item_rewritten, "underscore")) return super(JSONAPIOrderingFilter, self).remove_invalid_fields( - queryset, fields, view, request) + queryset, underscore_fields, view, request)