diff --git a/.travis.yml b/.travis.yml index 9c102172..8b073020 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 + env: TOXENV=py36-df20-django20-drf38 - python: 3.6 env: TOXENV=flake8 diff --git a/CHANGELOG.md b/CHANGELOG.md index cba4a852..be7dda6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,11 @@ * 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). -* Add optional [jsonapi-style](http://jsonapi.org/format/) sort filter backend. See [usage docs](docs/usage.md#filter-backends) +* 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` - +* 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/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 b9d89ecf..51576d06 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.django_filters.DjangoFilterBackend', ), '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,18 +119,65 @@ 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`. +#### `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 +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 +from rest_framework_json_api import django_filters class MyViewset(ModelViewSet): queryset = MyModel.objects.all() serializer_class = MyModelSerializer - filter_backends = (filters.JSONAPIOrderingFilter,) + filter_backends = (filters.JSONAPIOrderingFilter, django_filters.DjangoFilterBackend,) ``` 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 6856a91b..5356146f 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -26,6 +26,7 @@ 'polymorphic', 'example', 'debug_toolbar', + 'django_filters', ] TEMPLATES = [ @@ -90,6 +91,7 @@ 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( '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/example/tests/test_filters.py b/example/tests/test_filters.py index 2b18b5f3..273fe9ac 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,236 @@ 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_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..e2b8ef27 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -12,6 +12,8 @@ CompanyViewset, EntryRelationshipView, EntryViewSet, + FiltersetEntryViewSet, + NoFiltersetEntryViewSet, NonPaginatedEntryViewSet, 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..f3b9ccec 100644 --- a/example/views.py +++ b/example/views.py @@ -1,10 +1,11 @@ +import rest_framework.exceptions as exceptions import rest_framework.parsers import rest_framework.renderers -from rest_framework import exceptions 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 @@ -91,6 +92,49 @@ 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, + } + filter_fields = filterset_fields # django-filter<=1.1 (required for py27) + + +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 + filter_fields = filterset_fields # django-filter<=1.1 + filter_class = filterset_class + + +class NoFiltersetEntryViewSet(EntryViewSet): + """ + like above but no filtersets + """ + pagination_class = NoPagination + filterset_fields = None + filterset_class = None + filter_fields = filterset_fields # django-filter<=1.1 + filter_class = filterset_class class AuthorViewSet(ModelViewSet): 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/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/backends.py b/rest_framework_json_api/django_filters/backends.py new file mode 100644 index 00000000..31001b08 --- /dev/null +++ b/rest_framework_json_api/django_filters/backends.py @@ -0,0 +1,121 @@ +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 DjangoFilterBackend(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. + """ + search_param = api_settings.SEARCH_PARAM + + # Make this regex check for 'filter' as well as 'filter[...]' + # 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 + """ + 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(DjangoFilterBackend, 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 diff --git a/rest_framework_json_api/filters/__init__.py b/rest_framework_json_api/filters/__init__.py new file mode 100644 index 00000000..0bd022fb --- /dev/null +++ b/rest_framework_json_api/filters/__init__.py @@ -0,0 +1 @@ +from .sort import JSONAPIOrderingFilter # noqa: F401 diff --git a/rest_framework_json_api/filters.py b/rest_framework_json_api/filters/sort.py similarity index 100% rename from rest_framework_json_api/filters.py rename to rest_framework_json_api/filters/sort.py 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}