From 8821b9908b8fc0d4c1be401f147765b185b0eef5 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Tue, 21 Aug 2018 07:59:42 -0400 Subject: [PATCH 1/7] correct running example app documentation (#457) --- docs/getting-started.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index d68bbdd3..49bd64b7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -68,15 +68,15 @@ From Source ## Running the example app - git clone https://github.com/django-json-api/django-rest-framework-json-api.git - cd django-rest-framework-json-api - python -m venv env - source env/bin/activate - pip install -r example/requirements.txt + git clone https://github.com/django-json-api/django-rest-framework-json-api.git + cd django-rest-framework-json-api + python3 -m venv env + source env/bin/activate + pip install -r example/requirements.txt pip install -e . - django-admin.py startproject example . - python manage.py migrate - python manage.py runserver + django-admin migrate --settings=example.settings + django-admin runserver --settings=example.settings + Browse to http://localhost:8000 From 95e6d8db1e072a93122a0df7e5c5f26a8a0a1cdf Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Tue, 21 Aug 2018 15:48:25 -0400 Subject: [PATCH 2/7] Drf example fixtures (#462) * replace binary drf_example sqlite3 db file with fixture --- .gitignore | 3 + CHANGELOG.md | 1 + docs/getting-started.md | 1 + drf_example | Bin 80896 -> 0 bytes example/fixtures/drf_example.json | 124 ++++++++++++++++++++++++++++++ example/requirements.txt | 1 + 6 files changed, 130 insertions(+) delete mode 100644 drf_example create mode 100644 example/fixtures/drf_example.json diff --git a/.gitignore b/.gitignore index 1207cc48..6b952e8f 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ pip-delete-this-directory.txt *.sw* manage.py .DS_Store + +# example database +drf_example diff --git a/CHANGELOG.md b/CHANGELOG.md index fc160067..c591958f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * 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). v2.5.0 - Released July 11, 2018 diff --git a/docs/getting-started.md b/docs/getting-started.md index 49bd64b7..26117e0b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -75,6 +75,7 @@ From Source pip install -r example/requirements.txt pip install -e . django-admin migrate --settings=example.settings + django-admin loaddata drf_example --settings=example.settings django-admin runserver --settings=example.settings diff --git a/drf_example b/drf_example deleted file mode 100644 index 2b54190f8873c12784558b18f8aac186b0c613d8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 80896 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 S8Q4Rr>$y&NsViZN@&5r)Myl}u diff --git a/example/fixtures/drf_example.json b/example/fixtures/drf_example.json new file mode 100644 index 00000000..498c0d1c --- /dev/null +++ b/example/fixtures/drf_example.json @@ -0,0 +1,124 @@ +[ +{ + "model": "example.blog", + "pk": 1, + "fields": { + "created_at": "2016-05-02T08:27:16.889", + "modified_at": "2016-05-02T08:27:16.889", + "name": "Personal", + "tagline": "" + } +}, +{ + "model": "example.blog", + "pk": 2, + "fields": { + "created_at": "2016-05-02T08:27:23.871", + "modified_at": "2016-05-02T08:27:23.871", + "name": "Work", + "tagline": "" + } +}, +{ + "model": "example.author", + "pk": 1, + "fields": { + "created_at": "2016-05-02T10:09:48.277", + "modified_at": "2016-05-02T10:09:48.277", + "name": "Alice", + "email": "alice@example.com", + "type": null + } +}, +{ + "model": "example.author", + "pk": 2, + "fields": { + "created_at": "2016-05-02T10:09:57.133", + "modified_at": "2016-05-02T10:09:57.133", + "name": "Bob", + "email": "bob@example.com", + "type": null + } +}, +{ + "model": "example.authorbio", + "pk": 1, + "fields": { + "created_at": "2016-05-02T10:10:23.429", + "modified_at": "2016-05-02T10:10:23.429", + "author": 1, + "body": "I just want to send messages to Bob." + } +}, +{ + "model": "example.authorbio", + "pk": 2, + "fields": { + "created_at": "2016-05-02T10:11:30.327", + "modified_at": "2016-05-02T10:11:30.327", + "author": 2, + "body": "I get messages from Alice and send them to Carol" + } +}, +{ + "model": "example.entry", + "pk": 1, + "fields": { + "created_at": "2016-05-02T10:43:21.271", + "modified_at": "2016-05-02T10:43:21.271", + "blog": 1, + "headline": "This is a test, this is only a test", + "body_text": "And this is the body text for the blog entry. To see comments included in this payload visit: /entries/1?include=comments", + "pub_date": "2015-01-01", + "mod_date": "2015-04-05", + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [ + 1 + ] + } +}, +{ + "model": "example.entry", + "pk": 2, + "fields": { + "created_at": "2016-05-02T10:44:14.376", + "modified_at": "2016-05-02T10:49:30.150", + "blog": 1, + "headline": "Django, the framework for perfectionists with deadlines", + "body_text": "And this is the body text. Try out includes by using this uri: /entries/2?include=comments,authors,authors.bio", + "pub_date": "2015-05-01", + "mod_date": "2015-09-03", + "n_comments": 0, + "n_pingbacks": 0, + "rating": 0, + "authors": [ + 2 + ] + } +}, +{ + "model": "example.comment", + "pk": 1, + "fields": { + "created_at": "2016-05-02T10:44:35.093", + "modified_at": "2016-05-02T10:44:35.093", + "entry": 1, + "body": "Love this article!", + "author": 2 + } +}, +{ + "model": "example.comment", + "pk": 2, + "fields": { + "created_at": "2016-05-02T10:44:55.482", + "modified_at": "2016-05-02T10:44:55.482", + "entry": 2, + "body": "Frist comment!!!", + "author": null + } +} +] diff --git a/example/requirements.txt b/example/requirements.txt index 0fa77009..fe28eddc 100644 --- a/example/requirements.txt +++ b/example/requirements.txt @@ -11,3 +11,4 @@ pyparsing pytz six sqlparse + From 81d2236eadd998d2ed47012f1740337bfbaa0e9b Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Tue, 21 Aug 2018 21:21:27 -0400 Subject: [PATCH 3/7] JSONAPIOrderingFilter (#459) JSONAPIOrderingFilter includes @sliverc review requested changes - use json instead of yaml fixture to remove dependency on PyYAML - use reverse() - request.data dict instead of string concat to the url - response.json() instead of json.loads(response.content.decode(...) --- CHANGELOG.md | 3 +- README.rst | 3 +- docs/usage.md | 37 +++- example/fixtures/blogentry.json | 280 ++++++++++++++++++++++++++++ example/settings/dev.py | 2 +- example/tests/test_backends.py | 54 ++++++ requirements-development.txt | 1 + rest_framework_json_api/backends.py | 36 ++++ 8 files changed, 407 insertions(+), 9 deletions(-) create mode 100644 example/fixtures/blogentry.json create mode 100644 example/tests/test_backends.py create mode 100644 rest_framework_json_api/backends.py 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) From e6290af5c34bf1760cb8c79fb8648b58e9336685 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Wed, 22 Aug 2018 03:10:31 -0400 Subject: [PATCH 4/7] deprecated JsonApi paginators class prefix to JSONAPI prefix for consistency (#463) --- CHANGELOG.md | 2 ++ README.rst | 2 +- docs/usage.md | 16 +++++----- example/tests/unit/test_pagination.py | 16 ++++++++-- example/views.py | 6 ++-- rest_framework_json_api/pagination.py | 43 +++++++++++++++++++++++---- 6 files changed, 64 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6063d671..354c1b5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ * 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` v2.5.0 - Released July 11, 2018 diff --git a/README.rst b/README.rst index 6fa4aee5..7d345185 100644 --- a/README.rst +++ b/README.rst @@ -161,7 +161,7 @@ override ``settings.REST_FRAMEWORK`` 'PAGE_SIZE': 10, 'EXCEPTION_HANDLER': 'rest_framework_json_api.exceptions.exception_handler', 'DEFAULT_PAGINATION_CLASS': - 'rest_framework_json_api.pagination.JsonApiPageNumberPagination', + 'rest_framework_json_api.pagination.JSONAPIPageNumberPagination', 'DEFAULT_PARSER_CLASSES': ( 'rest_framework_json_api.parsers.JSONParser', 'rest_framework.parsers.FormParser', diff --git a/docs/usage.md b/docs/usage.md index 75fbad7d..e172df47 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -16,7 +16,7 @@ REST_FRAMEWORK = { 'PAGE_SIZE': 10, 'EXCEPTION_HANDLER': 'rest_framework_json_api.exceptions.exception_handler', 'DEFAULT_PAGINATION_CLASS': - 'rest_framework_json_api.pagination.JsonApiPageNumberPagination', + 'rest_framework_json_api.pagination.JSONAPIPageNumberPagination', 'DEFAULT_PARSER_CLASSES': ( 'rest_framework_json_api.parsers.JSONParser', 'rest_framework.parsers.FormParser', @@ -58,15 +58,15 @@ You can configure fixed values for the page size or limit -- or allow the client via query parameters. Two pagination classes are available: -- `JsonApiPageNumberPagination` breaks a response up into pages that start at a given page number with a given size - (number of items per page). It can be configured with the following attributes: +- `JSONAPIPageNumberPagination` breaks a response up into pages that start at a given page number + with a given size (number of items per page). It can be configured with the following attributes: - `page_query_param` (default `page[number]`) - `page_size_query_param` (default `page[size]`) Set this to `None` if you don't want to allow the client to specify the size. - `max_page_size` (default `100`) enforces an upper bound on the `page_size_query_param`. Set it to `None` if you don't want to enforce an upper bound. -- `JsonApiLimitOffsetPagination` breaks a response up into pages that start from an item's offset in the viewset for - a given number of items (the limit). +- `JSONAPILimitOffsetPagination` breaks a response up into pages that start from an item's offset + in the viewset for a given number of items (the limit). It can be configured with the following attributes: - `offset_query_param` (default `page[offset]`). - `limit_query_param` (default `page[limit]`). @@ -77,14 +77,14 @@ Two pagination classes are available: These examples show how to configure the parameters to use non-standard names and different limits: ```python -from rest_framework_json_api.pagination import JsonApiPageNumberPagination, JsonApiLimitOffsetPagination +from rest_framework_json_api.pagination import JSONAPIPageNumberPagination, JSONAPILimitOffsetPagination -class MyPagePagination(JsonApiPageNumberPagination): +class MyPagePagination(JSONAPIPageNumberPagination): page_query_param = 'page_number' page_size_query_param = 'page_size' max_page_size = 1000 -class MyLimitPagination(JsonApiLimitOffsetPagination): +class MyLimitPagination(JSONAPILimitOffsetPagination): offset_query_param = 'offset' limit_query_param = 'limit' max_limit = None diff --git a/example/tests/unit/test_pagination.py b/example/tests/unit/test_pagination.py index f6e95db0..5fdcade6 100644 --- a/example/tests/unit/test_pagination.py +++ b/example/tests/unit/test_pagination.py @@ -13,11 +13,11 @@ class TestLimitOffset: """ - Unit tests for `pagination.JsonApiLimitOffsetPagination`. + Unit tests for `pagination.JSONAPILimitOffsetPagination`. """ def setup(self): - class ExamplePagination(pagination.JsonApiLimitOffsetPagination): + class ExamplePagination(pagination.JSONAPILimitOffsetPagination): default_limit = 10 max_limit = 15 @@ -85,13 +85,18 @@ def test_limit_offset_deprecation(self): assert len(record) == 1 assert 'LimitOffsetPagination' in str(record[0].message) + with pytest.warns(DeprecationWarning) as record: + pagination.JsonApiLimitOffsetPagination() + assert len(record) == 1 + assert 'JsonApiLimitOffsetPagination' in str(record[0].message) + # TODO: This test fails under py27 but it's not clear why so just leave it out for now. @pytest.mark.xfail((sys.version_info.major, sys.version_info.minor) == (2, 7), reason="python2.7 fails for unknown reason") class TestPageNumber: """ - Unit tests for `pagination.JsonApiPageNumberPagination`. + Unit tests for `pagination.JSONAPIPageNumberPagination`. TODO: add unit tests for changing query parameter names, limits, etc. """ def test_page_number_deprecation(self): @@ -99,3 +104,8 @@ def test_page_number_deprecation(self): pagination.PageNumberPagination() assert len(record) == 1 assert 'PageNumberPagination' in str(record[0].message) + + with pytest.warns(DeprecationWarning) as record: + pagination.JsonApiPageNumberPagination() + assert len(record) == 1 + assert 'JsonApiPageNumberPagination' in str(record[0].message) diff --git a/example/views.py b/example/views.py index 5dfc3341..a42a80ae 100644 --- a/example/views.py +++ b/example/views.py @@ -34,7 +34,7 @@ def get_object(self): return super(BlogViewSet, self).get_object() -class JsonApiViewSet(ModelViewSet): +class JSONAPIViewSet(ModelViewSet): """ This is an example on how to configure DRF-jsonapi from within a class. It allows using DRF-jsonapi alongside @@ -58,12 +58,12 @@ def handle_exception(self, exc): exc.status_code = HTTP_422_UNPROCESSABLE_ENTITY # exception handler can't be set on class so you have to # override the error response in this method - response = super(JsonApiViewSet, self).handle_exception(exc) + response = super(JSONAPIViewSet, self).handle_exception(exc) context = self.get_exception_handler_context() return format_drf_errors(response, context, exc) -class BlogCustomViewSet(JsonApiViewSet): +class BlogCustomViewSet(JSONAPIViewSet): queryset = Blog.objects.all() serializer_class = BlogSerializer diff --git a/rest_framework_json_api/pagination.py b/rest_framework_json_api/pagination.py index 00873c99..b150aa83 100644 --- a/rest_framework_json_api/pagination.py +++ b/rest_framework_json_api/pagination.py @@ -9,7 +9,7 @@ from rest_framework.views import Response -class JsonApiPageNumberPagination(PageNumberPagination): +class JSONAPIPageNumberPagination(PageNumberPagination): """ A json-api compatible pagination format """ @@ -50,7 +50,7 @@ def get_paginated_response(self, data): }) -class JsonApiLimitOffsetPagination(LimitOffsetPagination): +class JSONAPILimitOffsetPagination(LimitOffsetPagination): """ A limit/offset based style. For example: http://api.example.org/accounts/?page[limit]=100 @@ -100,7 +100,23 @@ def get_paginated_response(self, data): }) -class PageNumberPagination(JsonApiPageNumberPagination): +class JsonApiPageNumberPagination(JSONAPIPageNumberPagination): + """ + Deprecated due to desire to use `JSONAPI` prefix for all classes. + """ + page_query_param = 'page' + page_size_query_param = 'page_size' + + def __init__(self): + warnings.warn( + 'JsonApiPageNumberPagination is deprecated. Use JSONAPIPageNumberPagination ' + 'or create custom pagination. See ' + 'https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#pagination', + DeprecationWarning) + super(JsonApiPageNumberPagination, self).__init__() + + +class PageNumberPagination(JSONAPIPageNumberPagination): """ Deprecated paginator that uses different query parameters """ @@ -109,14 +125,29 @@ class PageNumberPagination(JsonApiPageNumberPagination): def __init__(self): warnings.warn( - 'PageNumberPagination is deprecated. Use JsonApiPageNumberPagination ' + 'PageNumberPagination is deprecated. Use JSONAPIPageNumberPagination ' 'or create custom pagination. See ' 'https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#pagination', DeprecationWarning) super(PageNumberPagination, self).__init__() -class LimitOffsetPagination(JsonApiLimitOffsetPagination): +class JsonApiLimitOffsetPagination(JSONAPILimitOffsetPagination): + """ + Deprecated due to desire to use `JSONAPI` prefix for all classes. + """ + max_limit = None + + def __init__(self): + warnings.warn( + 'JsonApiLimitOffsetPagination is deprecated. Use JSONAPILimitOffsetPagination ' + 'or create custom pagination. See ' + 'https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#pagination', + DeprecationWarning) + super(JsonApiLimitOffsetPagination, self).__init__() + + +class LimitOffsetPagination(JSONAPILimitOffsetPagination): """ Deprecated paginator that uses a different max_limit """ @@ -124,7 +155,7 @@ class LimitOffsetPagination(JsonApiLimitOffsetPagination): def __init__(self): warnings.warn( - 'LimitOffsetPagination is deprecated. Use JsonApiLimitOffsetPagination ' + 'LimitOffsetPagination is deprecated. Use JSONAPILimitOffsetPagination ' 'or create custom pagination. See ' 'https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#pagination', DeprecationWarning) From 0d7afccd87493e3240c26ed88c8ac7386f78c6e5 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Wed, 22 Aug 2018 10:48:12 -0400 Subject: [PATCH 5/7] rename `backends` to `filters` --- docs/usage.md | 18 ++++++++++++++++-- example/settings/dev.py | 3 +-- .../{test_backends.py => test_filters.py} | 0 .../{backends.py => filters.py} | 0 4 files changed, 17 insertions(+), 4 deletions(-) rename example/tests/{test_backends.py => test_filters.py} (100%) rename rest_framework_json_api/{backends.py => filters.py} (100%) diff --git a/docs/usage.md b/docs/usage.md index e172df47..b9d89ecf 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -33,7 +33,7 @@ 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', ), 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', @@ -98,7 +98,7 @@ _This is the first of several anticipated JSON:API-specific filter backends._ `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`, +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 field name and the other two are not valid: ```json @@ -118,6 +118,20 @@ 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`. +#### 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: + + ```python +from rest_framework_json_api import filters + +class MyViewset(ModelViewSet): + queryset = MyModel.objects.all() + serializer_class = MyModelSerializer + filter_backends = (filters.JSONAPIOrderingFilter,) +``` + ### Performance Testing diff --git a/example/settings/dev.py b/example/settings/dev.py index e8ed4094..6856a91b 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -89,9 +89,8 @@ ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( - 'rest_framework_json_api.backends.JSONAPIOrderingFilter', + 'rest_framework_json_api.filters.JSONAPIOrderingFilter', ), - 'ORDERING_PARAM': 'sort', 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', ), diff --git a/example/tests/test_backends.py b/example/tests/test_filters.py similarity index 100% rename from example/tests/test_backends.py rename to example/tests/test_filters.py diff --git a/rest_framework_json_api/backends.py b/rest_framework_json_api/filters.py similarity index 100% rename from rest_framework_json_api/backends.py rename to rest_framework_json_api/filters.py From d77b17a2c3c462ba8a00cf30d1aaf561a56fc810 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Wed, 22 Aug 2018 17:18:46 -0400 Subject: [PATCH 6/7] bugfix: camelcase, etc. sort parameters were being ignored. Also added more test_cases and suppor for sorting via relationship paths. --- example/tests/test_filters.py | 59 ++++++++++++++++++++++++++++-- example/views.py | 1 + rest_framework_json_api/filters.py | 24 ++++++++---- 3 files changed, 72 insertions(+), 12 deletions(-) diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index 0721f780..2b18b5f3 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -24,8 +24,7 @@ def test_sort(self): 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() + sorted_headlines = sorted(headlines) self.assertEqual(headlines, sorted_headlines) def test_sort_reverse(self): @@ -37,8 +36,19 @@ def test_sort_reverse(self): 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() + sorted_headlines = sorted(headlines) + self.assertNotEqual(headlines, sorted_headlines) + + def test_sort_double_negative(self): + """ + what if they provide multiple `-`'s? It's OK. + """ + 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 = sorted(headlines) self.assertNotEqual(headlines, sorted_headlines) def test_sort_invalid(self): @@ -52,3 +62,44 @@ def test_sort_invalid(self): dja_response = response.json() self.assertEqual(dja_response['errors'][0]['detail'], "invalid sort parameters: nonesuch,-not_a_field") + + def test_sort_camelcase(self): + """ + test sort of camelcase field name + """ + response = self.client.get(self.url, data={'sort': 'bodyText'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + blog_ids = [(c['attributes']['bodyText'] or '') for c in dja_response['data']] + sorted_blog_ids = sorted(blog_ids) + self.assertEqual(blog_ids, sorted_blog_ids) + + def test_sort_underscore(self): + """ + test sort of underscore field name + Do we allow this notation in a search even if camelcase is in effect? + "Be conservative in what you send, be liberal in what you accept" + -- https://en.wikipedia.org/wiki/Robustness_principle + """ + response = self.client.get(self.url, data={'sort': 'body_text'}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + blog_ids = [(c['attributes']['bodyText'] or '') for c in dja_response['data']] + sorted_blog_ids = sorted(blog_ids) + self.assertEqual(blog_ids, sorted_blog_ids) + + def test_sort_related(self): + """ + test sort via related field using jsonapi path `.` and django orm `__` notation. + ORM relations must be predefined in the View's .ordering_fields attr + """ + for datum in ('blog__id', 'blog.id'): + response = self.client.get(self.url, data={'sort': datum}) + self.assertEqual(response.status_code, 200, + msg=response.content.decode("utf-8")) + dja_response = response.json() + 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) diff --git a/example/views.py b/example/views.py index a42a80ae..36026b17 100644 --- a/example/views.py +++ b/example/views.py @@ -90,6 +90,7 @@ class NoPagination(PageNumberPagination): class NonPaginatedEntryViewSet(EntryViewSet): pagination_class = NoPagination + ordering_fields = ('headline', 'body_text', 'blog__name', 'blog__id') class AuthorViewSet(ModelViewSet): diff --git a/rest_framework_json_api/filters.py b/rest_framework_json_api/filters.py index e6fda16b..748b18bf 100644 --- a/rest_framework_json_api/filters.py +++ b/rest_framework_json_api/filters.py @@ -10,27 +10,35 @@ class JSONAPIOrderingFilter(OrderingFilter): 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) + 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): - """ - 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 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, fields, view, request) + queryset, underscore_fields, view, request) From a3205367c8050c978c636cb6b7867b5351c3b2d5 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Thu, 23 Aug 2018 08:28:37 -0400 Subject: [PATCH 7/7] Missing contribution (#465) --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index 103d327b..b28cc99c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -6,6 +6,7 @@ Christian Zosel Greg Aker Jamie Bliss Jerel Unruh +Jonathan Senecal Léo S. Luc Cary Matt Layman