diff --git a/CHANGELOG.md b/CHANGELOG.md index c591958f..6063d671 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,10 @@ [unreleased] * Add testing configuration to `REST_FRAMEWORK` configuration as described in [DRF](https://www.django-rest-framework.org/api-guide/testing/#configuration) -* Add sorting configuration to `REST_FRAMEWORK` as defined in [json api spec](http://jsonapi.org/format/#fetching-sorting) * 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) v2.5.0 - Released July 11, 2018 diff --git a/README.rst b/README.rst index 46813188..6fa4aee5 100644 --- a/README.rst +++ b/README.rst @@ -173,9 +173,8 @@ override ``settings.REST_FRAMEWORK`` ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( - 'rest_framework.filters.OrderingFilter', + 'rest_framework_json_api.backends.JSONAPIOrderingFilter', ), - 'ORDERING_PARAM': 'sort', 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', ), diff --git a/docs/usage.md b/docs/usage.md index 25bb7310..75fbad7d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,10 +1,11 @@ # Usage -The DJA package implements a custom renderer, parser, exception handler, and +The DJA package implements a custom renderer, parser, exception handler, query filter backends, and pagination. 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`. +Many features of the [JSON:API](http://jsonapi.org/format) 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 from `rest_framework_json_api` instead of the usual `rest_framework` @@ -32,9 +33,8 @@ REST_FRAMEWORK = { ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( - 'rest_framework.filters.OrderingFilter', + 'rest_framework_json_api.backends.JSONAPIOrderingFilter', ), - 'ORDERING_PARAM': 'sort', 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', ), @@ -90,6 +90,35 @@ class MyLimitPagination(JsonApiLimitOffsetPagination): max_limit = None ``` +### Filter Backends + +_This is the first of several anticipated JSON:API-specific filter backends._ + +#### `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, "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 +field name and the other two are not valid: +```json +{ + "errors": [ + { + "detail": "invalid sort parameters: abc,def", + "source": { + "pointer": "/data" + }, + "status": "400" + } + ] +} +``` + +If you want to silently ignore bad sort fields, just use `rest_framework.filters.OrderingFilter` and set +`ordering_param` to `sort`. + + ### Performance Testing If you are trying to see if your viewsets are configured properly to optimize performance, diff --git a/example/fixtures/blogentry.json b/example/fixtures/blogentry.json new file mode 100644 index 00000000..15ceded9 --- /dev/null +++ b/example/fixtures/blogentry.json @@ -0,0 +1,280 @@ +[ +{ + "model": "example.blog", + "pk": 1, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "name": "ANTB", + "tagline": "ANTHROPOLOGY (BARNARD)" + } +}, +{ + "model": "example.blog", + "pk": 2, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "name": "CLSB", + "tagline": "CLASSICS (BARNARD)" + } +}, +{ + "model": "example.blog", + "pk": 3, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "name": "AMSB", + "tagline": "AMERICAN STUDIES (BARNARD)" + } +}, +{ + "model": "example.blog", + "pk": 4, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "name": "CHMB", + "tagline": "CHEMISTRY (BARNARD)" + } +}, +{ + "model": "example.blog", + "pk": 5, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "name": "ARHB", + "tagline": "ART HISTORY (BARNARD)" + } +}, +{ + "model": "example.blog", + "pk": 6, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "name": "ITLB", + "tagline": "ITALIAN (BARNARD)" + } +}, +{ + "model": "example.blog", + "pk": 7, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "name": "BIOB", + "tagline": "BIOLOGICAL SCIENCES (BARNARD)" + } +}, +{ + "model": "example.entry", + "pk": 1, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "blog": 1, + "headline": "ANTH1009V", + "body_text": "INTRO TO LANGUAGE & CULTURE", + "pub_date": null, + "mod_date": null, + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [ + 1 + ] + } +}, +{ + "model": "example.entry", + "pk": 2, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "blog": 2, + "headline": "CLCV2442V", + "body_text": "EGYPT IN CLASSICAL WORLD-DISC", + "pub_date": null, + "mod_date": null, + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [ + 2 + ] + } +}, +{ + "model": "example.entry", + "pk": 3, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "blog": 3, + "headline": "AMST3704X", + "body_text": "SENIOR RESEARCH ESSAY SEMINAR", + "pub_date": null, + "mod_date": null, + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [] + } +}, +{ + "model": "example.entry", + "pk": 4, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "blog": 1, + "headline": "ANTH3976V", + "body_text": "ANTHROPOLOGY OF SCIENCE", + "pub_date": null, + "mod_date": null, + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [] + } +}, +{ + "model": "example.entry", + "pk": 5, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "blog": 4, + "headline": "CHEM3271X", + "body_text": "INORGANIC CHEMISTRY", + "pub_date": null, + "mod_date": null, + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [] + } +}, +{ + "model": "example.entry", + "pk": 6, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "blog": 5, + "headline": "AHIS3915X", + "body_text": "ISLAM AND MEDIEVAL WEST", + "pub_date": null, + "mod_date": null, + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [] + } +}, +{ + "model": "example.entry", + "pk": 7, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "blog": 1, + "headline": "ANTH3868X", + "body_text": "ETHNOGRAPHIC FIELD RESEARCH IN NYC", + "pub_date": null, + "mod_date": null, + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [] + } +}, +{ + "model": "example.entry", + "pk": 8, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "blog": 6, + "headline": "CLIA3660V", + "body_text": "MAFIA MOVIES", + "pub_date": null, + "mod_date": null, + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [] + } +}, +{ + "model": "example.entry", + "pk": 9, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "blog": 5, + "headline": "AHIS3999X", + "body_text": "INDEPENDENT RESEARCH", + "pub_date": null, + "mod_date": null, + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [] + } +}, +{ + "model": "example.entry", + "pk": 10, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "blog": 7, + "headline": "BIOL3594X", + "body_text": "SENIOR THESIS SEMINAR", + "pub_date": null, + "mod_date": null, + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [] + } +}, +{ + "model": "example.entry", + "pk": 11, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "blog": 7, + "headline": "BIOL9999X", + "body_text": null, + "pub_date": null, + "mod_date": null, + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [] + } +}, +{ + "model": "example.entry", + "pk": 12, + "fields": { + "created_at": "2018-08-20T00:00:00", + "modified_at": "2018-08-20T00:00:00", + "blog": 7, + "headline": "BIOL0000X", + "body_text": "", + "pub_date": null, + "mod_date": null, + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [] + } +} +] diff --git a/example/settings/dev.py b/example/settings/dev.py index 5f938f78..e8ed4094 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -89,7 +89,7 @@ ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( - 'rest_framework.filters.OrderingFilter', + 'rest_framework_json_api.backends.JSONAPIOrderingFilter', ), 'ORDERING_PARAM': 'sort', 'TEST_REQUEST_RENDERER_CLASSES': ( diff --git a/example/tests/test_backends.py b/example/tests/test_backends.py new file mode 100644 index 00000000..0721f780 --- /dev/null +++ b/example/tests/test_backends.py @@ -0,0 +1,54 @@ +from rest_framework.reverse import reverse +from rest_framework.test import APITestCase + +from ..models import Blog, Entry + + +class DJATestParameters(APITestCase): + """ + tests of JSON:API backends + """ + fixtures = ('blogentry',) + + def setUp(self): + self.entries = Entry.objects.all() + self.blogs = Blog.objects.all() + self.url = reverse('nopage-entry-list') + + def test_sort(self): + """ + test sort + """ + response = self.client.get(self.url, data={'sort': 'headline'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + headlines = [c['attributes']['headline'] for c in dja_response['data']] + sorted_headlines = [c['attributes']['headline'] for c in dja_response['data']] + sorted_headlines.sort() + self.assertEqual(headlines, sorted_headlines) + + def test_sort_reverse(self): + """ + confirm switching the sort order actually works + """ + response = self.client.get(self.url, data={'sort': '-headline'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + headlines = [c['attributes']['headline'] for c in dja_response['data']] + sorted_headlines = [c['attributes']['headline'] for c in dja_response['data']] + sorted_headlines.sort() + self.assertNotEqual(headlines, sorted_headlines) + + def test_sort_invalid(self): + """ + test sort of invalid field + """ + response = self.client.get(self.url, + data={'sort': 'nonesuch,headline,-not_a_field'}) + self.assertEqual(response.status_code, 400, + msg=response.content.decode("utf-8")) + dja_response = response.json() + self.assertEqual(dja_response['errors'][0]['detail'], + "invalid sort parameters: nonesuch,-not_a_field") diff --git a/requirements-development.txt b/requirements-development.txt index f5c7cacb..e2e8aae3 100644 --- a/requirements-development.txt +++ b/requirements-development.txt @@ -14,3 +14,4 @@ Sphinx sphinx_rtd_theme tox twine + diff --git a/rest_framework_json_api/backends.py b/rest_framework_json_api/backends.py new file mode 100644 index 00000000..e6fda16b --- /dev/null +++ b/rest_framework_json_api/backends.py @@ -0,0 +1,36 @@ +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'` + + TODO: Add sorting based upon relationships (sort=relname.fieldname) + """ + ordering_param = 'sort' + + def remove_invalid_fields(self, queryset, fields, view, request): + """ + overrides remove_invalid_fields to raise a 400 exception instead of + silently removing them. set `ignore_bad_sort_fields = True` to not + do this validation. + """ + 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.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))) + + return super(JSONAPIOrderingFilter, self).remove_invalid_fields( + queryset, fields, view, request)