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/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 diff --git a/CHANGELOG.md b/CHANGELOG.md index fc160067..354c1b5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,12 @@ [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) +* 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 46813188..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', @@ -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/getting-started.md b/docs/getting-started.md index d68bbdd3..26117e0b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -68,15 +68,16 @@ 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 loaddata drf_example --settings=example.settings + django-admin runserver --settings=example.settings + Browse to http://localhost:8000 diff --git a/docs/usage.md b/docs/usage.md index 25bb7310..b9d89ecf 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` @@ -15,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', @@ -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.filters.JSONAPIOrderingFilter', ), - 'ORDERING_PARAM': 'sort', 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', ), @@ -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,19 +77,62 @@ 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 ``` +### 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 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 +{ + "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`. + +#### 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 If you are trying to see if your viewsets are configured properly to optimize performance, diff --git a/drf_example b/drf_example deleted file mode 100644 index 2b54190f..00000000 Binary files a/drf_example and /dev/null differ 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/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 + diff --git a/example/settings/dev.py b/example/settings/dev.py index 5f938f78..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.filters.OrderingFilter', + '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_filters.py b/example/tests/test_filters.py new file mode 100644 index 00000000..2b18b5f3 --- /dev/null +++ b/example/tests/test_filters.py @@ -0,0 +1,105 @@ +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 = sorted(headlines) + 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 = 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): + """ + 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") + + 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/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..36026b17 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 @@ -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/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/filters.py b/rest_framework_json_api/filters.py new file mode 100644 index 00000000..748b18bf --- /dev/null +++ b/rest_framework_json_api/filters.py @@ -0,0 +1,44 @@ +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'` + + 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): + 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.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, underscore_fields, view, request) 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)