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',