From 7b29f365e1ed30cb67b5484fa4ac7f3ab566bc2a Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Thu, 23 Aug 2018 16:26:30 -0400 Subject: [PATCH 01/16] 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[]` 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\W*)(?P[\w._]*)(?P\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[]= into = 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 Date: Fri, 24 Aug 2018 15:03:40 -0400 Subject: [PATCH 02/16] 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 Date: Fri, 24 Aug 2018 17:12:28 -0400 Subject: [PATCH 03/16] 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 Date: Fri, 24 Aug 2018 17:17:29 -0400 Subject: [PATCH 04/16] 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\W*)(?P[\w._]*)(?P\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 Date: Fri, 24 Aug 2018 18:22:13 -0400 Subject: [PATCH 05/16] 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[]` 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\W*)(?P[\w._]*)(?P\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[]= into = 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[]` 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\W*)(?P[\w._]*)(?P\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[]= into = 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 Date: Fri, 24 Aug 2018 19:00:51 -0400 Subject: [PATCH 06/16] 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 Date: Fri, 24 Aug 2018 19:04:31 -0400 Subject: [PATCH 07/16] 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 Date: Sat, 25 Aug 2018 16:28:00 -0400 Subject: [PATCH 08/16] 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 4f2b75bc1d93b04a324babade733777b5010fa2a Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Tue, 28 Aug 2018 10:52:01 -0400 Subject: [PATCH 09/16] 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[]` 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\W*)(?P[\w._]*)(?P\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\[?)(?P[\w\.\-]*)(?P\]?$)') def validate_filter(self, keys, filterset_class): for k in keys: From 6a8d7aef0f5a87ee0f856b959c79f122c32aac92 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Wed, 29 Aug 2018 16:20:48 -0400 Subject: [PATCH 10/16] 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[]` 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\[?)(?P[\w\.\-]*)(?P\]?$)') + + 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[]= into = 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[]` 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\[?)(?P[\w\.\-]*)(?P\]?$)') - - 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[]= into = 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 Date: Wed, 29 Aug 2018 16:38:26 -0400 Subject: [PATCH 11/16] 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 51b99464a7061f0c542c5eca42dfcf9fffc25467 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Thu, 30 Aug 2018 14:55:53 -0400 Subject: [PATCH 12/16] rename JSONAPIDjangoFilter to DjangoFilterBackend. Per discussion about naming, the idea is that it should be easy to updgrade from DRF to DJA by simply changing some imports, retaining the same DRF (or in this case, django-filter) class names that are extended by DJA. see https://github.com/django-json-api/django-rest-framework-json-api/issues/467#issuecomment-417338257 --- docs/usage.md | 8 ++++---- example/settings/dev.py | 2 +- rest_framework_json_api/filters/__init__.py | 2 +- .../filters/{filter.py => django_filter.py} | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) rename rest_framework_json_api/filters/{filter.py => django_filter.py} (98%) diff --git a/docs/usage.md b/docs/usage.md index 010c58d4..112317fb 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -34,7 +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', + 'rest_framework_json_api.filters.DjangoFilterBackend', ), 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', @@ -119,8 +119,8 @@ 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) +#### `DjangoFilterBackend` +`DjangoFilterBackend` 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 @@ -176,7 +176,7 @@ 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.JSONAPIOrderingFilter, filters.DjangoFilterBackend,) ``` diff --git a/example/settings/dev.py b/example/settings/dev.py index c6da48a8..e30ae10f 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -91,7 +91,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', + 'rest_framework_json_api.filters.DjangoFilterBackend', ), 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', diff --git a/rest_framework_json_api/filters/__init__.py b/rest_framework_json_api/filters/__init__.py index 66c78855..7083c65d 100644 --- a/rest_framework_json_api/filters/__init__.py +++ b/rest_framework_json_api/filters/__init__.py @@ -2,5 +2,5 @@ from .sort import JSONAPIOrderingFilter # 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 + from .django_filter import DjangoFilterBackend # noqa: F401 del pkgutil diff --git a/rest_framework_json_api/filters/filter.py b/rest_framework_json_api/filters/django_filter.py similarity index 98% rename from rest_framework_json_api/filters/filter.py rename to rest_framework_json_api/filters/django_filter.py index 42c3cfd1..4681c2f2 100644 --- a/rest_framework_json_api/filters/filter.py +++ b/rest_framework_json_api/filters/django_filter.py @@ -8,7 +8,7 @@ from rest_framework_json_api.utils import format_value -class JSONAPIDjangoFilter(DjangoFilterBackend): +class DjangoFilterBackend(DjangoFilterBackend): """ A Django-style ORM filter implementation, using `django-filter`. @@ -110,7 +110,7 @@ def filter_queryset(self, request, queryset, view): """ # TODO: remove when Python 2.7 support is deprecated if VERSION >= (2, 0, 0): - return super(JSONAPIDjangoFilter, self).filter_queryset(request, queryset, view) + return super(DjangoFilterBackend, self).filter_queryset(request, queryset, view) filter_class = self.get_filter_class(view, queryset) From d23ab5432820015fce9ec0975796c7375a482772 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Tue, 4 Sep 2018 10:41:20 -0400 Subject: [PATCH 13/16] remove "forward-looking" comment;-) --- rest_framework_json_api/filters/django_filter.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/rest_framework_json_api/filters/django_filter.py b/rest_framework_json_api/filters/django_filter.py index 4681c2f2..31001b08 100644 --- a/rest_framework_json_api/filters/django_filter.py +++ b/rest_framework_json_api/filters/django_filter.py @@ -40,11 +40,9 @@ class DjangoFilterBackend(DjangoFilterBackend): 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). From 345ba683505a787af34ac404f73d9c5e2b7d48b3 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Wed, 5 Sep 2018 08:48:00 -0400 Subject: [PATCH 14/16] fixed drf_example documentation reference --- CHANGELOG.md | 2 +- .../{filters/django_filter.py => django_filters.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename rest_framework_json_api/{filters/django_filter.py => django_filters.py} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0fe3a6a..be7dda6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ * Add testing configuration to `REST_FRAMEWORK` configuration as described in [DRF](https://www.django-rest-framework.org/api-guide/testing/#configuration) * 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). +* Replaced binary `drf_example` sqlite3 db with a [fixture](example/fixtures/drf_example.yaml). See [getting started](docs/getting-started.md#running-the-example-app). * For naming consistency, renamed new `JsonApi`-prefix pagination classes to `JSONAPI`-prefix. * Deprecates `JsonApiPageNumberPagination` and `JsonApiLimitOffsetPagination` * Performance improvement when rendering relationships with `ModelSerializer` diff --git a/rest_framework_json_api/filters/django_filter.py b/rest_framework_json_api/django_filters.py similarity index 100% rename from rest_framework_json_api/filters/django_filter.py rename to rest_framework_json_api/django_filters.py From 8cb9e58abd5c32f7c85232c38705a4f022c55626 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Wed, 5 Sep 2018 09:18:06 -0400 Subject: [PATCH 15/16] move DjangoFilterBackend into its own module --- README.rst | 3 ++- docs/usage.md | 5 +++-- example/settings/dev.py | 2 +- rest_framework_json_api/filters/__init__.py | 5 ----- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/README.rst b/README.rst index 7d345185..d690bb3f 100644 --- a/README.rst +++ b/README.rst @@ -173,7 +173,8 @@ override ``settings.REST_FRAMEWORK`` ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( - 'rest_framework_json_api.backends.JSONAPIOrderingFilter', + 'rest_framework_json_api.filters.JSONAPIOrderingFilter', + 'rest_framework_json_api.django_filters.DjangoFilterBackend', ), 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', diff --git a/docs/usage.md b/docs/usage.md index 112317fb..51576d06 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -34,7 +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.DjangoFilterBackend', + 'rest_framework_json_api.django_filters.DjangoFilterBackend', ), 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', @@ -172,11 +172,12 @@ in the [example settings](#configuration) or individually add them as `.filter_b ```python from rest_framework_json_api import filters +from rest_framework_json_api import django_filters class MyViewset(ModelViewSet): queryset = MyModel.objects.all() serializer_class = MyModelSerializer - filter_backends = (filters.JSONAPIOrderingFilter, filters.DjangoFilterBackend,) + filter_backends = (filters.JSONAPIOrderingFilter, django_filters.DjangoFilterBackend,) ``` diff --git a/example/settings/dev.py b/example/settings/dev.py index e30ae10f..5356146f 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -91,7 +91,7 @@ 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( 'rest_framework_json_api.filters.JSONAPIOrderingFilter', - 'rest_framework_json_api.filters.DjangoFilterBackend', + 'rest_framework_json_api.django_filters.DjangoFilterBackend', ), 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', diff --git a/rest_framework_json_api/filters/__init__.py b/rest_framework_json_api/filters/__init__.py index 7083c65d..0bd022fb 100644 --- a/rest_framework_json_api/filters/__init__.py +++ b/rest_framework_json_api/filters/__init__.py @@ -1,6 +1 @@ -import pkgutil from .sort import JSONAPIOrderingFilter # noqa: F401 -# If django-filter is not installed, no-op. -if pkgutil.find_loader('django_filters') is not None: - from .django_filter import DjangoFilterBackend # noqa: F401 -del pkgutil From c0b4b350ec8b9fb97a21073ecf876b868ba1981e Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Wed, 5 Sep 2018 09:39:45 -0400 Subject: [PATCH 16/16] python 2.7 --- rest_framework_json_api/django_filters/__init__.py | 1 + .../{django_filters.py => django_filters/backends.py} | 0 2 files changed, 1 insertion(+) create mode 100644 rest_framework_json_api/django_filters/__init__.py rename rest_framework_json_api/{django_filters.py => django_filters/backends.py} (100%) diff --git a/rest_framework_json_api/django_filters/__init__.py b/rest_framework_json_api/django_filters/__init__.py new file mode 100644 index 00000000..16c56714 --- /dev/null +++ b/rest_framework_json_api/django_filters/__init__.py @@ -0,0 +1 @@ +from .backends import DjangoFilterBackend # noqa: F401 diff --git a/rest_framework_json_api/django_filters.py b/rest_framework_json_api/django_filters/backends.py similarity index 100% rename from rest_framework_json_api/django_filters.py rename to rest_framework_json_api/django_filters/backends.py