From d8f848f0165fb53e7df3af5d78e4f93786761a1c Mon Sep 17 00:00:00 2001 From: Abdulhaq Emhemmed Date: Fri, 7 Oct 2016 22:51:59 +0200 Subject: [PATCH] Add filter feature per JSON API specs. Added filter feature. This uses DjangoFilterBackend which either comes from `rest-framework-filter` or `django-filter`. The former is a package which adds more features to the latter. All that is done is reformatting the request query parameters to be compatible with DjangoFilterBackend. The spec recommend the pattern `filter[field]=values`. JsonApiFilterBackend takes that pattern and reformats it to become `field=value` which will then work as typical filtering in DRF. The specs only rule towards filtering is that the `filter` keyword should be reserved. It doesn't however say about the exact format. It only **recommends** `filter[field]=value` but this can also be `filter{field}=value` or `filter(field)=value`. Therefore, a new setting for DJA is introduced: JSON_API_FILTER_KEYWORD. This is a regex which controls the filter format. Its default value is: ``` JSON_API_FILTER_KEYWORD = 'filter\[(?P\w+)\]' ``` It can be changed to anything as long as the `field` keyword is included in the regex. The docs have been updated. Unfortunately, the editor's beautifier was run and made lots of other changes to the code style. No harm was done though, that's why TDD exists :D --- .gitignore | 5 ++ docs/usage.md | 31 ++++++- example/filters.py | 12 +++ example/settings/dev.py | 6 ++ example/tests/test_filters.py | 140 +++++++++++++++++++++++++++++ example/tests/test_utils.py | 17 ++++ example/views.py | 2 + requirements-development.txt | 1 + rest_framework_json_api/filters.py | 19 ++++ rest_framework_json_api/utils.py | 51 ++++++++--- setup.py | 1 + 11 files changed, 272 insertions(+), 13 deletions(-) create mode 100644 example/filters.py create mode 100644 example/tests/test_filters.py create mode 100644 rest_framework_json_api/filters.py diff --git a/.gitignore b/.gitignore index 2a7b1180..384a855e 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,8 @@ pip-delete-this-directory.txt # VirtualEnv .venv/ + +#python3 pyvenv +bin/ +lib64 +pyvenv.cfg diff --git a/docs/usage.md b/docs/usage.md index 3cb9d157..2ee017c7 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,8 +1,7 @@ # Usage -The DJA package implements a custom renderer, parser, exception handler, and -pagination. To get started enable the pieces in `settings.py` that you want to use. +The DJA package implements a custom renderer, parser, exception handler, pagination and filter backend. To get started enable the pieces in `settings.py` that you want to use. Many features of the JSON:API format standard have been implemented using Mixin classes in `serializers.py`. The easiest way to make use of those features is to import ModelSerializer variants @@ -26,6 +25,10 @@ REST_FRAMEWORK = { 'rest_framework.renderers.BrowsableAPIRenderer', ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', + 'DEFAULT_FILTER_BACKENDS': ( + 'rest_framework_json_api.filters.JsonApiFilterBackend', + ) + } ``` @@ -462,3 +465,27 @@ Related links will be created automatically when using the Relationship View. ### Included ### Errors --> + +### Filtering + +JSON API spefications is agnostic towards filtering. However, it instructs that the `filter` keyword should be reserved for querying filtered resources, nothing other than that. Although, the specs recommend the following pattern for filtering: + +``` +GET /comments?filter[post]=1 HTTP/1.1 +``` + +DJA package implements its own filter backend (JsonApiFilterBackend) which can be enabled by configuring 'DEFAULT_FILTER_BACKENDS' (as included in the beginning). The backend depends on the DRF's own filtering which depends on [`django-filter`](https://github.com/carltongibson/django-filter). A substitute for `django-filter` is [`django-rest-framework-filters`](https://github.com/philipn/django-rest-framework-filters). The DJA provided filter backend can use both packages. + +The default filter format is set to match the above recommendation. This can be changed from the settings by modifying 'JSON_API_FILTER_KEYWORD' which is a simple regex. If for example, the square brackets need to be replaced by round parenthesis, the setting can be set to: +``` +JSON_API_FILTER_KEYWORD = 'filter\((?P\w+)\)' +``` + +Now the query should look like: +``` +GET /comments?filter(post)=1 HTTP/1.1 +``` + +The backend basically takes the request query paramters, which are formatted as the specs recommend, and reformats them in order to be used by DRF filtering. + +How the filtering actually works and how to deal with queries such as: `GET /comments?filter[post]=1,2,3 HTTP/1.1` is something user dependent and beyond the scope of DJA. diff --git a/example/filters.py b/example/filters.py new file mode 100644 index 00000000..3fe72b74 --- /dev/null +++ b/example/filters.py @@ -0,0 +1,12 @@ +import django_filters + +from example.models import Comment + + +class CommentFilter(django_filters.FilterSet): + + class Meta: + model = Comment + fileds = {'body': ['exact', 'in', 'icontains', 'contains'], + 'author': ['exact', 'gte', 'lte'], + } diff --git a/example/settings/dev.py b/example/settings/dev.py index 3cc1d6e1..21f0997d 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -61,6 +61,8 @@ JSON_API_FORMAT_KEYS = 'camelize' JSON_API_FORMAT_TYPES = 'camelize' +JSON_API_FILTER_KEYWORD = 'filter\[(?P\w+)\]' + REST_FRAMEWORK = { 'PAGE_SIZE': 5, 'EXCEPTION_HANDLER': 'rest_framework_json_api.exceptions.exception_handler', @@ -76,4 +78,8 @@ 'rest_framework.renderers.BrowsableAPIRenderer', ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', + 'DEFAULT_FILTER_BACKENDS': ( + 'rest_framework_json_api.filters.JsonApiFilterBackend', + ) + } diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py new file mode 100644 index 00000000..f4c3d798 --- /dev/null +++ b/example/tests/test_filters.py @@ -0,0 +1,140 @@ +from django.core.urlresolvers import reverse + +from rest_framework.settings import api_settings + +import pytest + +from example.tests.utils import dump_json, redump_json + +pytestmark = pytest.mark.django_db + + +class TestJsonApiFilter(object): + + def test_request_without_filter(self, client, comment_factory): + comment = comment_factory() + comment2 = comment_factory() + + expected = { + "links": { + "first": "http://testserver/comments?page=1", + "last": "http://testserver/comments?page=2", + "next": "http://testserver/comments?page=2", + "prev": None + }, + "data": [ + { + "type": "comments", + "id": str(comment.pk), + "attributes": { + "body": comment.body + }, + "relationships": { + "entry": { + "data": { + "type": "entries", + "id": str(comment.entry.pk) + } + }, + "author": { + "data": { + "type": "authors", + "id": str(comment.author.pk) + } + }, + } + } + ], + "meta": { + "pagination": { + "page": 1, + "pages": 2, + "count": 2 + } + } + } + + response = client.get('/comments') + # assert 0 + + assert response.status_code == 200 + actual = redump_json(response.content) + expected_json = dump_json(expected) + assert actual == expected_json + + def test_request_with_filter(self, client, comment_factory): + comment = comment_factory(body='Body for comment 1') + comment2 = comment_factory() + + expected = { + "links": { + "first": "http://testserver/comments?filter%5Bbody%5D=Body+for+comment+1&page=1", + "last": "http://testserver/comments?filter%5Bbody%5D=Body+for+comment+1&page=1", + "next": None, + "prev": None + }, + "data": [ + { + "type": "comments", + "id": str(comment.pk), + "attributes": { + "body": comment.body + }, + "relationships": { + "entry": { + "data": { + "type": "entries", + "id": str(comment.entry.pk) + } + }, + "author": { + "data": { + "type": "authors", + "id": str(comment.author.pk) + } + }, + } + } + ], + "meta": { + "pagination": { + "page": 1, + "pages": 1, + "count": 1 + } + } + } + + response = client.get('/comments?filter[body]=Body for comment 1') + + assert response.status_code == 200 + actual = redump_json(response.content) + expected_json = dump_json(expected) + assert actual == expected_json + + def test_failed_request_with_filter(self, client, comment_factory): + comment = comment_factory(body='Body for comment 1') + comment2 = comment_factory() + + expected = { + "links": { + "first": "http://testserver/comments?filter%5Bbody%5D=random+comment&page=1", + "last": "http://testserver/comments?filter%5Bbody%5D=random+comment&page=1", + "next": None, + "prev": None + }, + "data": [], + "meta": { + "pagination": { + "page": 1, + "pages": 1, + "count": 0 + } + } + } + + response = client.get('/comments?filter[body]=random comment') + assert response.status_code == 200 + actual = redump_json(response.content) + expected_json = dump_json(expected) + assert actual == expected_json diff --git a/example/tests/test_utils.py b/example/tests/test_utils.py index 3772ebe1..a924f75a 100644 --- a/example/tests/test_utils.py +++ b/example/tests/test_utils.py @@ -1,8 +1,12 @@ """ Test rest_framework_json_api's utils functions. """ +from django.http import QueryDict + from rest_framework_json_api import utils +import pytest + from ..serializers import EntrySerializer from ..tests import TestBase @@ -29,3 +33,16 @@ def test_m2m_relation(self): field = serializer.fields['authors'] self.assertEqual(utils.get_related_resource_type(field), 'authors') + + +def test_format_query_params(settings): + query_params = QueryDict( + 'filter[name]=Smith&filter[age]=50&other_random_param=10', + mutable=True) + + new_params = utils.format_query_params(query_params) + + expected_params = QueryDict('name=Smith&age=50&other_random_param=10') + + for key, value in new_params.items(): + assert expected_params[key] == new_params[key] diff --git a/example/views.py b/example/views.py index 988cda66..7726c93c 100644 --- a/example/views.py +++ b/example/views.py @@ -9,6 +9,7 @@ from example.models import Blog, Entry, Author, Comment from example.serializers import ( BlogSerializer, EntrySerializer, AuthorSerializer, CommentSerializer) +from example.filters import CommentFilter from rest_framework_json_api.utils import format_drf_errors @@ -70,6 +71,7 @@ class AuthorViewSet(viewsets.ModelViewSet): class CommentViewSet(viewsets.ModelViewSet): queryset = Comment.objects.all() serializer_class = CommentSerializer + filter_class = CommentFilter class EntryRelationshipView(RelationshipView): diff --git a/requirements-development.txt b/requirements-development.txt index 1ac48b58..d9c95a45 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -3,5 +3,6 @@ pytest>=2.9.0,<3.0 pytest-django pytest-factoryboy fake-factory +django-filter tox mock diff --git a/rest_framework_json_api/filters.py b/rest_framework_json_api/filters.py new file mode 100644 index 00000000..2c95c238 --- /dev/null +++ b/rest_framework_json_api/filters.py @@ -0,0 +1,19 @@ +try: + import rest_framework_filters + DjangoFilterBackend = rest_framework_filters.backends.DjangoFilterBackend +except ImportError: + from rest_framework import filters + DjangoFilterBackend = filters.DjangoFilterBackend + +from rest_framework_json_api.utils import format_query_params + +class JsonApiFilterBackend(DjangoFilterBackend): + + def filter_queryset(self, request, queryset, view): + + filter_class = self.get_filter_class(view, queryset) + new_query_params = format_query_params(request.query_params) + if filter_class: + return filter_class(new_query_params, queryset=queryset).qs + + return queryset diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 3f247da8..1c52e531 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -2,6 +2,7 @@ Utils. """ import copy +import re import inspect import warnings from collections import OrderedDict @@ -13,6 +14,7 @@ import django from django.conf import settings from django.db.models import Manager +from django.http import QueryDict from django.utils import encoding, six from django.utils.module_loading import import_string as import_class_from_dotted_path from django.utils.translation import ugettext_lazy as _ @@ -72,7 +74,8 @@ def get_resource_name(context): # The resource name is not a string - return as is return resource_name - # the name was calculated automatically from the view> pluralize and format + # the name was calculated automatically from the view> pluralize + # and format resource_name = format_resource_type(resource_name) return resource_name @@ -182,7 +185,8 @@ def get_related_resource_type(relation): if hasattr(relation, '_meta'): relation_model = relation._meta.model elif hasattr(relation, 'model'): - # the model type was explicitly passed as a kwarg to ResourceRelatedField + # the model type was explicitly passed as a kwarg to + # ResourceRelatedField relation_model = relation.model elif hasattr(relation, 'get_queryset') and relation.get_queryset() is not None: relation_model = relation.get_queryset().model @@ -192,16 +196,20 @@ def get_related_resource_type(relation): if hasattr(parent_serializer, 'Meta'): parent_model = getattr(parent_serializer.Meta, 'model', None) elif hasattr(parent_serializer, 'parent') and hasattr(parent_serializer.parent, 'Meta'): - parent_model = getattr(parent_serializer.parent.Meta, 'model', None) + parent_model = getattr( + parent_serializer.parent.Meta, 'model', None) if parent_model is not None: if relation.source: if relation.source != '*': - parent_model_relation = getattr(parent_model, relation.source) + parent_model_relation = getattr( + parent_model, relation.source) else: - parent_model_relation = getattr(parent_model, relation.field_name) + parent_model_relation = getattr( + parent_model, relation.field_name) else: - parent_model_relation = getattr(parent_model, parent_serializer.field_name) + parent_model_relation = getattr( + parent_model, parent_serializer.field_name) if type(parent_model_relation) is ReverseManyToOneDescriptor: if django.VERSION>= (1, 9): @@ -218,7 +226,8 @@ def get_related_resource_type(relation): return get_related_resource_type(parent_model_relation) if relation_model is None: - raise APIException(_('Could not resolve resource type for relation %s' % relation)) + raise APIException( + _('Could not resolve resource type for relation %s' % relation)) return get_resource_type_from_model(relation_model) @@ -258,7 +267,8 @@ def get_resource_type_from_serializer(serializer): def get_included_resources(request, serializer=None): """ Build a list of included resources. """ - include_resources_param = request.query_params.get('include') if request else None + include_resources_param = request.query_params.get( + 'include') if request else None if include_resources_param: return include_resources_param.split(',') else: @@ -273,14 +283,17 @@ def get_default_included_resources_from_serializer(serializer): def get_included_serializers(serializer): - included_serializers = copy.copy(getattr(serializer, 'included_serializers', dict())) + included_serializers = copy.copy( + getattr(serializer, 'included_serializers', dict())) for name, value in six.iteritems(included_serializers): if not isinstance(value, type): if value == 'self': - included_serializers[name] = serializer if isinstance(serializer, type) else serializer.__class__ + included_serializers[name] = serializer if isinstance( + serializer, type) else serializer.__class__ else: - included_serializers[name] = import_class_from_dotted_path(value) + included_serializers[ + name] = import_class_from_dotted_path(value) return included_serializers @@ -381,3 +394,19 @@ def format_errors(data): if len(data)> 1 and isinstance(data, list): data.sort(key=lambda x: x.get('source', {}).get('pointer', '')) return {'errors': data} + + +def format_query_params(query_params): + new_query_params = QueryDict(mutable=True) + for query_field, query_value in query_params.lists(): + keyword_pattern = getattr(settings, 'JSON_API_FILTER_KEYWORD', + 'filter\[(?P\w+)\]') + keyword = re.search(keyword_pattern, query_field) + if keyword: + new_field = keyword.group('field') + else: + new_field = query_field + + [new_query_params.update({new_field: x}) for x in query_value] + + return new_query_params diff --git a/setup.py b/setup.py index ca3a8753..b09dc86b 100755 --- a/setup.py +++ b/setup.py @@ -103,6 +103,7 @@ def get_package_data(package): ], setup_requires=pytest_runner + sphinx + wheel, tests_require=[ + 'django-filter', 'pytest-factoryboy', 'pytest-django', 'pytest>=2.8,<3',

AltStyle によって変換されたページ (->オリジナル) /