From 7b29f365e1ed30cb67b5484fa4ac7f3ab566bc2a Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Thu, 23 Aug 2018 16:26:30 -0400 Subject: [PATCH 01/26] initial integration of JSONAPIDjangoFilter --- example/settings/dev.py | 1 + example/tests/test_filters.py | 251 ++++++++++++++++++++++++++++- example/urls_test.py | 5 + example/views.py | 39 +++++ rest_framework_json_api/filters.py | 89 +++++++++- 5 files changed, 382 insertions(+), 3 deletions(-) diff --git a/example/settings/dev.py b/example/settings/dev.py index 6856a91b..720d2396 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -90,6 +90,7 @@ 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( 'rest_framework_json_api.filters.JSONAPIOrderingFilter', + 'rest_framework_json_api.filters.JSONAPIDjangoFilter', ), 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index 2b18b5f3..d0e8974c 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -4,9 +4,9 @@ from ..models import Blog, Entry -class DJATestParameters(APITestCase): +class DJATestFilters(APITestCase): """ - tests of JSON:API backends + tests of JSON:API filter backends """ fixtures = ('blogentry',) @@ -14,6 +14,8 @@ def setUp(self): self.entries = Entry.objects.all() self.blogs = Blog.objects.all() self.url = reverse('nopage-entry-list') + self.fs_url = reverse('filterset-entry-list') + self.no_fs_url = reverse('nofilterset-entry-list') def test_sort(self): """ @@ -103,3 +105,248 @@ def test_sort_related(self): 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) + + def test_filter_exact(self): + """ + filter for an exact match + """ + response = self.client.get(self.url, data={'filter[headline]': 'CHEM3271X'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(len(dja_response['data']), 1) + + def test_filter_exact_fail(self): + """ + failed search for an exact match + """ + response = self.client.get(self.url, data={'filter[headline]': 'XXXXX'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(len(dja_response['data']), 0) + + def test_filter_isnull(self): + """ + search for null value + """ + response = self.client.get(self.url, data={'filter[bodyText.isnull]': 'true'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual( + len(dja_response['data']), + len([k for k in self.entries if k.body_text is None]) + ) + + def test_filter_not_null(self): + """ + search for not null + """ + response = self.client.get(self.url, data={'filter[bodyText.isnull]': 'false'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual( + len(dja_response['data']), + len([k for k in self.entries if k.body_text is not None]) + ) + + def test_filter_isempty(self): + """ + search for an empty value (different from null!) + the easiest way to do this is search for r'^$' + """ + response = self.client.get(self.url, data={'filter[bodyText.regex]': '^$'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(len(dja_response['data']), + len([k for k in self.entries + if k.body_text is not None and + len(k.body_text) == 0])) + + def test_filter_related(self): + """ + filter via a relationship chain + """ + response = self.client.get(self.url, data={'filter[blog.name]': 'ANTB'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(len(dja_response['data']), + len([k for k in self.entries + if k.blog.name == 'ANTB'])) + + def test_filter_related_fieldset_class(self): + """ + filter via a FilterSet class instead of filterset_fields shortcut + This tests a shortcut for a longer ORM path: `bname` is a shortcut + name for `blog.name`. + """ + response = self.client.get(self.fs_url, data={'filter[bname]': 'ANTB'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(len(dja_response['data']), + len([k for k in self.entries + if k.blog.name == 'ANTB'])) + + def test_filter_related_missing_fieldset_class(self): + """ + filter via with neither filterset_fields nor filterset_class + This should return an error for any filter[] + """ + response = self.client.get(self.no_fs_url, data={'filter[bname]': 'ANTB'}) + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid filter[bname]") + + def test_filter_fields_union_list(self): + """ + test field for a list of values(ORed): ?filter[field.in]': 'val1,val2,val3 + """ + response = self.client.get(self.url, + data={'filter[headline.in]': 'CLCV2442V,XXX,BIOL3594X'}) + dja_response = response.json() + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + self.assertEqual( + len(dja_response['data']), + len([k for k in self.entries if k.headline == 'CLCV2442V']) + + len([k for k in self.entries if k.headline == 'XXX']) + + len([k for k in self.entries if k.headline == 'BIOL3594X']), + msg="filter field list (union)") + + def test_filter_fields_intersection(self): + """ + test fields (ANDed): ?filter[field1]': 'val1&filter[field2]'='val2 + """ + # + response = self.client.get(self.url, + data={'filter[headline.regex]': '^A', + 'filter[body_text.icontains]': 'in'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertGreater(len(dja_response['data']), 1) + self.assertEqual( + len(dja_response['data']), + len([k for k in self.entries if k.headline.startswith('A') and + 'in' in k.body_text.lower()])) + + def test_filter_invalid_association_name(self): + """ + test for filter with invalid filter association name + """ + response = self.client.get(self.url, data={'filter[nonesuch]': 'CHEM3271X'}) + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid filter[nonesuch]") + + def test_filter_empty_association_name(self): + """ + test for filter with missing association name + """ + response = self.client.get(self.url, data={'filter[]': 'foobar'}) + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid filter: filter[]") + + def test_filter_no_brackets(self): + """ + test for `filter=foobar` with missing filter[association] name + """ + response = self.client.get(self.url, data={'filter': 'foobar'}) + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid filter: filter") + + def test_filter_no_brackets_rvalue(self): + """ + test for `filter=` with missing filter[association] and value + """ + response = self.client.get(self.url + '?filter=') + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid filter: filter") + + def test_filter_no_brackets_equal(self): + """ + test for `filter` with missing filter[association] name and =value + """ + response = self.client.get(self.url +'?filter') + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid filter: filter") + + def test_filter_malformed_left_bracket(self): + """ + test for filter with invalid filter syntax + """ + response = self.client.get(self.url, data={'filter[': 'foobar'}) + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid filter: filter[") + + def test_filter_missing_right_bracket(self): + """ + test for filter missing right bracket + """ + response = self.client.get(self.url, data={'filter[headline': 'foobar'}) + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid filter: filter[headline") + + def test_filter_incorrect_brackets(self): + """ + test for filter with incorrect brackets + """ + response = self.client.get(self.url, data={'filter{headline}': 'foobar'}) + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid filter: filter{headline}") + + def test_filter_missing_rvalue(self): + """ + test for filter with missing value to test against + this should probably be an error rather than ignoring the filter: + https://django-filter.readthedocs.io/en/latest/guide/tips.html#filtering-by-an-empty-string + """ + response = self.client.get(self.url, data={'filter[headline]': ''}) + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "missing filter[headline] test value") + + def test_filter_missing_rvalue_equal(self): + """ + test for filter with missing value to test against + this should probably be an error rather than ignoring the filter: + """ + response = self.client.get(self.url + '?filter[headline]') + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "missing filter[headline] test value") + diff --git a/example/urls_test.py b/example/urls_test.py index e7a27ce4..8475bb79 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -13,6 +13,8 @@ EntryRelationshipView, EntryViewSet, NonPaginatedEntryViewSet, + FiltersetEntryViewSet, + NoFiltersetEntryViewSet, ProjectViewset ) @@ -20,7 +22,10 @@ router.register(r'blogs', BlogViewSet) router.register(r'entries', EntryViewSet) +# these "flavors" of entries are used for various tests: router.register(r'nopage-entries', NonPaginatedEntryViewSet, 'nopage-entry') +router.register(r'filterset-entries', FiltersetEntryViewSet, 'filterset-entry') +router.register(r'nofilterset-entries', NoFiltersetEntryViewSet, 'nofilterset-entry') router.register(r'authors', AuthorViewSet) router.register(r'comments', CommentViewSet) router.register(r'companies', CompanyViewset) diff --git a/example/views.py b/example/views.py index 36026b17..288d17e7 100644 --- a/example/views.py +++ b/example/views.py @@ -8,6 +8,7 @@ from rest_framework_json_api.pagination import PageNumberPagination from rest_framework_json_api.utils import format_drf_errors from rest_framework_json_api.views import ModelViewSet, RelationshipView +from django_filters import rest_framework as filters from example.models import Author, Blog, Comment, Company, Entry, Project from example.serializers import ( @@ -91,6 +92,44 @@ class NoPagination(PageNumberPagination): class NonPaginatedEntryViewSet(EntryViewSet): pagination_class = NoPagination ordering_fields = ('headline', 'body_text', 'blog__name', 'blog__id') + rels = ('exact', 'iexact', + 'contains', 'icontains', + 'gt', 'gte', 'lt', 'lte', + 'in', 'regex', 'isnull',) + filterset_fields = { + 'id': ('exact', 'in'), + 'headline': rels, + 'body_text': rels, + 'blog__name': rels, + 'blog__tagline': rels, + } + + +class EntryFilter(filters.FilterSet): + bname = filters.CharFilter(field_name="blog__name", + lookup_expr="exact") + + class Meta: + model = Entry + fields = ['id', 'headline', 'body_text'] + + +class FiltersetEntryViewSet(EntryViewSet): + """ + like above but use filterset_class instead of filterset_fields + """ + pagination_class = NoPagination + filterset_fields = None + filterset_class = EntryFilter + + +class NoFiltersetEntryViewSet(EntryViewSet): + """ + like above but no filtersets + """ + pagination_class = NoPagination + filterset_fields = None + filterset_class = None class AuthorViewSet(ModelViewSet): diff --git a/rest_framework_json_api/filters.py b/rest_framework_json_api/filters.py index 748b18bf..cf3257ad 100644 --- a/rest_framework_json_api/filters.py +++ b/rest_framework_json_api/filters.py @@ -1,8 +1,10 @@ +from django_filters.rest_framework import DjangoFilterBackend from rest_framework.exceptions import ValidationError from rest_framework.filters import OrderingFilter +from rest_framework.settings import api_settings from rest_framework_json_api.utils import format_value - +import re class JSONAPIOrderingFilter(OrderingFilter): """ @@ -42,3 +44,88 @@ def remove_invalid_fields(self, queryset, fields, view, request): return super(JSONAPIOrderingFilter, self).remove_invalid_fields( queryset, underscore_fields, view, request) + + +class JSONAPIDjangoFilter(DjangoFilterBackend): + """ + A Django-style ORM filter implementation, using `django-filter`. + + This is not part of the jsonapi standard per-se, other than the requirement + to use the `filter` keyword: This is an optional implementation of style of + filtering in which each filter is an ORM expression as implemented by + DjangoFilterBackend and seems to be in alignment with an interpretation of + http://jsonapi.org/recommendations/#filtering, including relationship + chaining. + + Filters can be: + - A resource field equality test: + `?filter[foo]=123` + - Apply other relational operators: + `?filter[foo.in]=bar,baz or ?filter[name.isnull]=true...` + - Membership in a list of values (OR): + `?filter[foo]=abc,123,zzz (foo in ['abc','123','zzz'])` + - Filters can be combined for intersection (AND): + `?filter[foo]=123&filter[bar]=abc,123,zzz&filter[...]` + - A related resource path for above tests: + `?filter[foo.rel.baz]=123 (where `rel` is the relationship name)` + + If you are also using rest_framework.filters.SearchFilter you'll want to customize + the name of the query parameter for searching to make sure it doesn't conflict + with a field name defined in the filterset. + The recommended value is: `search_param="filter[search]"` but just make sure it's + `filter[<something>]` to comply with the jsonapi spec requirement to use the filter + keyword. The default is "search" unless overriden but it's used here just to make sure + we don't complain about it being an invalid filter. + + TODO: find a better way to deal with search_param. + """ + search_param = api_settings.SEARCH_PARAM + # since 'filter' passes query parameter validation but is still invalid, + # make this regex check for it but not set `filter` regex group. + filter_regex = re.compile(r'^filter(?P<ldelim>\W*)(?P<assoc>[\w._]*)(?P<rdelim>\W*$)') + + def get_filterset(self, request, queryset, view): + """ + Sometimes there's no filterset_class defined yet the client still + requests a filter. Make sure they see an error too. This means + we have to get_filterset_kwargs() even if there's no filterset_class. + + TODO: .base_filters vs. .filters attr (not always present) + """ + filterset_class = self.get_filterset_class(view, queryset) + kwargs = self.get_filterset_kwargs(request, queryset, view) + for k in self.filter_keys: + if ((not filterset_class) + or (k not in filterset_class.base_filters)): + raise ValidationError("invalid filter[{}]".format(k)) + if filterset_class is None: + return None + return filterset_class(**kwargs) + + def get_filterset_kwargs(self, request, queryset, view): + """ + Turns filter[<field>]=<value> into <field>=<value> which is what + DjangoFilterBackend expects + """ + self.filter_keys = [] + # rewrite filter[field] query params to make DjangoFilterBackend work. + data = request.query_params.copy() + for qp, val in data.items(): + m = self.filter_regex.match(qp) + if m and (not m['assoc'] or m['ldelim'] != '[' or m['rdelim'] != ']'): + raise ValidationError("invalid filter: {}".format(qp)) + if m and qp != self.search_param: + if not val: + raise ValidationError("missing {} test value".format(qp)) + # convert jsonapi relationship path to Django ORM's __ notation + key = m['assoc'].replace('.', '__') + # undo JSON_API_FORMAT_FIELD_NAMES conversion: + key = format_value(key, 'underscore') + data[key] = val + self.filter_keys.append(key) + del data[qp] + return { + 'data': data, + 'queryset': queryset, + 'request': request, + } From dc5ca3894c59bce9b65896e762f686a52df0ad98 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Fri, 24 Aug 2018 15:03:40 -0400 Subject: [PATCH 02/26] documentation, isort, flake8 --- CHANGELOG.md | 2 +- docs/usage.md | 55 +++++++++++++++++++++++++++--- example/tests/test_filters.py | 7 ++-- example/urls_test.py | 2 +- example/views.py | 4 +-- requirements-development.txt | 2 +- rest_framework_json_api/filters.py | 34 +++++++++++------- setup.py | 3 +- 8 files changed, 82 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 354c1b5c..b61f677e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,9 @@ * Add `HyperlinkedRelatedField` and `SerializerMethodHyperlinkedRelatedField`. See [usage docs](docs/usage.md#related-fields) * Add related urls support. See [usage docs](docs/usage.md#related-urls) * Replaced binary `drf_example` sqlite3 db with a [fixture](example/fixtures/drf_example.yaml). See [usage docs](docs/usage.md#running-the-example-app). -* Add optional [jsonapi-style](http://jsonapi.org/format/) sort filter backend. See [usage docs](docs/usage.md#filter-backends) * For naming consistency, renamed new `JsonApi`-prefix pagination classes to `JSONAPI`-prefix. * Deprecates `JsonApiPageNumberPagination` and `JsonApiLimitOffsetPagination` +* Add optional [jsonapi-style](http://jsonapi.org/format/) filter backends. See [usage docs](docs/usage.md#filter-backends) v2.5.0 - Released July 11, 2018 diff --git a/docs/usage.md b/docs/usage.md index b9d89ecf..010c58d4 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -34,6 +34,7 @@ REST_FRAMEWORK = { 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( 'rest_framework_json_api.filters.JSONAPIOrderingFilter', + 'rest_framework_json_api.filters.JSONAPIDjangoFilter', ), 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', @@ -92,14 +93,14 @@ class MyLimitPagination(JSONAPILimitOffsetPagination): ### Filter Backends -_This is the first of several anticipated JSON:API-specific filter backends._ +_There are several anticipated JSON:API-specific filter backends in development. The first two are described below._ #### `JSONAPIOrderingFilter` `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 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 +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,10 +119,56 @@ 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`. +#### `JSONAPIDjangoFilter` +`JSONAPIDjangoFilter` implements a Django ORM-style [JSON:API `filter`](http://jsonapi.org/format/#fetching-filtering) +using the [django-filter](https://django-filter.readthedocs.io/) package. + +This filter is not part of the JSON:API standard per-se, other than the requirement +to use the `filter` keyword: It is an optional implementation of a style of +filtering in which each filter is an ORM expression as implemented by +`DjangoFilterBackend` and seems to be in alignment with an interpretation of the +[JSON:API _recommendations_](http://jsonapi.org/recommendations/#filtering), including relationship +chaining. + +Filters can be: +- A resource field equality test: + `?filter[qty]=123` +- Apply other [field lookup](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups) operators: + `?filter[name.icontains]=bar` or `?filter[name.isnull]=true` +- Membership in a list of values: + `?filter[name.in]=abc,123,zzz (name in ['abc','123','zzz'])` +- Filters can be combined for intersection (AND): + `?filter[qty]=123&filter[name.in]=abc,123,zzz&filter[...]` +- A related resource path can be used: + `?filter[inventory.item.partNum]=123456` (where `inventory.item` is the relationship path) + +If you are also using [`rest_framework.filters.SearchFilter`](https://django-rest-framework.readthedocs.io/en/latest/api-guide/filtering/#searchfilter) +(which performs single parameter searchs across multiple fields) you'll want to customize the name of the query +parameter for searching to make sure it doesn't conflict with a field name defined in the filterset. +The recommended value is: `search_param="filter[search]"` but just make sure it's +`filter[_something_]` to comply with the jsonapi spec requirement to use the filter +keyword. The default is "search" unless overriden. + +The filter returns a `400 Bad Request` error for invalid filter query parameters as in this example +for `GET http://127.0.0.1:8000/nopage-entries?filter[bad]=1`: +```json +{ + "errors": [ + { + "detail": "invalid filter[bad]", + "source": { + "pointer": "/data" + }, + "status": "400" + } + ] +} +``` + #### 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: +in the [example settings](#configuration) or individually add them as `.filter_backends` View attributes: ```python from rest_framework_json_api import filters @@ -129,7 +176,7 @@ from rest_framework_json_api import filters class MyViewset(ModelViewSet): queryset = MyModel.objects.all() serializer_class = MyModelSerializer - filter_backends = (filters.JSONAPIOrderingFilter,) + filter_backends = (filters.JSONAPIOrderingFilter, filters.JSONAPIDjangoFilter,) ``` diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index d0e8974c..203edf9f 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -208,7 +208,7 @@ def test_filter_fields_union_list(self): """ test field for a list of values(ORed): ?filter[field.in]': 'val1,val2,val3 """ - response = self.client.get(self.url, + response = self.client.get(self.url, data={'filter[headline.in]': 'CLCV2442V,XXX,BIOL3594X'}) dja_response = response.json() self.assertEqual(response.status_code, 200, @@ -225,7 +225,7 @@ def test_filter_fields_intersection(self): test fields (ANDed): ?filter[field1]': 'val1&filter[field2]'='val2 """ # - response = self.client.get(self.url, + response = self.client.get(self.url, data={'filter[headline.regex]': '^A', 'filter[body_text.icontains]': 'in'}) self.assertEqual(response.status_code, 200, @@ -285,7 +285,7 @@ def test_filter_no_brackets_equal(self): """ test for `filter` with missing filter[association] name and =value """ - response = self.client.get(self.url +'?filter') + response = self.client.get(self.url + '?filter') self.assertEqual(response.status_code, 400, msg=response.content.decode("utf-8")) dja_response = response.json() @@ -349,4 +349,3 @@ def test_filter_missing_rvalue_equal(self): dja_response = response.json() self.assertEqual(dja_response['errors'][0]['detail'], "missing filter[headline] test value") - diff --git a/example/urls_test.py b/example/urls_test.py index 8475bb79..e2b8ef27 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -12,9 +12,9 @@ CompanyViewset, EntryRelationshipView, EntryViewSet, - NonPaginatedEntryViewSet, FiltersetEntryViewSet, NoFiltersetEntryViewSet, + NonPaginatedEntryViewSet, ProjectViewset ) diff --git a/example/views.py b/example/views.py index 288d17e7..f3d34396 100644 --- a/example/views.py +++ b/example/views.py @@ -1,6 +1,7 @@ +import rest_framework.exceptions as exceptions import rest_framework.parsers import rest_framework.renderers -from rest_framework import exceptions +from django_filters import rest_framework as filters import rest_framework_json_api.metadata import rest_framework_json_api.parsers @@ -8,7 +9,6 @@ from rest_framework_json_api.pagination import PageNumberPagination from rest_framework_json_api.utils import format_drf_errors from rest_framework_json_api.views import ModelViewSet, RelationshipView -from django_filters import rest_framework as filters from example.models import Author, Blog, Comment, Company, Entry, Project from example.serializers import ( diff --git a/requirements-development.txt b/requirements-development.txt index e2e8aae3..d578ffb7 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -14,4 +14,4 @@ Sphinx sphinx_rtd_theme tox twine - +django-filter>=2.0 diff --git a/rest_framework_json_api/filters.py b/rest_framework_json_api/filters.py index cf3257ad..50e9e3ee 100644 --- a/rest_framework_json_api/filters.py +++ b/rest_framework_json_api/filters.py @@ -1,10 +1,18 @@ -from django_filters.rest_framework import DjangoFilterBackend +import re + from rest_framework.exceptions import ValidationError from rest_framework.filters import OrderingFilter from rest_framework.settings import api_settings from rest_framework_json_api.utils import format_value -import re + +try: + from django_filters.rest_framework import DjangoFilterBackend +except ImportError as e: + class DjangoFilterBackend(object): + def __init__(self): + raise ImportError("must install django-filter package to use JSONAPIDjangoFilter") + class JSONAPIOrderingFilter(OrderingFilter): """ @@ -55,19 +63,20 @@ class JSONAPIDjangoFilter(DjangoFilterBackend): filtering in which each filter is an ORM expression as implemented by DjangoFilterBackend and seems to be in alignment with an interpretation of http://jsonapi.org/recommendations/#filtering, including relationship - chaining. + chaining. It also returns a 400 error for invalid filters. Filters can be: - A resource field equality test: - `?filter[foo]=123` - - Apply other relational operators: - `?filter[foo.in]=bar,baz or ?filter[name.isnull]=true...` - - Membership in a list of values (OR): - `?filter[foo]=abc,123,zzz (foo in ['abc','123','zzz'])` + `?filter[qty]=123` + - Apply other [field lookup](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups) # noqa: E501 + operators: + `?filter[name.icontains]=bar` or `?filter[name.isnull]=true...` + - Membership in a list of values: + `?filter[name.in]=abc,123,zzz (name in ['abc','123','zzz'])` - Filters can be combined for intersection (AND): - `?filter[foo]=123&filter[bar]=abc,123,zzz&filter[...]` - - A related resource path for above tests: - `?filter[foo.rel.baz]=123 (where `rel` is the relationship name)` + `?filter[qty]=123&filter[name.in]=abc,123,zzz&filter[...]` + - A related resource path can be used: + `?filter[inventory.item.partNum]=123456 (where `inventory.item` is the relationship path)` If you are also using rest_framework.filters.SearchFilter you'll want to customize the name of the query parameter for searching to make sure it doesn't conflict @@ -95,8 +104,7 @@ def get_filterset(self, request, queryset, view): filterset_class = self.get_filterset_class(view, queryset) kwargs = self.get_filterset_kwargs(request, queryset, view) for k in self.filter_keys: - if ((not filterset_class) - or (k not in filterset_class.base_filters)): + if ((not filterset_class) or (k not in filterset_class.base_filters)): raise ValidationError("invalid filter[{}]".format(k)) if filterset_class is None: return None diff --git a/setup.py b/setup.py index 7ff381e2..144866b9 100755 --- a/setup.py +++ b/setup.py @@ -111,7 +111,8 @@ def get_package_data(package): 'pytest-cov', 'django-polymorphic>=2.0', 'packaging', - 'django-debug-toolbar' + 'django-debug-toolbar', + 'django-filter>=2.0' ] + mock, zip_safe=False, ) From 6b0dc8c9ee7cdef4dee6e1ae9e1017e3d3bad42b Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Fri, 24 Aug 2018 17:12:28 -0400 Subject: [PATCH 03/26] Forgot to add django_filters to installed_apps Not sure it's needed, but the docmentation says to do it. --- example/settings/dev.py | 1 + 1 file changed, 1 insertion(+) diff --git a/example/settings/dev.py b/example/settings/dev.py index 720d2396..c6da48a8 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -26,6 +26,7 @@ 'polymorphic', 'example', 'debug_toolbar', + 'django_filters', ] TEMPLATES = [ From d4fbf24555f1bc933f833b5f76e71def624c4823 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Fri, 24 Aug 2018 17:17:29 -0400 Subject: [PATCH 04/26] backwards compatibility for py27 + django-filter --- example/views.py | 5 +++++ rest_framework_json_api/filters.py | 28 +++++++++++++++++++++++----- setup.py | 3 +-- tox.ini | 7 +++++-- 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/example/views.py b/example/views.py index f3d34396..f24976fd 100644 --- a/example/views.py +++ b/example/views.py @@ -103,6 +103,7 @@ class NonPaginatedEntryViewSet(EntryViewSet): 'blog__name': rels, 'blog__tagline': rels, } + filter_fields = filterset_fields # django-filter<=1.11 (required for py27) class EntryFilter(filters.FilterSet): @@ -121,6 +122,8 @@ class FiltersetEntryViewSet(EntryViewSet): pagination_class = NoPagination filterset_fields = None filterset_class = EntryFilter + filter_fields = filterset_fields # django-filter<=1.11 + filter_class = filterset_class class NoFiltersetEntryViewSet(EntryViewSet): @@ -130,6 +133,8 @@ class NoFiltersetEntryViewSet(EntryViewSet): pagination_class = NoPagination filterset_fields = None filterset_class = None + filter_fields = filterset_fields # django-filter<=1.11 + filter_class = filterset_class class AuthorViewSet(ModelViewSet): diff --git a/rest_framework_json_api/filters.py b/rest_framework_json_api/filters.py index 50e9e3ee..7470cdfe 100644 --- a/rest_framework_json_api/filters.py +++ b/rest_framework_json_api/filters.py @@ -93,6 +93,11 @@ class JSONAPIDjangoFilter(DjangoFilterBackend): # make this regex check for it but not set `filter` regex group. filter_regex = re.compile(r'^filter(?P<ldelim>\W*)(?P<assoc>[\w._]*)(?P<rdelim>\W*$)') + def validate_filter(self, keys, filterset_class): + for k in keys: + if ((not filterset_class) or (k not in filterset_class.base_filters)): + raise ValidationError("invalid filter[{}]".format(k)) + def get_filterset(self, request, queryset, view): """ Sometimes there's no filterset_class defined yet the client still @@ -103,9 +108,7 @@ def get_filterset(self, request, queryset, view): """ filterset_class = self.get_filterset_class(view, queryset) kwargs = self.get_filterset_kwargs(request, queryset, view) - for k in self.filter_keys: - if ((not filterset_class) or (k not in filterset_class.base_filters)): - raise ValidationError("invalid filter[{}]".format(k)) + self.validate_filter(self.filter_keys, filterset_class) if filterset_class is None: return None return filterset_class(**kwargs) @@ -120,13 +123,14 @@ def get_filterset_kwargs(self, request, queryset, view): data = request.query_params.copy() for qp, val in data.items(): m = self.filter_regex.match(qp) - if m and (not m['assoc'] or m['ldelim'] != '[' or m['rdelim'] != ']'): + if m and (not m.groupdict()['assoc'] or + m.groupdict()['ldelim'] != '[' or m.groupdict()['rdelim'] != ']'): raise ValidationError("invalid filter: {}".format(qp)) if m and qp != self.search_param: if not val: raise ValidationError("missing {} test value".format(qp)) # convert jsonapi relationship path to Django ORM's __ notation - key = m['assoc'].replace('.', '__') + key = m.groupdict()['assoc'].replace('.', '__') # undo JSON_API_FORMAT_FIELD_NAMES conversion: key = format_value(key, 'underscore') data[key] = val @@ -137,3 +141,17 @@ def get_filterset_kwargs(self, request, queryset, view): 'queryset': queryset, 'request': request, } + + def filter_queryset(self, request, queryset, view): + """ + backwards compatibility to 1.1 + """ + filter_class = self.get_filter_class(view, queryset) + + kwargs = self.get_filterset_kwargs(request, queryset, view) + self.validate_filter(self.filter_keys, filter_class) + + if filter_class: + return filter_class(kwargs['data'], queryset=queryset, request=request).qs + + return queryset diff --git a/setup.py b/setup.py index 144866b9..7ff381e2 100755 --- a/setup.py +++ b/setup.py @@ -111,8 +111,7 @@ def get_package_data(package): 'pytest-cov', 'django-polymorphic>=2.0', 'packaging', - 'django-debug-toolbar', - 'django-filter>=2.0' + 'django-debug-toolbar' ] + mock, zip_safe=False, ) diff --git a/tox.ini b/tox.ini index d5cca046..e242ba2a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,8 @@ [tox] envlist = - py{27,34,35,36}-django111-drf{36,37,38}, - py{34,35,36}-django20-drf{37,38}, + py27-df11-django111-drf{36,37,38} + py{34,35,36}-df20-django111-drf{36,37,38}, + py{34,35,36}-df20-django20-drf{37,38}, [testenv] deps = @@ -10,6 +11,8 @@ deps = drf36: djangorestframework>=3.6.3,<3.7 drf37: djangorestframework>=3.7.0,<3.8 drf38: djangorestframework>=3.8.0,<3.9 + df11: django-filter<=1.1 + df20: django-filter>=2.0 setenv = PYTHONPATH = {toxinidir} From d86d217c78af0ae8c614d555933377705a980b37 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Fri, 24 Aug 2018 18:22:13 -0400 Subject: [PATCH 05/26] handle optional django-filter package - allow the example app to still run, just failing any JSONAPIDjangoFilter tests. - split the two filters into separate files for easier maintenance. --- example/settings/dev.py | 7 +- example/views.py | 20 ++- rest_framework_json_api/filters.py | 157 -------------------- rest_framework_json_api/filters/__init__.py | 2 + rest_framework_json_api/filters/filter.py | 121 +++++++++++++++ rest_framework_json_api/filters/sort.py | 44 ++++++ 6 files changed, 186 insertions(+), 165 deletions(-) delete mode 100644 rest_framework_json_api/filters.py create mode 100644 rest_framework_json_api/filters/__init__.py create mode 100644 rest_framework_json_api/filters/filter.py create mode 100644 rest_framework_json_api/filters/sort.py diff --git a/example/settings/dev.py b/example/settings/dev.py index c6da48a8..67c3a680 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -26,9 +26,14 @@ 'polymorphic', 'example', 'debug_toolbar', - 'django_filters', ] +try: + import django_filters # noqa: 401 + INSTALLED_APPS += ['django_filters'] +except ImportError: + pass + TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', diff --git a/example/views.py b/example/views.py index f24976fd..15512312 100644 --- a/example/views.py +++ b/example/views.py @@ -1,7 +1,6 @@ import rest_framework.exceptions as exceptions import rest_framework.parsers import rest_framework.renderers -from django_filters import rest_framework as filters import rest_framework_json_api.metadata import rest_framework_json_api.parsers @@ -106,13 +105,20 @@ class NonPaginatedEntryViewSet(EntryViewSet): filter_fields = filterset_fields # django-filter<=1.11 (required for py27) -class EntryFilter(filters.FilterSet): - bname = filters.CharFilter(field_name="blog__name", - lookup_expr="exact") +# While this example is used for testing with django-filter, leave the option of running it without. +# The test cases will fail, but the app will run. +try: + from django_filters import rest_framework as filters - class Meta: - model = Entry - fields = ['id', 'headline', 'body_text'] + class EntryFilter(filters.FilterSet): + bname = filters.CharFilter(field_name="blog__name", + lookup_expr="exact") + + class Meta: + model = Entry + fields = ['id', 'headline', 'body_text'] +except ImportError: + EntryFilter = None class FiltersetEntryViewSet(EntryViewSet): diff --git a/rest_framework_json_api/filters.py b/rest_framework_json_api/filters.py deleted file mode 100644 index 7470cdfe..00000000 --- a/rest_framework_json_api/filters.py +++ /dev/null @@ -1,157 +0,0 @@ -import re - -from rest_framework.exceptions import ValidationError -from rest_framework.filters import OrderingFilter -from rest_framework.settings import api_settings - -from rest_framework_json_api.utils import format_value - -try: - from django_filters.rest_framework import DjangoFilterBackend -except ImportError as e: - class DjangoFilterBackend(object): - def __init__(self): - raise ImportError("must install django-filter package to use JSONAPIDjangoFilter") - - -class JSONAPIOrderingFilter(OrderingFilter): - """ - This implements http://jsonapi.org/format/#fetching-sorting and raises 400 - 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'` - - 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): - 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.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, underscore_fields, view, request) - - -class JSONAPIDjangoFilter(DjangoFilterBackend): - """ - A Django-style ORM filter implementation, using `django-filter`. - - This is not part of the jsonapi standard per-se, other than the requirement - to use the `filter` keyword: This is an optional implementation of style of - filtering in which each filter is an ORM expression as implemented by - DjangoFilterBackend and seems to be in alignment with an interpretation of - http://jsonapi.org/recommendations/#filtering, including relationship - chaining. It also returns a 400 error for invalid filters. - - Filters can be: - - A resource field equality test: - `?filter[qty]=123` - - Apply other [field lookup](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups) # noqa: E501 - operators: - `?filter[name.icontains]=bar` or `?filter[name.isnull]=true...` - - Membership in a list of values: - `?filter[name.in]=abc,123,zzz (name in ['abc','123','zzz'])` - - Filters can be combined for intersection (AND): - `?filter[qty]=123&filter[name.in]=abc,123,zzz&filter[...]` - - A related resource path can be used: - `?filter[inventory.item.partNum]=123456 (where `inventory.item` is the relationship path)` - - If you are also using rest_framework.filters.SearchFilter you'll want to customize - the name of the query parameter for searching to make sure it doesn't conflict - with a field name defined in the filterset. - The recommended value is: `search_param="filter[search]"` but just make sure it's - `filter[<something>]` to comply with the jsonapi spec requirement to use the filter - keyword. The default is "search" unless overriden but it's used here just to make sure - we don't complain about it being an invalid filter. - - TODO: find a better way to deal with search_param. - """ - search_param = api_settings.SEARCH_PARAM - # since 'filter' passes query parameter validation but is still invalid, - # make this regex check for it but not set `filter` regex group. - filter_regex = re.compile(r'^filter(?P<ldelim>\W*)(?P<assoc>[\w._]*)(?P<rdelim>\W*$)') - - def validate_filter(self, keys, filterset_class): - for k in keys: - if ((not filterset_class) or (k not in filterset_class.base_filters)): - raise ValidationError("invalid filter[{}]".format(k)) - - def get_filterset(self, request, queryset, view): - """ - Sometimes there's no filterset_class defined yet the client still - requests a filter. Make sure they see an error too. This means - we have to get_filterset_kwargs() even if there's no filterset_class. - - TODO: .base_filters vs. .filters attr (not always present) - """ - filterset_class = self.get_filterset_class(view, queryset) - kwargs = self.get_filterset_kwargs(request, queryset, view) - self.validate_filter(self.filter_keys, filterset_class) - if filterset_class is None: - return None - return filterset_class(**kwargs) - - def get_filterset_kwargs(self, request, queryset, view): - """ - Turns filter[<field>]=<value> into <field>=<value> which is what - DjangoFilterBackend expects - """ - self.filter_keys = [] - # rewrite filter[field] query params to make DjangoFilterBackend work. - data = request.query_params.copy() - for qp, val in data.items(): - m = self.filter_regex.match(qp) - if m and (not m.groupdict()['assoc'] or - m.groupdict()['ldelim'] != '[' or m.groupdict()['rdelim'] != ']'): - raise ValidationError("invalid filter: {}".format(qp)) - if m and qp != self.search_param: - if not val: - raise ValidationError("missing {} test value".format(qp)) - # convert jsonapi relationship path to Django ORM's __ notation - key = m.groupdict()['assoc'].replace('.', '__') - # undo JSON_API_FORMAT_FIELD_NAMES conversion: - key = format_value(key, 'underscore') - data[key] = val - self.filter_keys.append(key) - del data[qp] - return { - 'data': data, - 'queryset': queryset, - 'request': request, - } - - def filter_queryset(self, request, queryset, view): - """ - backwards compatibility to 1.1 - """ - filter_class = self.get_filter_class(view, queryset) - - kwargs = self.get_filterset_kwargs(request, queryset, view) - self.validate_filter(self.filter_keys, filter_class) - - if filter_class: - return filter_class(kwargs['data'], queryset=queryset, request=request).qs - - return queryset diff --git a/rest_framework_json_api/filters/__init__.py b/rest_framework_json_api/filters/__init__.py new file mode 100644 index 00000000..98f93295 --- /dev/null +++ b/rest_framework_json_api/filters/__init__.py @@ -0,0 +1,2 @@ +from .sort import JSONAPIOrderingFilter # noqa: F401 +from .filter import JSONAPIDjangoFilter # noqa: F401 diff --git a/rest_framework_json_api/filters/filter.py b/rest_framework_json_api/filters/filter.py new file mode 100644 index 00000000..6d1f86ac --- /dev/null +++ b/rest_framework_json_api/filters/filter.py @@ -0,0 +1,121 @@ +import re + +from rest_framework.exceptions import ValidationError +from rest_framework.settings import api_settings + +from rest_framework_json_api.utils import format_value + +# django-filter is an optional package. Generate a dummy class if it's missing. +try: + from django_filters.rest_framework import DjangoFilterBackend +except ImportError: + class JSONAPIDjangoFilter(object): + + def filter_queryset(self, request, queryset, view): + """ + do nothing + """ + return queryset + +else: + class JSONAPIDjangoFilter(DjangoFilterBackend): + """ + A Django-style ORM filter implementation, using `django-filter`. + + This is not part of the jsonapi standard per-se, other than the requirement + to use the `filter` keyword: This is an optional implementation of style of + filtering in which each filter is an ORM expression as implemented by + DjangoFilterBackend and seems to be in alignment with an interpretation of + http://jsonapi.org/recommendations/#filtering, including relationship + chaining. It also returns a 400 error for invalid filters. + + Filters can be: + - A resource field equality test: + `?filter[qty]=123` + - Apply other [field lookup](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups) # noqa: E501 + operators: + `?filter[name.icontains]=bar` or `?filter[name.isnull]=true...` + - Membership in a list of values: + `?filter[name.in]=abc,123,zzz (name in ['abc','123','zzz'])` + - Filters can be combined for intersection (AND): + `?filter[qty]=123&filter[name.in]=abc,123,zzz&filter[...]` + - A related resource path can be used: + `?filter[inventory.item.partNum]=123456 (where `inventory.item` is the relationship path)` + + If you are also using rest_framework.filters.SearchFilter you'll want to customize + the name of the query parameter for searching to make sure it doesn't conflict + with a field name defined in the filterset. + The recommended value is: `search_param="filter[search]"` but just make sure it's + `filter[<something>]` to comply with the jsonapi spec requirement to use the filter + keyword. The default is "search" unless overriden but it's used here just to make sure + we don't complain about it being an invalid filter. + + TODO: find a better way to deal with search_param. + """ + search_param = api_settings.SEARCH_PARAM + # since 'filter' passes query parameter validation but is still invalid, + # make this regex check for it but not set `filter` regex group. + filter_regex = re.compile(r'^filter(?P<ldelim>\W*)(?P<assoc>[\w._]*)(?P<rdelim>\W*$)') + + def validate_filter(self, keys, filterset_class): + for k in keys: + if ((not filterset_class) or (k not in filterset_class.base_filters)): + raise ValidationError("invalid filter[{}]".format(k)) + + def get_filterset(self, request, queryset, view): + """ + Sometimes there's no filterset_class defined yet the client still + requests a filter. Make sure they see an error too. This means + we have to get_filterset_kwargs() even if there's no filterset_class. + + TODO: .base_filters vs. .filters attr (not always present) + """ + filterset_class = self.get_filterset_class(view, queryset) + kwargs = self.get_filterset_kwargs(request, queryset, view) + self.validate_filter(self.filter_keys, filterset_class) + if filterset_class is None: + return None + return filterset_class(**kwargs) + + def get_filterset_kwargs(self, request, queryset, view): + """ + Turns filter[<field>]=<value> into <field>=<value> which is what + DjangoFilterBackend expects + """ + self.filter_keys = [] + # rewrite filter[field] query params to make DjangoFilterBackend work. + data = request.query_params.copy() + for qp, val in data.items(): + m = self.filter_regex.match(qp) + if m and (not m.groupdict()['assoc'] or + m.groupdict()['ldelim'] != '[' or m.groupdict()['rdelim'] != ']'): + raise ValidationError("invalid filter: {}".format(qp)) + if m and qp != self.search_param: + if not val: + raise ValidationError("missing {} test value".format(qp)) + # convert jsonapi relationship path to Django ORM's __ notation + key = m.groupdict()['assoc'].replace('.', '__') + # undo JSON_API_FORMAT_FIELD_NAMES conversion: + key = format_value(key, 'underscore') + data[key] = val + self.filter_keys.append(key) + del data[qp] + return { + 'data': data, + 'queryset': queryset, + 'request': request, + } + + def filter_queryset(self, request, queryset, view): + """ + backwards compatibility to 1.1 + """ + filter_class = self.get_filter_class(view, queryset) + + kwargs = self.get_filterset_kwargs(request, queryset, view) + self.validate_filter(self.filter_keys, filter_class) + + if filter_class: + return filter_class(kwargs['data'], queryset=queryset, request=request).qs + + return queryset diff --git a/rest_framework_json_api/filters/sort.py b/rest_framework_json_api/filters/sort.py new file mode 100644 index 00000000..748b18bf --- /dev/null +++ b/rest_framework_json_api/filters/sort.py @@ -0,0 +1,44 @@ +from rest_framework.exceptions import ValidationError +from rest_framework.filters import OrderingFilter + +from rest_framework_json_api.utils import format_value + + +class JSONAPIOrderingFilter(OrderingFilter): + """ + This implements http://jsonapi.org/format/#fetching-sorting and raises 400 + 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'` + + 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): + 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.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, underscore_fields, view, request) From 83c4cc060a31b08c19beda646e2df097eee71ba0 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Fri, 24 Aug 2018 19:00:51 -0400 Subject: [PATCH 06/26] fix travis to match new TOXENVs due to django-filter --- .travis.yml | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/.travis.yml b/.travis.yml index 9c102172..694660b9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,44 +5,44 @@ cache: pip matrix: include: - python: 2.7 - env: TOXENV=py27-django111-drf36 + env: TOXENV=py27-df11-django111-drf36 - python: 2.7 - env: TOXENV=py27-django111-drf37 + env: TOXENV=py27-df11-django111-drf37 - python: 2.7 - env: TOXENV=py27-django111-drf38 + env: TOXENV=py27-df11-django111-drf38 - python: 3.4 - env: TOXENV=py34-django111-drf36 + env: TOXENV=py34-df20-django111-drf36 - python: 3.4 - env: TOXENV=py34-django111-drf37 + env: TOXENV=py34-df20-django111-drf37 - python: 3.4 - env: TOXENV=py34-django111-drf38 + env: TOXENV=py34-df20-django111-drf38 - python: 3.4 - env: TOXENV=py34-django20-drf37 + env: TOXENV=py34-df20-django20-drf37 - python: 3.4 - env: TOXENV=py34-django20-drf38 + env: TOXENV=py34-df20-django20-drf38 - python: 3.5 - env: TOXENV=py35-django111-drf36 + env: TOXENV=py35-df20-django111-drf36 - python: 3.5 - env: TOXENV=py35-django111-drf37 + env: TOXENV=py35-df20-django111-drf37 - python: 3.5 - env: TOXENV=py35-django111-drf38 + env: TOXENV=py35-df20-django111-drf38 - python: 3.5 - env: TOXENV=py35-django20-drf37 + env: TOXENV=py35-df20-django20-drf37 - python: 3.5 - env: TOXENV=py35-django20-drf38 + env: TOXENV=py35-df20-django20-drf38 - python: 3.6 - env: TOXENV=py36-django111-drf36 + env: TOXENV=py36-df20-django111-drf36 - python: 3.6 - env: TOXENV=py36-django111-drf37 + env: TOXENV=py36-df20-django111-drf37 - python: 3.6 - env: TOXENV=py36-django111-drf38 + env: TOXENV=py36-df20-django111-drf38 - python: 3.6 - env: TOXENV=py36-django20-drf37 + env: TOXENV=py36-df20-django20-drf37 - python: 3.6 - env: TOXENV=py36-django20-drf38 +x env: TOXENV=py36-df20-django20-drf38 - python: 3.6 env: TOXENV=flake8 From f5792c1dfb84f3f26b7bd528d8761512bc7cd4bb Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Fri, 24 Aug 2018 19:04:31 -0400 Subject: [PATCH 07/26] fixed a typo --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 694660b9..8b073020 100644 --- a/.travis.yml +++ b/.travis.yml @@ -42,7 +42,7 @@ matrix: - python: 3.6 env: TOXENV=py36-df20-django20-drf37 - python: 3.6 -x env: TOXENV=py36-df20-django20-drf38 + env: TOXENV=py36-df20-django20-drf38 - python: 3.6 env: TOXENV=flake8 From cbc9d55aae94887908d16f89be3b8933420bc620 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sat, 25 Aug 2018 16:28:00 -0400 Subject: [PATCH 08/26] add a warning if django-filter is missing and JSONAPIDjangoFilter is used --- rest_framework_json_api/filters/filter.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/rest_framework_json_api/filters/filter.py b/rest_framework_json_api/filters/filter.py index 6d1f86ac..95b11886 100644 --- a/rest_framework_json_api/filters/filter.py +++ b/rest_framework_json_api/filters/filter.py @@ -1,4 +1,5 @@ import re +import warnings from rest_framework.exceptions import ValidationError from rest_framework.settings import api_settings @@ -11,6 +12,13 @@ except ImportError: class JSONAPIDjangoFilter(object): + def __init__(self, *args, **kwargs): + """ + complain that they need django-filter + TODO: should this be a warning or an exception? + """ + warnings.warn("must install django-filter package to use JSONAPIDjangoFilter") + def filter_queryset(self, request, queryset, view): """ do nothing From 2f6ba1d20e4166974d38078e8b96510a90236329 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Mon, 27 Aug 2018 17:04:20 -0400 Subject: [PATCH 09/26] JSONAPIQueryValidationFilter implementation --- README.rst | 2 + docs/usage.md | 21 +++++++++- example/settings/dev.py | 1 + example/tests/test_filters.py | 22 +++++++---- rest_framework_json_api/filters/__init__.py | 1 + .../filters/queryvalidation.py | 39 +++++++++++++++++++ 6 files changed, 76 insertions(+), 10 deletions(-) create mode 100644 rest_framework_json_api/filters/queryvalidation.py diff --git a/README.rst b/README.rst index 7d345185..61e632bd 100644 --- a/README.rst +++ b/README.rst @@ -173,7 +173,9 @@ override ``settings.REST_FRAMEWORK`` ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( + 'rest_framework_json_api.filters.JSONAPIQueryValidationFilter', 'rest_framework_json_api.backends.JSONAPIOrderingFilter', + 'rest_framework_json_api.filters.JSONAPIDjangoFilter', ), 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', diff --git a/docs/usage.md b/docs/usage.md index 010c58d4..b005c0ef 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -33,6 +33,7 @@ REST_FRAMEWORK = { ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( + 'rest_framework_json_api.filters.JSONAPIQueryValidationFilter', 'rest_framework_json_api.filters.JSONAPIOrderingFilter', 'rest_framework_json_api.filters.JSONAPIDjangoFilter', ), @@ -93,7 +94,7 @@ class MyLimitPagination(JSONAPILimitOffsetPagination): ### Filter Backends -_There are several anticipated JSON:API-specific filter backends in development. The first two are described below._ +_There are several anticipated JSON:API-specific filter backends in development. The first three are described below._ #### `JSONAPIOrderingFilter` `JSONAPIOrderingFilter` implements the [JSON:API `sort`](http://jsonapi.org/format/#fetching-sorting) and uses @@ -165,6 +166,21 @@ for `GET http://127.0.0.1:8000/nopage-entries?filter[bad]=1`: } ``` +#### `JSONAPIQueryValidationFilter` +`JSONAPIQueryValidationFilter` validates query parameters to be one of the defined JSON:API query parameters +(sort, include, filter, fields, page) and returns a `400 Bad Request`. If a non-matching query parameter +is used. This can help the client identify misspelled query parameters, for example. + +If you want to add some additional non-standard query parameters, +simply override `.query_regex` adding the new parameters but, "with the additional +requirement that they MUST contain contain at least one non a-z character (U+0061 to U+007A). +It is RECOMMENDED that a U+002D HYPHEN-MINUS, “-“, U+005F LOW LINE, “_”, or capital letter is +used (e.g. camelCasing)." -- http://jsonapi.org/format/#query-parameters + +If you don't care if non-JSON:API query parameters are allowed (and potentially silently ignored), +simply don't use this filter backend. + + #### Configuring Filter Backends You can configure the filter backends either by setting the `REST_FRAMEWORK['DEFAULT_FILTER_BACKENDS']` as shown @@ -176,7 +192,8 @@ from rest_framework_json_api import filters class MyViewset(ModelViewSet): queryset = MyModel.objects.all() serializer_class = MyModelSerializer - filter_backends = (filters.JSONAPIOrderingFilter, filters.JSONAPIDjangoFilter,) + filter_backends = (filters.JSONAPIQueryValidationFilter, + filters.JSONAPIOrderingFilter, filters.JSONAPIDjangoFilter,) ``` diff --git a/example/settings/dev.py b/example/settings/dev.py index 67c3a680..ee865266 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -95,6 +95,7 @@ ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( + 'rest_framework_json_api.filters.JSONAPIQueryValidationFilter', 'rest_framework_json_api.filters.JSONAPIOrderingFilter', 'rest_framework_json_api.filters.JSONAPIDjangoFilter', ), diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index 203edf9f..b3c46d95 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -251,13 +251,16 @@ def test_filter_invalid_association_name(self): def test_filter_empty_association_name(self): """ test for filter with missing association name + error texts are different depending on whether JSONAPIQueryValidationFilter is in use. + TODO: Just change the "invalid filter" to "invalid query parameter" in JSONAPIDjangoFilter? """ response = self.client.get(self.url, data={'filter[]': 'foobar'}) self.assertEqual(response.status_code, 400, msg=response.content.decode("utf-8")) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "invalid filter: filter[]") + self.assertIn(dja_response['errors'][0]['detail'], + ["invalid filter: filter[]", + "invalid query parameter: filter[]"]) def test_filter_no_brackets(self): """ @@ -300,8 +303,9 @@ def test_filter_malformed_left_bracket(self): self.assertEqual(response.status_code, 400, msg=response.content.decode("utf-8")) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "invalid filter: filter[") + self.assertIn(dja_response['errors'][0]['detail'], + ["invalid filter: filter[", + "invalid query parameter: filter["]) def test_filter_missing_right_bracket(self): """ @@ -311,8 +315,9 @@ def test_filter_missing_right_bracket(self): self.assertEqual(response.status_code, 400, msg=response.content.decode("utf-8")) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "invalid filter: filter[headline") + self.assertIn(dja_response['errors'][0]['detail'], + ["invalid filter: filter[headline", + "invalid query parameter: filter[headline"]) def test_filter_incorrect_brackets(self): """ @@ -322,8 +327,9 @@ def test_filter_incorrect_brackets(self): self.assertEqual(response.status_code, 400, msg=response.content.decode("utf-8")) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "invalid filter: filter{headline}") + self.assertIn(dja_response['errors'][0]['detail'], + ["invalid filter: filter{headline}", + "invalid query parameter: filter{headline}"]) def test_filter_missing_rvalue(self): """ diff --git a/rest_framework_json_api/filters/__init__.py b/rest_framework_json_api/filters/__init__.py index 98f93295..4c053acf 100644 --- a/rest_framework_json_api/filters/__init__.py +++ b/rest_framework_json_api/filters/__init__.py @@ -1,2 +1,3 @@ from .sort import JSONAPIOrderingFilter # noqa: F401 from .filter import JSONAPIDjangoFilter # noqa: F401 +from .queryvalidation import JSONAPIQueryValidationFilter # noqa: F401 diff --git a/rest_framework_json_api/filters/queryvalidation.py b/rest_framework_json_api/filters/queryvalidation.py new file mode 100644 index 00000000..43cd690d --- /dev/null +++ b/rest_framework_json_api/filters/queryvalidation.py @@ -0,0 +1,39 @@ +import re + +from rest_framework.exceptions import ValidationError +from rest_framework.filters import BaseFilterBackend + + +class JSONAPIQueryValidationFilter(BaseFilterBackend): + """ + A backend filter that performs strict validation of query parameters for + jsonapi spec conformance and raises a 400 error if non-conforming usage is + found. + + If you want to add some additional non-standard query parameters, + simply override `.query_regex` adding the new parameters but, "with the additional + requirement that they MUST contain contain at least one non a-z character (U+0061 to U+007A). + It is RECOMMENDED that a U+002D HYPHEN-MINUS, “-“, U+005F LOW LINE, “_”, or capital letter is + used (e.g. camelCasing)." -- http://jsonapi.org/format/#query-parameters + + TODO: For jsonapi error object conformance, must set jsonapi errors + "parameter" for the ValidationError. This requires extending DRF/DJA Exceptions. + """ + # sort and include stand alone; filter, fields, page have []'s + query_regex = re.compile(r'^(sort|include)$|^(filter|fields|page)(\[[\w\.\-]+\])?$') + + def validate_query_params(self, request): + """ + Validate that query params are in the list of valid query keywords + Raises ValidationError if not. + """ + for qp in request.query_params.keys(): + if not self.query_regex.match(qp): + raise ValidationError('invalid query parameter: {}'.format(qp)) + if len(request.query_params.getlist(qp)) > 1: + raise ValidationError( + 'repeated query parameter not allowed: {}'.format(qp)) + + def filter_queryset(self, request, queryset, view): + self.validate_query_params(request) + return queryset From 4f2b75bc1d93b04a324babade733777b5010fa2a Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Tue, 28 Aug 2018 10:52:01 -0400 Subject: [PATCH 10/26] improve filter_regex - Had a mistake (unquoted '.') and missing '-' as an allowed character. Also '_' already in '\w' - Don't be so exhaustive in testing for invalid filters; let JSONAPIQueryValidationFilter (when available) deal with that. --- example/tests/test_filters.py | 11 ----------- rest_framework_json_api/filters/filter.py | 16 +++++++++++----- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index 203edf9f..273fe9ac 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -314,17 +314,6 @@ def test_filter_missing_right_bracket(self): self.assertEqual(dja_response['errors'][0]['detail'], "invalid filter: filter[headline") - def test_filter_incorrect_brackets(self): - """ - test for filter with incorrect brackets - """ - response = self.client.get(self.url, data={'filter{headline}': 'foobar'}) - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) - dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "invalid filter: filter{headline}") - def test_filter_missing_rvalue(self): """ test for filter with missing value to test against diff --git a/rest_framework_json_api/filters/filter.py b/rest_framework_json_api/filters/filter.py index 95b11886..70b86cb4 100644 --- a/rest_framework_json_api/filters/filter.py +++ b/rest_framework_json_api/filters/filter.py @@ -57,13 +57,19 @@ class JSONAPIDjangoFilter(DjangoFilterBackend): `filter[<something>]` to comply with the jsonapi spec requirement to use the filter keyword. The default is "search" unless overriden but it's used here just to make sure we don't complain about it being an invalid filter. - - TODO: find a better way to deal with search_param. """ + # TODO: find a better way to deal with search_param. search_param = api_settings.SEARCH_PARAM - # since 'filter' passes query parameter validation but is still invalid, - # make this regex check for it but not set `filter` regex group. - filter_regex = re.compile(r'^filter(?P<ldelim>\W*)(?P<assoc>[\w._]*)(?P<rdelim>\W*$)') + + # Make this regex check for 'filter' as well as 'filter[...]' + # Leave other incorrect usages of 'filter' to JSONAPIQueryValidationFilter. + # See http://jsonapi.org/format/#document-member-names for allowed characters + # and http://jsonapi.org/format/#document-member-names-reserved-characters for reserved + # characters (for use in paths, lists or as delimiters). + # regex `\w` matches [a-zA-Z0-9_]. + # TODO: U+0080 and above allowed but not recommended. Leave them out for now. Fix later? + # Also, ' ' (space) is allowed within a member name but not recommended. + filter_regex = re.compile(r'^filter(?P<ldelim>\[?)(?P<assoc>[\w\.\-]*)(?P<rdelim>\]?$)') def validate_filter(self, keys, filterset_class): for k in keys: From 2742d6087f6752e9aa3b5a793101c6e403151999 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Wed, 29 Aug 2018 13:46:08 -0400 Subject: [PATCH 11/26] rename tests from filter to param --- example/tests/test_filters.py | 47 +++++++++++++++++------------------ 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index b3c46d95..36fd92fe 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -307,30 +307,6 @@ def test_filter_malformed_left_bracket(self): ["invalid filter: filter[", "invalid query parameter: filter["]) - def test_filter_missing_right_bracket(self): - """ - test for filter missing right bracket - """ - response = self.client.get(self.url, data={'filter[headline': 'foobar'}) - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) - dja_response = response.json() - self.assertIn(dja_response['errors'][0]['detail'], - ["invalid filter: filter[headline", - "invalid query parameter: filter[headline"]) - - def test_filter_incorrect_brackets(self): - """ - test for filter with incorrect brackets - """ - response = self.client.get(self.url, data={'filter{headline}': 'foobar'}) - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) - dja_response = response.json() - self.assertIn(dja_response['errors'][0]['detail'], - ["invalid filter: filter{headline}", - "invalid query parameter: filter{headline}"]) - def test_filter_missing_rvalue(self): """ test for filter with missing value to test against @@ -355,3 +331,26 @@ def test_filter_missing_rvalue_equal(self): dja_response = response.json() self.assertEqual(dja_response['errors'][0]['detail'], "missing filter[headline] test value") + + def test_param_missing_right_bracket(self): + """ + test for filter missing right bracket + """ + response = self.client.get(self.url, data={'filter[headline': 'foobar'}) + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid query parameter: filter[headline") + + def test_param_incorrect_brackets(self): + """ + test for filter with incorrect brackets + """ + response = self.client.get(self.url, data={'filter{headline}': 'foobar'}) + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid query parameter: filter{headline}") + From 6a8d7aef0f5a87ee0f856b959c79f122c32aac92 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Wed, 29 Aug 2018 16:20:48 -0400 Subject: [PATCH 12/26] easy changes recommended by @sliverc review --- example/requirements.txt | 2 +- example/settings/dev.py | 7 +- example/views.py | 26 +-- rest_framework_json_api/filters/filter.py | 240 ++++++++++------------ 4 files changed, 126 insertions(+), 149 deletions(-) diff --git a/example/requirements.txt b/example/requirements.txt index fe28eddc..58ce43b2 100644 --- a/example/requirements.txt +++ b/example/requirements.txt @@ -11,4 +11,4 @@ pyparsing pytz six sqlparse - +django-filter>=2.0 diff --git a/example/settings/dev.py b/example/settings/dev.py index 67c3a680..c6da48a8 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -26,14 +26,9 @@ 'polymorphic', 'example', 'debug_toolbar', + 'django_filters', ] -try: - import django_filters # noqa: 401 - INSTALLED_APPS += ['django_filters'] -except ImportError: - pass - TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', diff --git a/example/views.py b/example/views.py index 15512312..f3b9ccec 100644 --- a/example/views.py +++ b/example/views.py @@ -5,6 +5,7 @@ import rest_framework_json_api.metadata import rest_framework_json_api.parsers import rest_framework_json_api.renderers +from django_filters import rest_framework as filters from rest_framework_json_api.pagination import PageNumberPagination from rest_framework_json_api.utils import format_drf_errors from rest_framework_json_api.views import ModelViewSet, RelationshipView @@ -102,23 +103,16 @@ class NonPaginatedEntryViewSet(EntryViewSet): 'blog__name': rels, 'blog__tagline': rels, } - filter_fields = filterset_fields # django-filter<=1.11 (required for py27) + filter_fields = filterset_fields # django-filter<=1.1 (required for py27) -# While this example is used for testing with django-filter, leave the option of running it without. -# The test cases will fail, but the app will run. -try: - from django_filters import rest_framework as filters +class EntryFilter(filters.FilterSet): + bname = filters.CharFilter(field_name="blog__name", + lookup_expr="exact") - class EntryFilter(filters.FilterSet): - bname = filters.CharFilter(field_name="blog__name", - lookup_expr="exact") - - class Meta: - model = Entry - fields = ['id', 'headline', 'body_text'] -except ImportError: - EntryFilter = None + class Meta: + model = Entry + fields = ['id', 'headline', 'body_text'] class FiltersetEntryViewSet(EntryViewSet): @@ -128,7 +122,7 @@ class FiltersetEntryViewSet(EntryViewSet): pagination_class = NoPagination filterset_fields = None filterset_class = EntryFilter - filter_fields = filterset_fields # django-filter<=1.11 + filter_fields = filterset_fields # django-filter<=1.1 filter_class = filterset_class @@ -139,7 +133,7 @@ class NoFiltersetEntryViewSet(EntryViewSet): pagination_class = NoPagination filterset_fields = None filterset_class = None - filter_fields = filterset_fields # django-filter<=1.11 + filter_fields = filterset_fields # django-filter<=1.1 filter_class = filterset_class diff --git a/rest_framework_json_api/filters/filter.py b/rest_framework_json_api/filters/filter.py index 70b86cb4..42c3cfd1 100644 --- a/rest_framework_json_api/filters/filter.py +++ b/rest_framework_json_api/filters/filter.py @@ -1,135 +1,123 @@ import re -import warnings from rest_framework.exceptions import ValidationError from rest_framework.settings import api_settings +from django_filters import VERSION +from django_filters.rest_framework import DjangoFilterBackend from rest_framework_json_api.utils import format_value -# django-filter is an optional package. Generate a dummy class if it's missing. -try: - from django_filters.rest_framework import DjangoFilterBackend -except ImportError: - class JSONAPIDjangoFilter(object): - - def __init__(self, *args, **kwargs): - """ - complain that they need django-filter - TODO: should this be a warning or an exception? - """ - warnings.warn("must install django-filter package to use JSONAPIDjangoFilter") - - def filter_queryset(self, request, queryset, view): - """ - do nothing - """ - return queryset - -else: - class JSONAPIDjangoFilter(DjangoFilterBackend): + +class JSONAPIDjangoFilter(DjangoFilterBackend): + """ + A Django-style ORM filter implementation, using `django-filter`. + + This is not part of the jsonapi standard per-se, other than the requirement + to use the `filter` keyword: This is an optional implementation of style of + filtering in which each filter is an ORM expression as implemented by + DjangoFilterBackend and seems to be in alignment with an interpretation of + http://jsonapi.org/recommendations/#filtering, including relationship + chaining. It also returns a 400 error for invalid filters. + + Filters can be: + - A resource field equality test: + `?filter[qty]=123` + - Apply other [field lookup](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups) # noqa: E501 + operators: + `?filter[name.icontains]=bar` or `?filter[name.isnull]=true...` + - Membership in a list of values: + `?filter[name.in]=abc,123,zzz (name in ['abc','123','zzz'])` + - Filters can be combined for intersection (AND): + `?filter[qty]=123&filter[name.in]=abc,123,zzz&filter[...]` + - A related resource path can be used: + `?filter[inventory.item.partNum]=123456 (where `inventory.item` is the relationship path)` + + If you are also using rest_framework.filters.SearchFilter you'll want to customize + the name of the query parameter for searching to make sure it doesn't conflict + with a field name defined in the filterset. + The recommended value is: `search_param="filter[search]"` but just make sure it's + `filter[<something>]` to comply with the jsonapi spec requirement to use the filter + keyword. The default is "search" unless overriden but it's used here just to make sure + we don't complain about it being an invalid filter. + """ + # TODO: find a better way to deal with search_param. + search_param = api_settings.SEARCH_PARAM + + # Make this regex check for 'filter' as well as 'filter[...]' + # Leave other incorrect usages of 'filter' to JSONAPIQueryValidationFilter. + # See http://jsonapi.org/format/#document-member-names for allowed characters + # and http://jsonapi.org/format/#document-member-names-reserved-characters for reserved + # characters (for use in paths, lists or as delimiters). + # regex `\w` matches [a-zA-Z0-9_]. + # TODO: U+0080 and above allowed but not recommended. Leave them out for now.e + # Also, ' ' (space) is allowed within a member name but not recommended. + filter_regex = re.compile(r'^filter(?P<ldelim>\[?)(?P<assoc>[\w\.\-]*)(?P<rdelim>\]?$)') + + def _validate_filter(self, keys, filterset_class): + for k in keys: + if ((not filterset_class) or (k not in filterset_class.base_filters)): + raise ValidationError("invalid filter[{}]".format(k)) + + def get_filterset(self, request, queryset, view): + """ + Sometimes there's no filterset_class defined yet the client still + requests a filter. Make sure they see an error too. This means + we have to get_filterset_kwargs() even if there's no filterset_class. + + TODO: .base_filters vs. .filters attr (not always present) + """ + filterset_class = self.get_filterset_class(view, queryset) + kwargs = self.get_filterset_kwargs(request, queryset, view) + self._validate_filter(kwargs.pop('filter_keys'), filterset_class) + if filterset_class is None: + return None + return filterset_class(**kwargs) + + def get_filterset_kwargs(self, request, queryset, view): + """ + Turns filter[<field>]=<value> into <field>=<value> which is what + DjangoFilterBackend expects """ - A Django-style ORM filter implementation, using `django-filter`. - - This is not part of the jsonapi standard per-se, other than the requirement - to use the `filter` keyword: This is an optional implementation of style of - filtering in which each filter is an ORM expression as implemented by - DjangoFilterBackend and seems to be in alignment with an interpretation of - http://jsonapi.org/recommendations/#filtering, including relationship - chaining. It also returns a 400 error for invalid filters. - - Filters can be: - - A resource field equality test: - `?filter[qty]=123` - - Apply other [field lookup](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups) # noqa: E501 - operators: - `?filter[name.icontains]=bar` or `?filter[name.isnull]=true...` - - Membership in a list of values: - `?filter[name.in]=abc,123,zzz (name in ['abc','123','zzz'])` - - Filters can be combined for intersection (AND): - `?filter[qty]=123&filter[name.in]=abc,123,zzz&filter[...]` - - A related resource path can be used: - `?filter[inventory.item.partNum]=123456 (where `inventory.item` is the relationship path)` - - If you are also using rest_framework.filters.SearchFilter you'll want to customize - the name of the query parameter for searching to make sure it doesn't conflict - with a field name defined in the filterset. - The recommended value is: `search_param="filter[search]"` but just make sure it's - `filter[<something>]` to comply with the jsonapi spec requirement to use the filter - keyword. The default is "search" unless overriden but it's used here just to make sure - we don't complain about it being an invalid filter. + filter_keys = [] + # rewrite filter[field] query params to make DjangoFilterBackend work. + data = request.query_params.copy() + for qp, val in data.items(): + m = self.filter_regex.match(qp) + if m and (not m.groupdict()['assoc'] or + m.groupdict()['ldelim'] != '[' or m.groupdict()['rdelim'] != ']'): + raise ValidationError("invalid filter: {}".format(qp)) + if m and qp != self.search_param: + if not val: + raise ValidationError("missing {} test value".format(qp)) + # convert jsonapi relationship path to Django ORM's __ notation + key = m.groupdict()['assoc'].replace('.', '__') + # undo JSON_API_FORMAT_FIELD_NAMES conversion: + key = format_value(key, 'underscore') + data[key] = val + filter_keys.append(key) + del data[qp] + return { + 'data': data, + 'queryset': queryset, + 'request': request, + 'filter_keys': filter_keys, + } + + def filter_queryset(self, request, queryset, view): """ - # TODO: find a better way to deal with search_param. - search_param = api_settings.SEARCH_PARAM - - # Make this regex check for 'filter' as well as 'filter[...]' - # Leave other incorrect usages of 'filter' to JSONAPIQueryValidationFilter. - # See http://jsonapi.org/format/#document-member-names for allowed characters - # and http://jsonapi.org/format/#document-member-names-reserved-characters for reserved - # characters (for use in paths, lists or as delimiters). - # regex `\w` matches [a-zA-Z0-9_]. - # TODO: U+0080 and above allowed but not recommended. Leave them out for now. Fix later? - # Also, ' ' (space) is allowed within a member name but not recommended. - filter_regex = re.compile(r'^filter(?P<ldelim>\[?)(?P<assoc>[\w\.\-]*)(?P<rdelim>\]?$)') - - def validate_filter(self, keys, filterset_class): - for k in keys: - if ((not filterset_class) or (k not in filterset_class.base_filters)): - raise ValidationError("invalid filter[{}]".format(k)) - - def get_filterset(self, request, queryset, view): - """ - Sometimes there's no filterset_class defined yet the client still - requests a filter. Make sure they see an error too. This means - we have to get_filterset_kwargs() even if there's no filterset_class. - - TODO: .base_filters vs. .filters attr (not always present) - """ - filterset_class = self.get_filterset_class(view, queryset) - kwargs = self.get_filterset_kwargs(request, queryset, view) - self.validate_filter(self.filter_keys, filterset_class) - if filterset_class is None: - return None - return filterset_class(**kwargs) - - def get_filterset_kwargs(self, request, queryset, view): - """ - Turns filter[<field>]=<value> into <field>=<value> which is what - DjangoFilterBackend expects - """ - self.filter_keys = [] - # rewrite filter[field] query params to make DjangoFilterBackend work. - data = request.query_params.copy() - for qp, val in data.items(): - m = self.filter_regex.match(qp) - if m and (not m.groupdict()['assoc'] or - m.groupdict()['ldelim'] != '[' or m.groupdict()['rdelim'] != ']'): - raise ValidationError("invalid filter: {}".format(qp)) - if m and qp != self.search_param: - if not val: - raise ValidationError("missing {} test value".format(qp)) - # convert jsonapi relationship path to Django ORM's __ notation - key = m.groupdict()['assoc'].replace('.', '__') - # undo JSON_API_FORMAT_FIELD_NAMES conversion: - key = format_value(key, 'underscore') - data[key] = val - self.filter_keys.append(key) - del data[qp] - return { - 'data': data, - 'queryset': queryset, - 'request': request, - } - - def filter_queryset(self, request, queryset, view): - """ - backwards compatibility to 1.1 - """ - filter_class = self.get_filter_class(view, queryset) - - kwargs = self.get_filterset_kwargs(request, queryset, view) - self.validate_filter(self.filter_keys, filter_class) - - if filter_class: - return filter_class(kwargs['data'], queryset=queryset, request=request).qs - - return queryset + Backwards compatibility to 1.1 (required for Python 2.7) + In 1.1 filter_queryset does not call get_filterset or get_filterset_kwargs. + """ + # TODO: remove when Python 2.7 support is deprecated + if VERSION >= (2, 0, 0): + return super(JSONAPIDjangoFilter, self).filter_queryset(request, queryset, view) + + filter_class = self.get_filter_class(view, queryset) + + kwargs = self.get_filterset_kwargs(request, queryset, view) + self._validate_filter(kwargs.pop('filter_keys'), filter_class) + + if filter_class: + return filter_class(kwargs['data'], queryset=queryset, request=request).qs + + return queryset From db9e1f9b1b8fa7b961767fd15103b094bb4900e6 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Wed, 29 Aug 2018 16:38:26 -0400 Subject: [PATCH 13/26] resolve @sliverc review method of using optional django-filter. See https://github.com/django-json-api/django-rest-framework-json-api/pull/466#discussion_r213599037 --- rest_framework_json_api/filters/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rest_framework_json_api/filters/__init__.py b/rest_framework_json_api/filters/__init__.py index 98f93295..66c78855 100644 --- a/rest_framework_json_api/filters/__init__.py +++ b/rest_framework_json_api/filters/__init__.py @@ -1,2 +1,6 @@ +import pkgutil from .sort import JSONAPIOrderingFilter # noqa: F401 -from .filter import JSONAPIDjangoFilter # noqa: F401 +# If django-filter is not installed, no-op. +if pkgutil.find_loader('django_filters') is not None: + from .filter import JSONAPIDjangoFilter # noqa: F401 +del pkgutil From 64d4af084656ea3fca3fda8fda061e122532b23e Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Mon, 17 Sep 2018 10:57:56 -0400 Subject: [PATCH 14/26] remove JSONAPI prefix per #471 --- docs/usage.md | 6 +++--- example/tests/test_filters.py | 2 +- rest_framework_json_api/filters/__init__.py | 2 +- rest_framework_json_api/filters/filter.py | 2 +- rest_framework_json_api/filters/queryvalidation.py | 10 +++++----- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 9af5c3b9..d42659cf 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -177,8 +177,8 @@ for `GET http://127.0.0.1:8000/nopage-entries?filter[bad]=1`: } ``` -#### `JSONAPIQueryValidationFilter` -`JSONAPIQueryValidationFilter` validates query parameters to be one of the defined JSON:API query parameters +#### `QueryValidationFilter` +`QueryValidationFilter` validates query parameters to be one of the defined JSON:API query parameters (sort, include, filter, fields, page) and returns a `400 Bad Request`. If a non-matching query parameter is used. This can help the client identify misspelled query parameters, for example. @@ -216,7 +216,7 @@ from models import MyModel class MyViewset(ModelViewSet): queryset = MyModel.objects.all() serializer_class = MyModelSerializer - filter_backends = (filters.JSONAPIQueryValidationFilter, filters.OrderingFilter, + filter_backends = (filters.QueryValidationFilter, filters.OrderingFilter, django_filters.DjangoFilterBackend,) filterset_fields = { 'id': ('exact', 'lt', 'gt', 'gte', 'lte', 'in'), diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index b0bb0453..b79e3fda 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -251,7 +251,7 @@ def test_filter_invalid_association_name(self): def test_filter_empty_association_name(self): """ test for filter with missing association name - error texts are different depending on whether JSONAPIQueryValidationFilter is in use. + error texts are different depending on whether QueryValidationFilter is in use. TODO: Just change the "invalid filter" to "invalid query parameter" in JSONAPIDjangoFilter? """ response = self.client.get(self.url, data={'filter[]': 'foobar'}) diff --git a/rest_framework_json_api/filters/__init__.py b/rest_framework_json_api/filters/__init__.py index 1bf9168d..3c3ef15c 100644 --- a/rest_framework_json_api/filters/__init__.py +++ b/rest_framework_json_api/filters/__init__.py @@ -1,2 +1,2 @@ from .sort import OrderingFilter # noqa: F401 -from .queryvalidation import JSONAPIQueryValidationFilter # noqa: F401 +from .queryvalidation import QueryValidationFilter # noqa: F401 diff --git a/rest_framework_json_api/filters/filter.py b/rest_framework_json_api/filters/filter.py index 42c3cfd1..ac7561be 100644 --- a/rest_framework_json_api/filters/filter.py +++ b/rest_framework_json_api/filters/filter.py @@ -44,7 +44,7 @@ class JSONAPIDjangoFilter(DjangoFilterBackend): search_param = api_settings.SEARCH_PARAM # Make this regex check for 'filter' as well as 'filter[...]' - # Leave other incorrect usages of 'filter' to JSONAPIQueryValidationFilter. + # Leave other incorrect usages of 'filter' to QueryValidationFilter. # See http://jsonapi.org/format/#document-member-names for allowed characters # and http://jsonapi.org/format/#document-member-names-reserved-characters for reserved # characters (for use in paths, lists or as delimiters). diff --git a/rest_framework_json_api/filters/queryvalidation.py b/rest_framework_json_api/filters/queryvalidation.py index 43cd690d..7b8a38e1 100644 --- a/rest_framework_json_api/filters/queryvalidation.py +++ b/rest_framework_json_api/filters/queryvalidation.py @@ -4,7 +4,7 @@ from rest_framework.filters import BaseFilterBackend -class JSONAPIQueryValidationFilter(BaseFilterBackend): +class QueryValidationFilter(BaseFilterBackend): """ A backend filter that performs strict validation of query parameters for jsonapi spec conformance and raises a 400 error if non-conforming usage is @@ -15,11 +15,9 @@ class JSONAPIQueryValidationFilter(BaseFilterBackend): requirement that they MUST contain contain at least one non a-z character (U+0061 to U+007A). It is RECOMMENDED that a U+002D HYPHEN-MINUS, “-“, U+005F LOW LINE, “_”, or capital letter is used (e.g. camelCasing)." -- http://jsonapi.org/format/#query-parameters - - TODO: For jsonapi error object conformance, must set jsonapi errors - "parameter" for the ValidationError. This requires extending DRF/DJA Exceptions. """ - # sort and include stand alone; filter, fields, page have []'s + #: compiled regex that matches the allowed http://jsonapi.org/format/#query-parameters + #: `sort` and `include` stand alone; `filter`, `fields`, and `page` have []'s query_regex = re.compile(r'^(sort|include)$|^(filter|fields|page)(\[[\w\.\-]+\])?$') def validate_query_params(self, request): @@ -27,6 +25,8 @@ def validate_query_params(self, request): Validate that query params are in the list of valid query keywords Raises ValidationError if not. """ + # TODO: For jsonapi error object conformance, must set jsonapi errors "parameter" for + # the ValidationError. This requires extending DRF/DJA Exceptions. for qp in request.query_params.keys(): if not self.query_regex.match(qp): raise ValidationError('invalid query parameter: {}'.format(qp)) From 23616a23700045fef6c329dee2d3ce29de7f53f4 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Mon, 17 Sep 2018 11:17:32 -0400 Subject: [PATCH 15/26] inadvertently removed when merging master --- example/tests/test_filters.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index b79e3fda..468ba70d 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -273,6 +273,17 @@ def test_filter_no_brackets(self): self.assertEqual(dja_response['errors'][0]['detail'], "invalid filter: filter") + def test_filter_missing_right_bracket(self): + """ + test for filter missing right bracket + """ + response = self.client.get(self.url, data={'filter[headline': 'foobar'}) + self.assertEqual(response.status_code, 400, msg = response.content.decode("utf-8")) + dja_response = response.json() + self.assertIn(dja_response['errors'][0]['detail'], + ["invalid filter: filter[headline", + "invalid query parameter: filter[headline"]) + def test_filter_no_brackets_rvalue(self): """ test for `filter=` with missing filter[association] and value From 2c476d9bc4254684ad5c167d5715bae636047ac3 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Mon, 17 Sep 2018 11:35:07 -0400 Subject: [PATCH 16/26] add QueryValidation filter to NonPaginatedEntryViewset to avoid breaking lots of older tests that use non-standard query params like page_size --- example/views.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/example/views.py b/example/views.py index d0980f01..c9ac8f82 100644 --- a/example/views.py +++ b/example/views.py @@ -1,11 +1,14 @@ import rest_framework.exceptions as exceptions import rest_framework.parsers import rest_framework.renderers +from rest_framework.filters import SearchFilter import rest_framework_json_api.metadata import rest_framework_json_api.parsers import rest_framework_json_api.renderers from django_filters import rest_framework as filters +from rest_framework_json_api.django_filters import DjangoFilterBackend +from rest_framework_json_api.filters import OrderingFilter, QueryValidationFilter from rest_framework_json_api.pagination import PageNumberPagination from rest_framework_json_api.utils import format_drf_errors from rest_framework_json_api.views import ModelViewSet, RelationshipView @@ -91,6 +94,9 @@ class NoPagination(PageNumberPagination): class NonPaginatedEntryViewSet(EntryViewSet): pagination_class = NoPagination + # override the default filter backends in order to test QueryValidationFilter without + # breaking older usage of non-standard query params like `page_size`. + filter_backends = (QueryValidationFilter, OrderingFilter, DjangoFilterBackend, SearchFilter) ordering_fields = ('headline', 'body_text', 'blog__name', 'blog__id') rels = ('exact', 'iexact', 'contains', 'icontains', From 9b5ab9db9aede55a5f4e7df00f46eb837cbabb4d Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Mon, 17 Sep 2018 11:35:44 -0400 Subject: [PATCH 17/26] flake8 --- example/tests/test_filters.py | 10 +++++----- rest_framework_json_api/filters/__init__.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index 468ba70d..2c9ecb79 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -259,8 +259,8 @@ def test_filter_empty_association_name(self): msg=response.content.decode("utf-8")) dja_response = response.json() self.assertIn(dja_response['errors'][0]['detail'], - ["invalid filter: filter[]", - "invalid query parameter: filter[]"]) + ["invalid filter: filter[]", + "invalid query parameter: filter[]"]) def test_filter_no_brackets(self): """ @@ -278,7 +278,7 @@ def test_filter_missing_right_bracket(self): test for filter missing right bracket """ response = self.client.get(self.url, data={'filter[headline': 'foobar'}) - self.assertEqual(response.status_code, 400, msg = response.content.decode("utf-8")) + self.assertEqual(response.status_code, 400, msg=response.content.decode("utf-8")) dja_response = response.json() self.assertIn(dja_response['errors'][0]['detail'], ["invalid filter: filter[headline", @@ -315,8 +315,8 @@ def test_filter_malformed_left_bracket(self): msg=response.content.decode("utf-8")) dja_response = response.json() self.assertIn(dja_response['errors'][0]['detail'], - ["invalid filter: filter[", - "invalid query parameter: filter["]) + ["invalid filter: filter[", + "invalid query parameter: filter["]) def test_filter_missing_rvalue(self): """ diff --git a/rest_framework_json_api/filters/__init__.py b/rest_framework_json_api/filters/__init__.py index 3c3ef15c..98f79a5a 100644 --- a/rest_framework_json_api/filters/__init__.py +++ b/rest_framework_json_api/filters/__init__.py @@ -1,2 +1,2 @@ from .sort import OrderingFilter # noqa: F401 -from .queryvalidation import QueryValidationFilter # noqa: F401 +from .queryvalidation import QueryValidationFilter # noqa: F401 From dbd3d3257776e72edc8902205f3bd8ae0760cb6b Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Mon, 17 Sep 2018 12:39:32 -0400 Subject: [PATCH 18/26] 100% test coverage for QueryParamaterValidationFilter --- example/tests/test_filters.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index 2c9ecb79..30b3290a 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -335,7 +335,7 @@ def test_filter_missing_rvalue_equal(self): """ test for filter with missing value to test against this should probably be an error rather than ignoring the filter: - """ + """ response = self.client.get(self.url + '?filter[headline]') self.assertEqual(response.status_code, 400, msg=response.content.decode("utf-8")) @@ -482,3 +482,30 @@ def test_search_multiple_keywords(self): self.assertEqual(len(dja_response['data']), expected_len) returned_ids = set([k['id'] for k in dja_response['data']]) self.assertEqual(returned_ids, expected_ids) + + def test_param_invalid(self): + """ + Test a "wrong" query parameter + """ + response = self.client.get(self.url, data={'garbage': 'foo'}) + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid query parameter: garbage") + + def test_param_duplicate(self): + """ + Test a duplicated query parameter: + `?sort=headline&page[size]=3&sort=bodyText` is not allowed. + This is not so obvious when using a data dict.... + """ + response = self.client.get(self.url, + data={'sort': ['headline', 'bodyText'], + 'page[size]': 3} + ) + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "repeated query parameter not allowed: sort") From 11aaf06ee84d497c9fec7688256c13a3b3271d63 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Mon, 17 Sep 2018 12:40:34 -0400 Subject: [PATCH 19/26] move QueryValidationFilter earlier and document how to extend query_regex --- docs/usage.md | 42 ++++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index d42659cf..ebd0cad9 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -33,6 +33,7 @@ REST_FRAMEWORK = { ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( + 'rest_framework_json_api.filters.QueryValidationFilter', 'rest_framework_json_api.filters.OrderingFilter', 'rest_framework_json_api.django_filters.DjangoFilterBackend', 'rest_framework.filters.SearchFilter', @@ -104,9 +105,32 @@ class MyLimitPagination(JsonApiLimitOffsetPagination): ### Filter Backends -Following are descriptions for two JSON:API-specific filter backends and documentation on suggested usage +Following are descriptions for three JSON:API-specific filter backends and documentation on suggested usage for a standard DRF keyword-search filter backend that makes it consistent with JSON:API. +#### `QueryValidationFilter` +`QueryValidationFilter` validates query parameters to be one of the defined JSON:API query parameters +(sort, include, filter, fields, page) and returns a `400 Bad Request` if a non-matching query parameter +is used. This can help the client identify misspelled query parameters, for example. + +If you want to change the list of valid query parameters, override the `.query_regex` attribute: +```python +# compiled regex that matches the allowed http://jsonapi.org/format/#query-parameters +# `sort` and `include` stand alone; `filter`, `fields`, and `page` have []'s +query_regex = re.compile(r'^(sort|include)$|^(filter|fields|page)(\[[\w\.\-]+\])?$') +``` +For example: +```python +import re +from rest_framework_json_api.filters import QueryValidationFilter + +class MyQPValidator(QueryValidationFilter): + query_regex = re.compile(r'^(sort|include|page|page_size)$|^(filter|fields|page)(\[[\w\.\-]+\])?$') +``` + +If you don't care if non-JSON:API query parameters are allowed (and potentially silently ignored), +simply don't use this filter backend. + #### `OrderingFilter` `OrderingFilter` 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). @@ -177,20 +201,6 @@ for `GET http://127.0.0.1:8000/nopage-entries?filter[bad]=1`: } ``` -#### `QueryValidationFilter` -`QueryValidationFilter` validates query parameters to be one of the defined JSON:API query parameters -(sort, include, filter, fields, page) and returns a `400 Bad Request`. If a non-matching query parameter -is used. This can help the client identify misspelled query parameters, for example. - -If you want to add some additional non-standard query parameters, -simply override `.query_regex` adding the new parameters but, "with the additional -requirement that they MUST contain contain at least one non a-z character (U+0061 to U+007A). -It is RECOMMENDED that a U+002D HYPHEN-MINUS, “-“, U+005F LOW LINE, “_”, or capital letter is -used (e.g. camelCasing)." -- http://jsonapi.org/format/#query-parameters - -If you don't care if non-JSON:API query parameters are allowed (and potentially silently ignored), -simply don't use this filter backend. - #### `SearchFilter` To comply with JSON:API query parameter naming standards, DRF's @@ -217,7 +227,7 @@ class MyViewset(ModelViewSet): queryset = MyModel.objects.all() serializer_class = MyModelSerializer filter_backends = (filters.QueryValidationFilter, filters.OrderingFilter, - django_filters.DjangoFilterBackend,) + django_filters.DjangoFilterBackend, SearchFilter) filterset_fields = { 'id': ('exact', 'lt', 'gt', 'gte', 'lte', 'in'), 'descriptuon': ('icontains', 'iexact', 'contains'), From 57e95cc79e6af6e8efea753a5a9796369dc7106a Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Mon, 17 Sep 2018 12:41:55 -0400 Subject: [PATCH 20/26] QueryValidationFilter to README --- README.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/README.rst b/README.rst index c06dbbd5..ebd1e524 100644 --- a/README.rst +++ b/README.rst @@ -173,6 +173,7 @@ override ``settings.REST_FRAMEWORK`` ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( + 'rest_framework_json_api.filters.QueryValidationFilter', 'rest_framework_json_api.filters.OrderingFilter', 'rest_framework_json_api.django_filters.DjangoFilterBackend', 'rest_framework.filters.SearchFilter', From a22ca217340eab5b307bb31cc206d003e7d0e95f Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Mon, 17 Sep 2018 12:52:29 -0400 Subject: [PATCH 21/26] py2.7 fix for a non-ASCII quotation mark --- rest_framework_json_api/filters/queryvalidation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework_json_api/filters/queryvalidation.py b/rest_framework_json_api/filters/queryvalidation.py index 7b8a38e1..ce3958b0 100644 --- a/rest_framework_json_api/filters/queryvalidation.py +++ b/rest_framework_json_api/filters/queryvalidation.py @@ -13,7 +13,7 @@ class QueryValidationFilter(BaseFilterBackend): If you want to add some additional non-standard query parameters, simply override `.query_regex` adding the new parameters but, "with the additional requirement that they MUST contain contain at least one non a-z character (U+0061 to U+007A). - It is RECOMMENDED that a U+002D HYPHEN-MINUS, “-“, U+005F LOW LINE, “_”, or capital letter is + It is RECOMMENDED that a U+002D HYPHEN-MINUS, "-", U+005F LOW LINE, "_", or capital letter is used (e.g. camelCasing)." -- http://jsonapi.org/format/#query-parameters """ #: compiled regex that matches the allowed http://jsonapi.org/format/#query-parameters From 025209680a4ad2d2e0829f55d8b2e53b1313d322 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Mon, 17 Sep 2018 13:00:58 -0400 Subject: [PATCH 22/26] ugh I added back this junk file by mistake again --- rest_framework_json_api/filters/filter.py | 123 ---------------------- 1 file changed, 123 deletions(-) delete mode 100644 rest_framework_json_api/filters/filter.py diff --git a/rest_framework_json_api/filters/filter.py b/rest_framework_json_api/filters/filter.py deleted file mode 100644 index ac7561be..00000000 --- a/rest_framework_json_api/filters/filter.py +++ /dev/null @@ -1,123 +0,0 @@ -import re - -from rest_framework.exceptions import ValidationError -from rest_framework.settings import api_settings - -from django_filters import VERSION -from django_filters.rest_framework import DjangoFilterBackend -from rest_framework_json_api.utils import format_value - - -class JSONAPIDjangoFilter(DjangoFilterBackend): - """ - A Django-style ORM filter implementation, using `django-filter`. - - This is not part of the jsonapi standard per-se, other than the requirement - to use the `filter` keyword: This is an optional implementation of style of - filtering in which each filter is an ORM expression as implemented by - DjangoFilterBackend and seems to be in alignment with an interpretation of - http://jsonapi.org/recommendations/#filtering, including relationship - chaining. It also returns a 400 error for invalid filters. - - Filters can be: - - A resource field equality test: - `?filter[qty]=123` - - Apply other [field lookup](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups) # noqa: E501 - operators: - `?filter[name.icontains]=bar` or `?filter[name.isnull]=true...` - - Membership in a list of values: - `?filter[name.in]=abc,123,zzz (name in ['abc','123','zzz'])` - - Filters can be combined for intersection (AND): - `?filter[qty]=123&filter[name.in]=abc,123,zzz&filter[...]` - - A related resource path can be used: - `?filter[inventory.item.partNum]=123456 (where `inventory.item` is the relationship path)` - - If you are also using rest_framework.filters.SearchFilter you'll want to customize - the name of the query parameter for searching to make sure it doesn't conflict - with a field name defined in the filterset. - The recommended value is: `search_param="filter[search]"` but just make sure it's - `filter[<something>]` to comply with the jsonapi spec requirement to use the filter - keyword. The default is "search" unless overriden but it's used here just to make sure - we don't complain about it being an invalid filter. - """ - # TODO: find a better way to deal with search_param. - search_param = api_settings.SEARCH_PARAM - - # Make this regex check for 'filter' as well as 'filter[...]' - # Leave other incorrect usages of 'filter' to QueryValidationFilter. - # See http://jsonapi.org/format/#document-member-names for allowed characters - # and http://jsonapi.org/format/#document-member-names-reserved-characters for reserved - # characters (for use in paths, lists or as delimiters). - # regex `\w` matches [a-zA-Z0-9_]. - # TODO: U+0080 and above allowed but not recommended. Leave them out for now.e - # Also, ' ' (space) is allowed within a member name but not recommended. - filter_regex = re.compile(r'^filter(?P<ldelim>\[?)(?P<assoc>[\w\.\-]*)(?P<rdelim>\]?$)') - - def _validate_filter(self, keys, filterset_class): - for k in keys: - if ((not filterset_class) or (k not in filterset_class.base_filters)): - raise ValidationError("invalid filter[{}]".format(k)) - - def get_filterset(self, request, queryset, view): - """ - Sometimes there's no filterset_class defined yet the client still - requests a filter. Make sure they see an error too. This means - we have to get_filterset_kwargs() even if there's no filterset_class. - - TODO: .base_filters vs. .filters attr (not always present) - """ - filterset_class = self.get_filterset_class(view, queryset) - kwargs = self.get_filterset_kwargs(request, queryset, view) - self._validate_filter(kwargs.pop('filter_keys'), filterset_class) - if filterset_class is None: - return None - return filterset_class(**kwargs) - - def get_filterset_kwargs(self, request, queryset, view): - """ - Turns filter[<field>]=<value> into <field>=<value> which is what - DjangoFilterBackend expects - """ - filter_keys = [] - # rewrite filter[field] query params to make DjangoFilterBackend work. - data = request.query_params.copy() - for qp, val in data.items(): - m = self.filter_regex.match(qp) - if m and (not m.groupdict()['assoc'] or - m.groupdict()['ldelim'] != '[' or m.groupdict()['rdelim'] != ']'): - raise ValidationError("invalid filter: {}".format(qp)) - if m and qp != self.search_param: - if not val: - raise ValidationError("missing {} test value".format(qp)) - # convert jsonapi relationship path to Django ORM's __ notation - key = m.groupdict()['assoc'].replace('.', '__') - # undo JSON_API_FORMAT_FIELD_NAMES conversion: - key = format_value(key, 'underscore') - data[key] = val - filter_keys.append(key) - del data[qp] - return { - 'data': data, - 'queryset': queryset, - 'request': request, - 'filter_keys': filter_keys, - } - - def filter_queryset(self, request, queryset, view): - """ - Backwards compatibility to 1.1 (required for Python 2.7) - In 1.1 filter_queryset does not call get_filterset or get_filterset_kwargs. - """ - # TODO: remove when Python 2.7 support is deprecated - if VERSION >= (2, 0, 0): - return super(JSONAPIDjangoFilter, self).filter_queryset(request, queryset, view) - - filter_class = self.get_filter_class(view, queryset) - - kwargs = self.get_filterset_kwargs(request, queryset, view) - self._validate_filter(kwargs.pop('filter_keys'), filter_class) - - if filter_class: - return filter_class(kwargs['data'], queryset=queryset, request=request).qs - - return queryset From 9e715fa0c6c8ac9838a737864832ced8072e6ce5 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Tue, 18 Sep 2018 08:01:01 -0400 Subject: [PATCH 23/26] Change "invalid filter" to "invalid query parameter" for malformed filter query parameter. This makes a consistent error message irrespective of whether QueryParameterValidation is used. --- example/tests/test_filters.py | 20 +++++++------------ .../django_filters/backends.py | 11 +++++++++- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index 30b3290a..569cd5d7 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -252,15 +252,12 @@ def test_filter_empty_association_name(self): """ test for filter with missing association name error texts are different depending on whether QueryValidationFilter is in use. - TODO: Just change the "invalid filter" to "invalid query parameter" in JSONAPIDjangoFilter? """ response = self.client.get(self.url, data={'filter[]': 'foobar'}) self.assertEqual(response.status_code, 400, msg=response.content.decode("utf-8")) dja_response = response.json() - self.assertIn(dja_response['errors'][0]['detail'], - ["invalid filter: filter[]", - "invalid query parameter: filter[]"]) + self.assertEqual(dja_response['errors'][0]['detail'], "invalid query parameter: filter[]") def test_filter_no_brackets(self): """ @@ -271,7 +268,7 @@ def test_filter_no_brackets(self): msg=response.content.decode("utf-8")) dja_response = response.json() self.assertEqual(dja_response['errors'][0]['detail'], - "invalid filter: filter") + "invalid query parameter: filter") def test_filter_missing_right_bracket(self): """ @@ -280,9 +277,8 @@ def test_filter_missing_right_bracket(self): response = self.client.get(self.url, data={'filter[headline': 'foobar'}) self.assertEqual(response.status_code, 400, msg=response.content.decode("utf-8")) dja_response = response.json() - self.assertIn(dja_response['errors'][0]['detail'], - ["invalid filter: filter[headline", - "invalid query parameter: filter[headline"]) + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid query parameter: filter[headline") def test_filter_no_brackets_rvalue(self): """ @@ -293,7 +289,7 @@ def test_filter_no_brackets_rvalue(self): msg=response.content.decode("utf-8")) dja_response = response.json() self.assertEqual(dja_response['errors'][0]['detail'], - "invalid filter: filter") + "invalid query parameter: filter") def test_filter_no_brackets_equal(self): """ @@ -304,7 +300,7 @@ def test_filter_no_brackets_equal(self): msg=response.content.decode("utf-8")) dja_response = response.json() self.assertEqual(dja_response['errors'][0]['detail'], - "invalid filter: filter") + "invalid query parameter: filter") def test_filter_malformed_left_bracket(self): """ @@ -314,9 +310,7 @@ def test_filter_malformed_left_bracket(self): self.assertEqual(response.status_code, 400, msg=response.content.decode("utf-8")) dja_response = response.json() - self.assertIn(dja_response['errors'][0]['detail'], - ["invalid filter: filter[", - "invalid query parameter: filter["]) + self.assertEqual(dja_response['errors'][0]['detail'], "invalid query parameter: filter[") def test_filter_missing_rvalue(self): """ diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index 31001b08..61148266 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -52,6 +52,13 @@ class DjangoFilterBackend(DjangoFilterBackend): filter_regex = re.compile(r'^filter(?P<ldelim>\[?)(?P<assoc>[\w\.\-]*)(?P<rdelim>\]?$)') def _validate_filter(self, keys, filterset_class): + """ + Check that all the filter[key] are valid. + + :param keys: list of FilterSet keys + :param filterset_class: :py:class:`django_filters.rest_framework.FilterSet` + :raises ValidationError: if key not in FilterSet keys or no FilterSet. + """ for k in keys: if ((not filterset_class) or (k not in filterset_class.base_filters)): raise ValidationError("invalid filter[{}]".format(k)) @@ -75,6 +82,8 @@ def get_filterset_kwargs(self, request, queryset, view): """ Turns filter[<field>]=<value> into <field>=<value> which is what DjangoFilterBackend expects + + :raises ValidationError: for bad filter syntax """ filter_keys = [] # rewrite filter[field] query params to make DjangoFilterBackend work. @@ -83,7 +92,7 @@ def get_filterset_kwargs(self, request, queryset, view): m = self.filter_regex.match(qp) if m and (not m.groupdict()['assoc'] or m.groupdict()['ldelim'] != '[' or m.groupdict()['rdelim'] != ']'): - raise ValidationError("invalid filter: {}".format(qp)) + raise ValidationError("invalid query parameter: {}".format(qp)) if m and qp != self.search_param: if not val: raise ValidationError("missing {} test value".format(qp)) From af10543c739deaabaac06c7cacb329e6cd14c042 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Tue, 18 Sep 2018 08:10:31 -0400 Subject: [PATCH 24/26] renamed to QueryParameterValidationFilter to be clear that this is query param validation (not queryset) --- README.rst | 2 +- docs/usage.md | 8 ++++---- example/tests/test_filters.py | 2 +- example/views.py | 6 +++--- rest_framework_json_api/filters/__init__.py | 2 +- rest_framework_json_api/filters/queryvalidation.py | 8 +++----- 6 files changed, 13 insertions(+), 15 deletions(-) diff --git a/README.rst b/README.rst index ebd1e524..2d97772a 100644 --- a/README.rst +++ b/README.rst @@ -173,7 +173,7 @@ override ``settings.REST_FRAMEWORK`` ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( - 'rest_framework_json_api.filters.QueryValidationFilter', + 'rest_framework_json_api.filters.QueryParameterValidationFilter', 'rest_framework_json_api.filters.OrderingFilter', 'rest_framework_json_api.django_filters.DjangoFilterBackend', 'rest_framework.filters.SearchFilter', diff --git a/docs/usage.md b/docs/usage.md index ebd0cad9..6eba540c 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.filters.QueryValidationFilter', + 'rest_framework_json_api.filters.QueryParameterValidationFilter', 'rest_framework_json_api.filters.OrderingFilter', 'rest_framework_json_api.django_filters.DjangoFilterBackend', 'rest_framework.filters.SearchFilter', @@ -108,8 +108,8 @@ class MyLimitPagination(JsonApiLimitOffsetPagination): Following are descriptions for three JSON:API-specific filter backends and documentation on suggested usage for a standard DRF keyword-search filter backend that makes it consistent with JSON:API. -#### `QueryValidationFilter` -`QueryValidationFilter` validates query parameters to be one of the defined JSON:API query parameters +#### `QueryParameterValidationFilter` +`QueryParameterValidationFilter` validates query parameters to be one of the defined JSON:API query parameters (sort, include, filter, fields, page) and returns a `400 Bad Request` if a non-matching query parameter is used. This can help the client identify misspelled query parameters, for example. @@ -226,7 +226,7 @@ from models import MyModel class MyViewset(ModelViewSet): queryset = MyModel.objects.all() serializer_class = MyModelSerializer - filter_backends = (filters.QueryValidationFilter, filters.OrderingFilter, + filter_backends = (filters.QueryParameterValidationFilter, filters.OrderingFilter, django_filters.DjangoFilterBackend, SearchFilter) filterset_fields = { 'id': ('exact', 'lt', 'gt', 'gte', 'lte', 'in'), diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index 569cd5d7..f0243457 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -251,7 +251,7 @@ def test_filter_invalid_association_name(self): def test_filter_empty_association_name(self): """ test for filter with missing association name - error texts are different depending on whether QueryValidationFilter is in use. + error texts are different depending on whether QueryParameterValidationFilter is in use. """ response = self.client.get(self.url, data={'filter[]': 'foobar'}) self.assertEqual(response.status_code, 400, diff --git a/example/views.py b/example/views.py index c9ac8f82..fe26246b 100644 --- a/example/views.py +++ b/example/views.py @@ -8,7 +8,7 @@ import rest_framework_json_api.renderers from django_filters import rest_framework as filters from rest_framework_json_api.django_filters import DjangoFilterBackend -from rest_framework_json_api.filters import OrderingFilter, QueryValidationFilter +from rest_framework_json_api.filters import OrderingFilter, QueryParameterValidationFilter from rest_framework_json_api.pagination import PageNumberPagination from rest_framework_json_api.utils import format_drf_errors from rest_framework_json_api.views import ModelViewSet, RelationshipView @@ -94,9 +94,9 @@ class NoPagination(PageNumberPagination): class NonPaginatedEntryViewSet(EntryViewSet): pagination_class = NoPagination - # override the default filter backends in order to test QueryValidationFilter without + # override the default filter backends in order to test QueryParameterValidationFilter without # breaking older usage of non-standard query params like `page_size`. - filter_backends = (QueryValidationFilter, OrderingFilter, DjangoFilterBackend, SearchFilter) + filter_backends = (QueryParameterValidationFilter, OrderingFilter, DjangoFilterBackend, SearchFilter) ordering_fields = ('headline', 'body_text', 'blog__name', 'blog__id') rels = ('exact', 'iexact', 'contains', 'icontains', diff --git a/rest_framework_json_api/filters/__init__.py b/rest_framework_json_api/filters/__init__.py index 98f79a5a..37c21e2e 100644 --- a/rest_framework_json_api/filters/__init__.py +++ b/rest_framework_json_api/filters/__init__.py @@ -1,2 +1,2 @@ from .sort import OrderingFilter # noqa: F401 -from .queryvalidation import QueryValidationFilter # noqa: F401 +from .queryvalidation import QueryParameterValidationFilter # noqa: F401 diff --git a/rest_framework_json_api/filters/queryvalidation.py b/rest_framework_json_api/filters/queryvalidation.py index ce3958b0..746ab787 100644 --- a/rest_framework_json_api/filters/queryvalidation.py +++ b/rest_framework_json_api/filters/queryvalidation.py @@ -4,17 +4,15 @@ from rest_framework.filters import BaseFilterBackend -class QueryValidationFilter(BaseFilterBackend): +class QueryParameterValidationFilter(BaseFilterBackend): """ A backend filter that performs strict validation of query parameters for jsonapi spec conformance and raises a 400 error if non-conforming usage is found. If you want to add some additional non-standard query parameters, - simply override `.query_regex` adding the new parameters but, "with the additional - requirement that they MUST contain contain at least one non a-z character (U+0061 to U+007A). - It is RECOMMENDED that a U+002D HYPHEN-MINUS, "-", U+005F LOW LINE, "_", or capital letter is - used (e.g. camelCasing)." -- http://jsonapi.org/format/#query-parameters + override :py:attr:`query_regex` adding the new parameters. Make sure to comply with + the rules at http://jsonapi.org/format/#query-parameters. """ #: compiled regex that matches the allowed http://jsonapi.org/format/#query-parameters #: `sort` and `include` stand alone; `filter`, `fields`, and `page` have []'s From c928d724fd62944fd87ceec4c4c92caf59ef21de Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Tue, 18 Sep 2018 08:13:51 -0400 Subject: [PATCH 25/26] clearer language --- docs/usage.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/usage.md b/docs/usage.md index 6eba540c..345356be 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -105,7 +105,7 @@ class MyLimitPagination(JsonApiLimitOffsetPagination): ### Filter Backends -Following are descriptions for three JSON:API-specific filter backends and documentation on suggested usage +Following are descriptions of JSON:API-specific filter backends and documentation on suggested usage for a standard DRF keyword-search filter backend that makes it consistent with JSON:API. #### `QueryParameterValidationFilter` From 6e008ad1d1f6d6d30d811972828b6add3ea3b892 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Tue, 18 Sep 2018 08:14:26 -0400 Subject: [PATCH 26/26] flake8 line length after renaming the class --- example/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/example/views.py b/example/views.py index fe26246b..2307925a 100644 --- a/example/views.py +++ b/example/views.py @@ -96,7 +96,8 @@ class NonPaginatedEntryViewSet(EntryViewSet): pagination_class = NoPagination # override the default filter backends in order to test QueryParameterValidationFilter without # breaking older usage of non-standard query params like `page_size`. - filter_backends = (QueryParameterValidationFilter, OrderingFilter, DjangoFilterBackend, SearchFilter) + filter_backends = (QueryParameterValidationFilter, OrderingFilter, + DjangoFilterBackend, SearchFilter) ordering_fields = ('headline', 'body_text', 'blog__name', 'blog__id') rels = ('exact', 'iexact', 'contains', 'icontains',