From 03f7e7ead8f240e6a1b2a297ce0be1c37b7ce022 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 18 Aug 2020 14:44:21 +0200 Subject: [PATCH 001/252] Set maximum version of supported DRF and Django (#808) Avoid failling of master if we haven't added support for newly released versions yet. --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 73e86fb1..d37c66f3 100755 --- a/setup.py +++ b/setup.py @@ -90,8 +90,8 @@ def get_package_data(package): ], install_requires=[ 'inflection>=0.3.0', - 'djangorestframework>=3.10', - 'django>=1.11', + 'djangorestframework>=3.10,<3.12', + 'django>=1.11,<3.1', ], extras_require={ 'django-polymorphic': ['django-polymorphic>=2.0'], From 930b4942a8c0fd3ae5b94d117725933eac89bafd Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 18 Aug 2020 16:06:48 +0200 Subject: [PATCH 002/252] Scheduled biweekly dependency update for week 33 (#807) * Update flake8-isort from 3.0.0 to 4.0.0 * Update isort from 4.3.21 to 5.4.2 * Update sphinx from 3.1.1 to 3.2.1 * Update sphinx_rtd_theme from 0.4.3 to 0.5.0 * Update twine from 3.1.1 to 3.2.0 * Update factory-boy from 2.12.0 to 3.0.1 * Update faker from 4.1.0 to 4.1.1 * Update pytest from 5.4.3 to 6.0.1 * Update pytest-cov from 2.10.0 to 2.10.1 Co-authored-by: Oliver Sauder --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-documentation.txt | 4 ++-- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 8 ++++---- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 8f66dcab..ae4d7e69 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,3 +1,3 @@ flake8==3.8.3 -flake8-isort==3.0.0 -isort==4.3.21 +flake8-isort==4.0.0 +isort==5.4.2 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index ddbfe30b..f3cb3cac 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.6.0 -Sphinx==3.1.1 -sphinx_rtd_theme==0.4.3 +Sphinx==3.2.1 +sphinx_rtd_theme==0.5.0 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index 53d9454a..7bc36909 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==3.1.1 +twine==3.2.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 8f710ebf..357bf12a 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ django-debug-toolbar==2.2 -factory-boy==2.12.0 -Faker==4.1.0 -pytest==5.4.3 -pytest-cov==2.10.0 +factory-boy==3.0.1 +Faker==4.1.1 +pytest==6.0.1 +pytest-cov==2.10.1 pytest-django==3.9.0 pytest-factoryboy==2.0.3 From 42173db04744f1a321703d589deb6fa5d49a8a18 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sat, 22 Aug 2020 17:16:19 +0200 Subject: [PATCH 003/252] Install DJA locally within requirements context (#809) This resolves issue that ReadTheDocs uses setup.py and requirements.txt separately. Additionally is it now easier to run test without running several pip installs. --- README.rst | 2 +- docs/getting-started.md | 2 +- requirements.txt | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 5541eba9..446d035e 100644 --- a/README.rst +++ b/README.rst @@ -128,7 +128,7 @@ installed and activated: $ git clone https://github.com/django-json-api/django-rest-framework-json-api.git $ cd django-rest-framework-json-api - $ pip install -U -e . -r requirements.txt + $ pip install -Ur requirements.txt $ django-admin migrate --settings=example.settings $ django-admin loaddata drf_example --settings=example.settings $ django-admin runserver --settings=example.settings diff --git a/docs/getting-started.md b/docs/getting-started.md index 00d77c61..bef744eb 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -77,7 +77,7 @@ From Source cd django-rest-framework-json-api python3 -m venv env source env/bin/activate - pip install -U -e . r requirements.txt + pip install -Ur requirements.txt django-admin migrate --settings=example.settings django-admin loaddata drf_example --settings=example.settings django-admin runserver --settings=example.settings diff --git a/requirements.txt b/requirements.txt index 862b4aa8..f1ba742f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,9 +3,10 @@ # there are a number of packages that are useful to install. # Laying these out as separate requirements files, allows us to -# only included the relevant sets when running tox, and ensures +# only include the relevant sets when running tox, and ensures # we are only ever declaring our dependencies in one place. +-e . -r requirements/requirements-optionals.txt -r requirements/requirements-testing.txt -r requirements/requirements-documentation.txt From 3eaf07cf16924e8ee1029fe911b47278b7e9eb1a Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sat, 22 Aug 2020 17:59:14 +0200 Subject: [PATCH 004/252] Document setting JSON_API_UNIFORM_EXCEPTIONS (#813) Fixes #326 Extracted from #437 Co-authored-by: Alan Crosswell --- docs/usage.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/usage.md b/docs/usage.md index cdb335d6..5b7010d3 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -237,6 +237,13 @@ class MyViewset(ModelViewSet): ``` +### Exception handling + +For the `exception_handler` class, if the optional `JSON_API_UNIFORM_EXCEPTIONS` is set to True, +all exceptions will respond with the JSON API [error format](http://jsonapi.org/format/#error-objects). + +When `JSON_API_UNIFORM_EXCEPTIONS` is False (the default), non-JSON API views will respond +with the normal DRF error format. ### Performance Testing From 8dbe0089f71b3fd0594305152af5ef4fbc0a974f Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 25 Aug 2020 19:09:05 +0200 Subject: [PATCH 005/252] Add handling of nested errors (#815) * Add handling of nested errors * Switch from syrupy to snapshottest to support Python 3.5 Once we drop support for Python 3.5 we might consider moving back to syrupy again --- CHANGELOG.md | 1 + example/tests/snapshots/__init__.py | 0 example/tests/snapshots/snap_test_errors.py | 121 ++++++++++ example/tests/test_errors.py | 240 ++++++++++++++++++++ example/tests/test_generic_viewset.py | 11 +- requirements/requirements-testing.txt | 1 + rest_framework_json_api/utils.py | 69 ++++-- setup.cfg | 1 + 8 files changed, 418 insertions(+), 26 deletions(-) create mode 100644 example/tests/snapshots/__init__.py create mode 100644 example/tests/snapshots/snap_test_errors.py create mode 100644 example/tests/test_errors.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2efa0f40..97546f10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ any parts of the framework not mentioned in the documentation should generally b * Avoid `AttributeError` for PUT and PATCH methods when using `APIView` * Clear many-to-many relationships instead of deleting related objects during PATCH on `RelationshipView` * Allow POST, PATCH, DELETE for actions in `ReadOnlyModelViewSet`. It was problematic since 2.8.0. +* Properly format nested errors ### Changed diff --git a/example/tests/snapshots/__init__.py b/example/tests/snapshots/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/example/tests/snapshots/snap_test_errors.py b/example/tests/snapshots/snap_test_errors.py new file mode 100644 index 00000000..d77c1e24 --- /dev/null +++ b/example/tests/snapshots/snap_test_errors.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +# snapshottest: v1 - https://goo.gl/zC4yUc +from __future__ import unicode_literals + +from snapshottest import Snapshot + + +snapshots = Snapshot() + +snapshots['test_first_level_attribute_error 1'] = { + 'errors': [ + { + 'code': 'required', + 'detail': 'This field is required.', + 'source': { + 'pointer': '/data/attributes/headline' + }, + 'status': '400' + } + ] +} + +snapshots['test_first_level_custom_attribute_error 1'] = { + 'errors': [ + { + 'detail': 'Too short', + 'source': { + 'pointer': '/data/attributes/body-text' + }, + 'title': 'Too Short title' + } + ] +} + +snapshots['test_second_level_array_error 1'] = { + 'errors': [ + { + 'code': 'required', + 'detail': 'This field is required.', + 'source': { + 'pointer': '/data/attributes/comments/0/body' + }, + 'status': '400' + } + ] +} + +snapshots['test_second_level_dict_error 1'] = { + 'errors': [ + { + 'code': 'required', + 'detail': 'This field is required.', + 'source': { + 'pointer': '/data/attributes/comment/body' + }, + 'status': '400' + } + ] +} + +snapshots['test_third_level_array_error 1'] = { + 'errors': [ + { + 'code': 'required', + 'detail': 'This field is required.', + 'source': { + 'pointer': '/data/attributes/comments/0/attachments/0/data' + }, + 'status': '400' + } + ] +} + +snapshots['test_third_level_custom_array_error 1'] = { + 'errors': [ + { + 'code': 'invalid', + 'detail': 'Too short data', + 'source': { + 'pointer': '/data/attributes/comments/0/attachments/0/data' + }, + 'status': '400' + } + ] +} + +snapshots['test_third_level_dict_error 1'] = { + 'errors': [ + { + 'code': 'required', + 'detail': 'This field is required.', + 'source': { + 'pointer': '/data/attributes/comments/0/attachment/data' + }, + 'status': '400' + } + ] +} + +snapshots['test_many_third_level_dict_errors 1'] = { + 'errors': [ + { + 'code': 'required', + 'detail': 'This field is required.', + 'source': { + 'pointer': '/data/attributes/comments/0/attachment/data' + }, + 'status': '400' + }, + { + 'code': 'required', + 'detail': 'This field is required.', + 'source': { + 'pointer': '/data/attributes/comments/0/body' + }, + 'status': '400' + } + ] +} + +snapshots['test_deprecation_warning 1'] = 'Rendering nested serializer as relationship is deprecated. Use `ResourceRelatedField` instead if DummyNestedSerializer in serializer example.tests.test_errors.test_deprecation_warning..DummySerializer should remain a relationship. Otherwise set JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE to True to render nested serializer as nested json attribute' diff --git a/example/tests/test_errors.py b/example/tests/test_errors.py new file mode 100644 index 00000000..75bc8842 --- /dev/null +++ b/example/tests/test_errors.py @@ -0,0 +1,240 @@ +import pytest +from django.conf.urls import url +from django.test import override_settings +from django.urls import reverse +from rest_framework import views + +from rest_framework_json_api import serializers + +from example.models import Blog + + +# serializers +class CommentAttachmentSerializer(serializers.Serializer): + data = serializers.CharField(allow_null=False, required=True) + + def validate_data(self, value): + if value and len(value) < 10: + raise serializers.ValidationError('Too short data') + + +class CommentSerializer(serializers.Serializer): + attachments = CommentAttachmentSerializer(many=True, required=False) + attachment = CommentAttachmentSerializer(required=False) + one_more_attachment = CommentAttachmentSerializer(required=False) + body = serializers.CharField(allow_null=False, required=True) + + +class EntrySerializer(serializers.Serializer): + blog = serializers.IntegerField() + comments = CommentSerializer(many=True, required=False) + comment = CommentSerializer(required=False) + headline = serializers.CharField(allow_null=True, required=True) + body_text = serializers.CharField() + + def validate(self, attrs): + body_text = attrs['body_text'] + if len(body_text) < 5: + raise serializers.ValidationError({'body_text': { + 'title': 'Too Short title', 'detail': 'Too short'} + }) + + +# view +class DummyTestView(views.APIView): + serializer_class = EntrySerializer + resource_name = 'entries' + + def post(self, request, *args, **kwargs): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + +urlpatterns = [ + url('entries-nested', DummyTestView.as_view(), + name='entries-nested-list') +] + + +@pytest.fixture(scope='function') +def some_blog(db): + return Blog.objects.create(name='Some Blog', tagline="It's a blog") + + +def perform_error_test(client, data): + with override_settings( + JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE=True, + ROOT_URLCONF=__name__ + ): + url = reverse('entries-nested-list') + response = client.post(url, data=data) + + return response.json() + + +def test_first_level_attribute_error(client, some_blog, snapshot): + data = { + 'data': { + 'type': 'entries', + 'attributes': { + 'blog': some_blog.pk, + 'bodyText': 'body_text', + } + } + } + snapshot.assert_match(perform_error_test(client, data)) + + +def test_first_level_custom_attribute_error(client, some_blog, snapshot): + data = { + 'data': { + 'type': 'entries', + 'attributes': { + 'blog': some_blog.pk, + 'body-text': 'body', + 'headline': 'headline' + } + } + } + with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'): + snapshot.assert_match(perform_error_test(client, data)) + + +def test_second_level_array_error(client, some_blog, snapshot): + data = { + 'data': { + 'type': 'entries', + 'attributes': { + 'blog': some_blog.pk, + 'bodyText': 'body_text', + 'headline': 'headline', + 'comments': [ + { + } + ] + } + } + } + + snapshot.assert_match(perform_error_test(client, data)) + + +def test_second_level_dict_error(client, some_blog, snapshot): + data = { + 'data': { + 'type': 'entries', + 'attributes': { + 'blog': some_blog.pk, + 'bodyText': 'body_text', + 'headline': 'headline', + 'comment': {} + } + } + } + + snapshot.assert_match(perform_error_test(client, data)) + + +def test_third_level_array_error(client, some_blog, snapshot): + data = { + 'data': { + 'type': 'entries', + 'attributes': { + 'blog': some_blog.pk, + 'bodyText': 'body_text', + 'headline': 'headline', + 'comments': [ + { + 'body': 'test comment', + 'attachments': [ + { + } + ] + } + ] + } + } + } + + snapshot.assert_match(perform_error_test(client, data)) + + +def test_third_level_custom_array_error(client, some_blog, snapshot): + data = { + 'data': { + 'type': 'entries', + 'attributes': { + 'blog': some_blog.pk, + 'bodyText': 'body_text', + 'headline': 'headline', + 'comments': [ + { + 'body': 'test comment', + 'attachments': [ + { + 'data': 'text' + } + ] + } + ] + } + } + } + + snapshot.assert_match(perform_error_test(client, data)) + + +def test_third_level_dict_error(client, some_blog, snapshot): + data = { + 'data': { + 'type': 'entries', + 'attributes': { + 'blog': some_blog.pk, + 'bodyText': 'body_text', + 'headline': 'headline', + 'comments': [ + { + 'body': 'test comment', + 'attachment': {} + } + ] + } + } + } + + snapshot.assert_match(perform_error_test(client, data)) + + +def test_many_third_level_dict_errors(client, some_blog, snapshot): + data = { + 'data': { + 'type': 'entries', + 'attributes': { + 'blog': some_blog.pk, + 'bodyText': 'body_text', + 'headline': 'headline', + 'comments': [ + { + 'attachment': {} + } + ] + } + } + } + + snapshot.assert_match(perform_error_test(client, data)) + + +@pytest.mark.filterwarnings('default::DeprecationWarning:rest_framework_json_api.serializers') +def test_deprecation_warning(recwarn, settings, snapshot): + settings.JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE = False + + class DummyNestedSerializer(serializers.Serializer): + field = serializers.CharField() + + class DummySerializer(serializers.Serializer): + nested = DummyNestedSerializer(many=True) + + assert len(recwarn) == 1 + warning = recwarn.pop(DeprecationWarning) + snapshot.assert_match(str(warning.message)) diff --git a/example/tests/test_generic_viewset.py b/example/tests/test_generic_viewset.py index a9abdd53..ba315740 100644 --- a/example/tests/test_generic_viewset.py +++ b/example/tests/test_generic_viewset.py @@ -95,11 +95,6 @@ def test_custom_validation_exceptions(self): """ expected = { 'errors': [ - { - 'id': 'armageddon101', - 'detail': 'Hey! You need a last name!', - 'meta': 'something', - }, { 'status': '400', 'source': { @@ -108,6 +103,12 @@ def test_custom_validation_exceptions(self): 'detail': 'Enter a valid email address.', 'code': 'invalid', }, + { + 'id': 'armageddon101', + 'detail': 'Hey! You need a last name!', + 'meta': 'something', + 'source': {'pointer': '/data/attributes/lastName'} + }, ] } response = self.client.post('/identities', { diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 357bf12a..6f02437a 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -5,3 +5,4 @@ pytest==6.0.1 pytest-cov==2.10.1 pytest-django==3.9.0 pytest-factoryboy==2.0.3 +snapshottest==0.5.1 diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index b3932651..2443575f 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -312,29 +312,25 @@ def format_drf_errors(response, context, exc): # handle generic errors. ValidationError('test') in a view for example if isinstance(response.data, list): for message in response.data: - errors.append(format_error_object(message, '/data', response)) + errors.extend(format_error_object(message, '/data', response)) # handle all errors thrown from serializers else: for field, error in response.data.items(): field = format_value(field) pointer = '/data/attributes/{}'.format(field) - # see if they passed a dictionary to ValidationError manually - if isinstance(error, dict): - errors.append(error) - elif isinstance(exc, Http404) and isinstance(error, str): + if isinstance(exc, Http404) and isinstance(error, str): # 404 errors don't have a pointer - errors.append(format_error_object(error, None, response)) + errors.extend(format_error_object(error, None, response)) elif isinstance(error, str): classes = inspect.getmembers(exceptions, inspect.isclass) # DRF sets the `field` to 'detail' for its own exceptions if isinstance(exc, tuple(x[1] for x in classes)): pointer = '/data' - errors.append(format_error_object(error, pointer, response)) + errors.extend(format_error_object(error, pointer, response)) elif isinstance(error, list): - for message in error: - errors.append(format_error_object(message, pointer, response)) + errors.extend(format_error_object(error, pointer, response)) else: - errors.append(format_error_object(error, pointer, response)) + errors.extend(format_error_object(error, pointer, response)) context['view'].resource_name = 'errors' response.data = errors @@ -343,18 +339,49 @@ def format_drf_errors(response, context, exc): def format_error_object(message, pointer, response): - error_obj = { - 'detail': message, - 'status': encoding.force_str(response.status_code), - } - if pointer is not None: - error_obj['source'] = { - 'pointer': pointer, + errors = [] + if isinstance(message, dict): + + # as there is no required field in error object we check that all fields are string + # except links and source which might be a dict + is_custom_error = all([ + isinstance(value, str) + for key, value in message.items() if key not in ['links', 'source'] + ]) + + if is_custom_error: + if 'source' not in message: + message['source'] = {} + message['source'] = { + 'pointer': pointer, + } + errors.append(message) + else: + for k, v in message.items(): + errors.extend(format_error_object(v, pointer + '/{}'.format(k), response)) + elif isinstance(message, list): + for num, error in enumerate(message): + if isinstance(error, (list, dict)): + new_pointer = pointer + '/{}'.format(num) + else: + new_pointer = pointer + if error: + errors.extend(format_error_object(error, new_pointer, response)) + else: + error_obj = { + 'detail': message, + 'status': encoding.force_str(response.status_code), } - code = getattr(message, "code", None) - if code is not None: - error_obj['code'] = code - return error_obj + if pointer is not None: + error_obj['source'] = { + 'pointer': pointer, + } + code = getattr(message, "code", None) + if code is not None: + error_obj['code'] = code + errors.append(error_obj) + + return errors def format_errors(data): diff --git a/setup.cfg b/setup.cfg index ef25b7bd..d8247c1d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -8,6 +8,7 @@ universal = 1 ignore = F405,W504 max-line-length = 100 exclude = + snapshots build/lib, docs/conf.py, migrations, From 959aec7cadcb6c92a53890d53950b6dd1bb305ca Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 25 Aug 2020 19:32:13 +0200 Subject: [PATCH 006/252] Change default behavior of tests to use nested serializers (#816) Co-authored-by: Alan Crosswell --- example/serializers.py | 6 +++--- example/settings/dev.py | 1 + example/tests/integration/test_meta.py | 12 ++---------- .../test_non_paginated_responses.py | 8 ++------ example/tests/integration/test_pagination.py | 1 + example/tests/test_filters.py | 4 +--- example/tests/test_serializers.py | 9 +++++++-- example/tests/test_views.py | 4 ++-- .../tests/unit/test_default_drf_serializers.py | 18 ++++++------------ pytest.ini | 1 - 10 files changed, 25 insertions(+), 39 deletions(-) diff --git a/example/serializers.py b/example/serializers.py index 2af5eb70..1728b742 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -40,9 +40,9 @@ class Meta: class BlogSerializer(serializers.ModelSerializer): copyright = serializers.SerializerMethodField() - tags = TaggedItemSerializer(many=True, read_only=True) + tags = relations.ResourceRelatedField(many=True, read_only=True) - include_serializers = { + included_serializers = { 'tags': 'example.serializers.TaggedItemSerializer', } @@ -147,7 +147,7 @@ def __init__(self, *args, **kwargs): model=Entry, read_only=True ) - tags = TaggedItemSerializer(many=True, read_only=True) + tags = relations.ResourceRelatedField(many=True, read_only=True) def get_suggested(self, obj): return Entry.objects.exclude(pk=obj.pk) diff --git a/example/settings/dev.py b/example/settings/dev.py index ade24139..07dedf03 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -67,6 +67,7 @@ JSON_API_FORMAT_FIELD_NAMES = 'camelize' JSON_API_FORMAT_TYPES = 'camelize' +JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE = True REST_FRAMEWORK = { 'PAGE_SIZE': 5, 'EXCEPTION_HANDLER': 'rest_framework_json_api.exceptions.exception_handler', diff --git a/example/tests/integration/test_meta.py b/example/tests/integration/test_meta.py index 05856865..25457b1c 100644 --- a/example/tests/integration/test_meta.py +++ b/example/tests/integration/test_meta.py @@ -18,11 +18,7 @@ def test_top_level_meta_for_list_view(blog, client): "links": { "self": 'http://testserver/blogs/1' }, - "relationships": { - "tags": { - "data": [] - } - }, + 'relationships': {'tags': {'data': [], 'meta': {'count': 0}}}, "meta": { "copyright": datetime.now().year }, @@ -53,11 +49,7 @@ def test_top_level_meta_for_detail_view(blog, client): "attributes": { "name": blog.name }, - "relationships": { - "tags": { - "data": [] - } - }, + 'relationships': {'tags': {'data': [], 'meta': {'count': 0}}}, "links": { "self": "http://testserver/blogs/1" }, diff --git a/example/tests/integration/test_non_paginated_responses.py b/example/tests/integration/test_non_paginated_responses.py index 9f1f532e..b73eae5e 100644 --- a/example/tests/integration/test_non_paginated_responses.py +++ b/example/tests/integration/test_non_paginated_responses.py @@ -72,9 +72,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "self": "http://testserver/entries/1/relationships/featured_hyperlinked" } }, - "tags": { - "data": [] - } + 'tags': {'data': [], 'meta': {'count': 0}}, } }, { @@ -135,9 +133,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "self": "http://testserver/entries/2/relationships/featured_hyperlinked" } }, - "tags": { - "data": [] - } + 'tags': {'data': [], 'meta': {'count': 0}}, } }, ] diff --git a/example/tests/integration/test_pagination.py b/example/tests/integration/test_pagination.py index 25d01c44..1b12a0d2 100644 --- a/example/tests/integration/test_pagination.py +++ b/example/tests/integration/test_pagination.py @@ -73,6 +73,7 @@ def test_pagination_with_single_entry(single_entry, client): } }, "tags": { + 'meta': {'count': 1}, "data": [ { "id": "1", diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index b422ed11..c3b1c42d 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -461,9 +461,7 @@ def test_search_keywords(self): 'self': 'http://testserver/entries/7/relationships/suggested_hyperlinked', # noqa: E501 'related': 'http://testserver/entries/7/suggested/'} }, - 'tags': { - 'data': [] - }, + 'tags': {'data': [], 'meta': {'count': 0}}, 'featuredHyperlinked': { 'links': { 'self': 'http://testserver/entries/7/relationships/featured_hyperlinked', # noqa: E501 diff --git a/example/tests/test_serializers.py b/example/tests/test_serializers.py index 5f277f2f..de97dcf8 100644 --- a/example/tests/test_serializers.py +++ b/example/tests/test_serializers.py @@ -45,7 +45,7 @@ def setUp(self): ) def test_forward_relationship_not_loaded_when_not_included(self): - to_representation_method = 'example.serializers.BlogSerializer.to_representation' + to_representation_method = 'example.serializers.TaggedItemSerializer.to_representation' with mock.patch(to_representation_method) as mocked_serializer: class EntrySerializer(ModelSerializer): blog = BlogSerializer() @@ -79,7 +79,12 @@ class Meta: expected = dict( [ ('id', 1), - ('blog', dict([('type', 'blogs'), ('id', 1)])), + ('blog', dict([ + ('name', 'Some Blog'), + ('tags', []), + ('copyright', 2020), + ('url', 'http://testserver/blogs/1') + ])), ('headline', 'headline'), ('body_text', 'body_text'), ('pub_date', DateField().to_representation(self.entry.pub_date)), diff --git a/example/tests/test_views.py b/example/tests/test_views.py index 1f47245b..9cd493f7 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -538,7 +538,7 @@ def test_get_object_gives_correct_blog(self): 'id': '{}'.format(self.blog.id), 'links': {'self': 'http://testserver/blogs/{}'.format(self.blog.id)}, 'meta': {'copyright': datetime.now().year}, - 'relationships': {'tags': {'data': []}}, + 'relationships': {'tags': {'data': [], 'meta': {'count': 0}}}, 'type': 'blogs' }, 'meta': {'apiDocs': '/docs/api/blogs'} @@ -632,7 +632,7 @@ def test_get_object_gives_correct_entry(self): '/suggested_hyperlinked'.format(self.second_entry.id) } }, - 'tags': {'data': []}}, + 'tags': {'data': [], 'meta': {'count': 0}}}, 'type': 'posts' } } diff --git a/example/tests/unit/test_default_drf_serializers.py b/example/tests/unit/test_default_drf_serializers.py index 680f6a8a..3233ce84 100644 --- a/example/tests/unit/test_default_drf_serializers.py +++ b/example/tests/unit/test_default_drf_serializers.py @@ -95,11 +95,10 @@ def test_blog_create(client): expected = { 'data': { - 'attributes': {'name': blog.name}, + 'attributes': {'name': blog.name, 'tags': []}, 'id': '{}'.format(blog.id), 'links': {'self': 'http://testserver/blogs/{}'.format(blog.id)}, 'meta': {'copyright': datetime.now().year}, - 'relationships': {'tags': {'data': []}}, 'type': 'blogs' }, 'meta': {'apiDocs': '/docs/api/blogs'} @@ -116,11 +115,10 @@ def test_get_object_gives_correct_blog(client, blog, entry): resp = client.get(url) expected = { 'data': { - 'attributes': {'name': blog.name}, + 'attributes': {'name': blog.name, 'tags': []}, 'id': '{}'.format(blog.id), 'links': {'self': 'http://testserver/blogs/{}'.format(blog.id)}, 'meta': {'copyright': datetime.now().year}, - 'relationships': {'tags': {'data': []}}, 'type': 'blogs' }, 'meta': {'apiDocs': '/docs/api/blogs'} @@ -154,11 +152,10 @@ def test_get_object_patches_correct_blog(client, blog, entry): expected = { 'data': { - 'attributes': {'name': new_name}, + 'attributes': {'name': new_name, 'tags': []}, 'id': '{}'.format(blog.id), 'links': {'self': 'http://testserver/blogs/{}'.format(blog.id)}, 'meta': {'copyright': datetime.now().year}, - 'relationships': {'tags': {'data': []}}, 'type': 'blogs' }, 'meta': {'apiDocs': '/docs/api/blogs'} @@ -189,17 +186,14 @@ def test_get_entry_list_with_blogs(client, entry): 'first': 'http://testserver/drf-entries/1/suggested/?page%5Bnumber%5D=1', 'last': 'http://testserver/drf-entries/1/suggested/?page%5Bnumber%5D=1', 'next': None, - 'prev': None + 'prev': None, }, 'data': [ { 'type': 'entries', 'id': '1', - 'attributes': {}, - 'relationships': { - 'tags': { - 'data': [] - } + 'attributes': { + 'tags': [], }, 'links': { 'self': 'http://testserver/drf-blogs/1' diff --git a/pytest.ini b/pytest.ini index ebf0e544..2c69372d 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,4 +3,3 @@ DJANGO_SETTINGS_MODULE=example.settings.test filterwarnings = error::DeprecationWarning error::PendingDeprecationWarning - ignore::DeprecationWarning:rest_framework_json_api.serializers \ No newline at end of file From 9c49b65894a38a185b34737f569785d720eccc67 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 26 Aug 2020 01:49:02 +0200 Subject: [PATCH 007/252] Release version 3.2.0 (#817) --- CHANGELOG.md | 5 +++-- rest_framework_json_api/__init__.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97546f10..51c13dc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,17 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [3.2.0] - 2020-08-26 ### Added * Added support for serializing nested serializers as attribute json value introducing setting `JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE` + * Note: As keys of nested serializers are not json api spec field names they are not inflected by format field names option. ### Fixed * Avoid `AttributeError` for PUT and PATCH methods when using `APIView` * Clear many-to-many relationships instead of deleting related objects during PATCH on `RelationshipView` -* Allow POST, PATCH, DELETE for actions in `ReadOnlyModelViewSet`. It was problematic since 2.8.0. +* Allow POST, PATCH, DELETE for actions in `ReadOnlyModelViewSet`. Regression since version `2.8.0`. * Properly format nested errors ### Changed diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index a15ece29..28a440ce 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- __title__ = 'djangorestframework-jsonapi' -__version__ = '3.1.0' +__version__ = '3.2.0' __author__ = '' __license__ = 'BSD' __copyright__ = '' From 7458d568a6b8460eb740ebf91d831beabb41b0eb Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 26 Aug 2020 15:50:36 +0200 Subject: [PATCH 008/252] Adjust batches (#819) * Remove gitter chat badge as not being used * Add PyPI badge --- README.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 446d035e..b3162184 100644 --- a/README.rst +++ b/README.rst @@ -9,9 +9,9 @@ JSON API and Django Rest Framework :alt: Read the docs :target: https://django-rest-framework-json-api.readthedocs.org/ -.. image:: https://badges.gitter.im/Join%20Chat.svg - :alt: Join the chat at https://gitter.im/django-json-api/django-rest-framework-json-api - :target: https://gitter.im/django-json-api/django-rest-framework-json-api +.. image:: https://img.shields.io/pypi/v/djangorestframework-jsonapi.svg + :alt: PyPi Version + :target: https://pypi.org/project/djangorestframework-jsonapi/ -------- Overview From 45d42249dab743d913fac03aa688b1333ec5bafa Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 4 Sep 2020 22:56:26 +0200 Subject: [PATCH 009/252] Fixes #818 (#824) Correctly document JSON API ids as string --- README.rst | 2 +- docs/getting-started.md | 2 +- docs/usage.md | 16 ++++++++-------- rest_framework_json_api/renderers.py | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.rst b/README.rst index b3162184..bc52bbf5 100644 --- a/README.rst +++ b/README.rst @@ -48,7 +48,7 @@ like the following:: }, "data": [{ "type": "identities", - "id": 3, + "id": "3", "attributes": { "username": "john", "full-name": "John Coltrane" diff --git a/docs/getting-started.md b/docs/getting-started.md index bef744eb..58768e39 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -32,7 +32,7 @@ like the following: }, "data": [{ "type": "identities", - "id": 3, + "id": "3", "attributes": { "username": "john", "full-name": "John Coltrane" diff --git a/docs/usage.md b/docs/usage.md index 5b7010d3..9fd46e6b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -338,7 +338,7 @@ Example - Without format conversion: { "data": [{ "type": "identities", - "id": 3, + "id": "3", "attributes": { "username": "john", "first_name": "John", @@ -359,7 +359,7 @@ Example - With format conversion set to `dasherize`: { "data": [{ "type": "identities", - "id": 3, + "id": "3", "attributes": { "username": "john", "first-name": "John", @@ -389,7 +389,7 @@ Example without format conversion: { "data": [{ "type": "blog_identity", - "id": 3, + "id": "3", "attributes": { ... }, @@ -412,7 +412,7 @@ When set to dasherize: { "data": [{ "type": "blog-identity", - "id": 3, + "id": "3", "attributes": { ... }, @@ -438,7 +438,7 @@ Example without pluralization: { "data": [{ "type": "identity", - "id": 3, + "id": "3", "attributes": { ... }, @@ -446,7 +446,7 @@ Example without pluralization: "home_towns": { "data": [{ "type": "home_town", - "id": 3 + "id": "3" }] } } @@ -461,7 +461,7 @@ When set to pluralize: { "data": [{ "type": "identities", - "id": 3, + "id": "3", "attributes": { ... }, @@ -469,7 +469,7 @@ When set to pluralize: "home_towns": { "data": [{ "type": "home_towns", - "id": 3 + "id": "3" }] } } diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index ccd71510..cfc74f1f 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -33,7 +33,7 @@ class JSONRenderer(renderers.JSONRenderer): "data": [ { "type": "companies", - "id": 1, + "id": "1", "attributes": { "name": "Mozilla", "slug": "mozilla", From bb672e4d1bd94608c7e4b310aa8339a113809abf Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 8 Sep 2020 17:31:52 +0200 Subject: [PATCH 010/252] Fixes #821 (#826) Clarify potential issue in changelog through introduction of `rest_framework_json_api.serializers.Serializer` class. --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51c13dc1..dcd05605 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,10 @@ any parts of the framework not mentioned in the documentation should generally b ### Added * Added support for serializing nested serializers as attribute json value introducing setting `JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE` - * Note: As keys of nested serializers are not json api spec field names they are not inflected by format field names option. + * Note: As keys of nested serializers are not json:api spec field names they are not inflected by format field names option. +* Added `rest_framework_json_api.serializer.Serializer` class to support initial JSON API views without models. + * Note that serializers derived from this class need to define `resource_name` in their `Meta` class. + * This fix might be a **BREAKING CHANGE** if you use `rest_framework_json_api.serializers.Serializer` for non json:api spec views (usually `APIView`). You need to change those serializers classes to use `rest_framework.serializers.Serializer` instead. ### Fixed From 82daf66a3df8f74786f55ab21b5f4bc4f792d547 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 8 Sep 2020 18:09:12 +0200 Subject: [PATCH 011/252] Add api documentation to serializer classes (#827) --- rest_framework_json_api/serializers.py | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 3266acd2..80604b6f 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -57,6 +57,12 @@ def to_internal_value(self, data): class SparseFieldsetsMixin(object): + """ + A serializer mixin that adds support for sparse fieldsets through `fields` query parameter. + + Specification: https://jsonapi.org/format/#fetching-sparse-fieldsets + """ + def __init__(self, *args, **kwargs): super(SparseFieldsetsMixin, self).__init__(*args, **kwargs) context = kwargs.get('context') @@ -85,6 +91,13 @@ def __init__(self, *args, **kwargs): class IncludedResourcesValidationMixin(object): + """ + A serializer mixin that adds validation of `include` query parameter to + support compound documents. + + Specification: https://jsonapi.org/format/#document-compound-documents) + """ + def __init__(self, *args, **kwargs): context = kwargs.get('context') request = context.get('request') if context else None @@ -147,6 +160,20 @@ class Serializer( IncludedResourcesValidationMixin, SparseFieldsetsMixin, Serializer, metaclass=SerializerMetaclass ): + """ + A `Serializer` is a model-less serializer class with additional + support for json:api spec features. + + As in json:api specification a type is always required you need to + make sure that you define `resource_name` in your `Meta` class + when deriving from this class. + + Included Mixins: + + * A mixin class to enable sparse fieldsets is included + * A mixin class to enable validation of included resources is included + """ + pass From 7b2a4fff587447a925b5b1dde0ea9b26e4c4f5e3 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 21 Sep 2020 18:05:16 +0200 Subject: [PATCH 012/252] Drop support for Django 1.11 and 2.1 (#831) --- .travis.yml | 35 ----------------------------------- CHANGELOG.md | 9 +++++++++ README.rst | 2 +- docs/getting-started.md | 2 +- setup.py | 2 +- tox.ini | 7 ++----- 6 files changed, 14 insertions(+), 43 deletions(-) diff --git a/.travis.yml b/.travis.yml index 301ed0cc..ad495df9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,11 +5,6 @@ cache: pip # Favor explicit over implicit and use an explicit build matrix. matrix: allow_failures: - - env: TOXENV=py35-django111-drfmaster - - env: TOXENV=py36-django111-drfmaster - - env: TOXENV=py35-django21-drfmaster - - env: TOXENV=py36-django21-drfmaster - - env: TOXENV=py37-django21-drfmaster - env: TOXENV=py35-django22-drfmaster - env: TOXENV=py36-django22-drfmaster - env: TOXENV=py37-django22-drfmaster @@ -24,18 +19,6 @@ matrix: - python: 3.6 env: TOXENV=docs - - python: 3.5 - env: TOXENV=py35-django111-drf310 - - python: 3.5 - env: TOXENV=py35-django111-drf311 - - python: 3.5 - env: TOXENV=py35-django111-drfmaster - - python: 3.5 - env: TOXENV=py35-django21-drf310 - - python: 3.5 - env: TOXENV=py35-django21-drf311 - - python: 3.5 - env: TOXENV=py35-django21-drfmaster - python: 3.5 env: TOXENV=py35-django22-drf310 - python: 3.5 @@ -43,18 +26,6 @@ matrix: - python: 3.5 env: TOXENV=py35-django22-drfmaster - - python: 3.6 - env: TOXENV=py36-django111-drf310 - - python: 3.6 - env: TOXENV=py36-django111-drf311 - - python: 3.6 - env: TOXENV=py36-django111-drfmaster - - python: 3.6 - env: TOXENV=py36-django21-drf310 - - python: 3.6 - env: TOXENV=py36-django21-drf311 - - python: 3.6 - env: TOXENV=py36-django21-drfmaster - python: 3.6 env: TOXENV=py36-django22-drf310 - python: 3.6 @@ -66,12 +37,6 @@ matrix: - python: 3.6 env: TOXENV=py36-django30-drfmaster - - python: 3.7 - env: TOXENV=py37-django21-drf310 - - python: 3.7 - env: TOXENV=py37-django21-drf311 - - python: 3.7 - env: TOXENV=py37-django21-drfmaster - python: 3.7 env: TOXENV=py37-django22-drf310 - python: 3.7 diff --git a/CHANGELOG.md b/CHANGELOG.md index dcd05605..f4b110ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## [Unreleased] + +### Removed + +* Removed support for Django 1.11. +* Removed support for Django 2.1. + ## [3.2.0] - 2020-08-26 +This is the last release supporting Django 1.11 and Django 2.1. + ### Added * Added support for serializing nested serializers as attribute json value introducing setting `JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE` diff --git a/README.rst b/README.rst index bc52bbf5..07f18a8d 100644 --- a/README.rst +++ b/README.rst @@ -88,7 +88,7 @@ Requirements ------------ 1. Python (3.5, 3.6, 3.7, 3.8) -2. Django (1.11, 2.1, 2.2, 3.0) +2. Django (2.2, 3.0) 3. Django REST Framework (3.10, 3.11) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST Framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index 58768e39..39ef6a88 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.5, 3.6, 3.7, 3.8) -2. Django (1.11, 2.1, 2.2, 3.0) +2. Django (2.2, 3.0) 3. Django REST Framework (3.10, 3.11) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST Framework series. diff --git a/setup.py b/setup.py index d37c66f3..42d7d8c4 100755 --- a/setup.py +++ b/setup.py @@ -91,7 +91,7 @@ def get_package_data(package): install_requires=[ 'inflection>=0.3.0', 'djangorestframework>=3.10,<3.12', - 'django>=1.11,<3.1', + 'django>=2.2,<3.1', ], extras_require={ 'django-polymorphic': ['django-polymorphic>=2.0'], diff --git a/tox.ini b/tox.ini index 04b970ac..58956ee5 100644 --- a/tox.ini +++ b/tox.ini @@ -1,15 +1,12 @@ [tox] envlist = - py{35,36}-django{111}-drf{310,311,master}, - py{35,36,37}-django{21,22}-drf{310,311,master}, + py{35,36,37}-django22-drf{310,311,master}, py38-django22-drf{311,master}, - py{36,37,38}-django{30}-drf{311,master}, + py{36,37,38}-django30-drf{311,master}, lint,docs [testenv] deps = - django111: Django>=1.11,<1.12 - django21: Django>=2.1,<2.2 django22: Django>=2.2,<2.3 django30: Django>=3.0,<3.1 drf310: djangorestframework>=3.10.2,<3.11 From d012c023bd8fadab84d2299c8208b7e86d507fbc Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Wed, 23 Sep 2020 20:18:54 +0200 Subject: [PATCH 013/252] Scheduled biweekly dependency update for week 38 (#828) * Update isort from 5.4.2 to 5.5.3 * Update django-polymorphic from 2.1.2 to 3.0.0 * Update django-debug-toolbar from 2.2 to 3.0 * Update faker from 4.1.1 to 4.1.3 * Update pytest from 6.0.1 to 6.0.2 * Update pytest-django from 3.9.0 to 3.10.0 Co-authored-by: Oliver Sauder --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-optionals.txt | 2 +- requirements/requirements-testing.txt | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index ae4d7e69..d746159a 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,3 +1,3 @@ flake8==3.8.3 flake8-isort==4.0.0 -isort==5.4.2 +isort==5.5.3 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 1acdef65..24d3625d 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,2 +1,2 @@ django-filter==2.3.0 -django-polymorphic==2.1.2 +django-polymorphic==3.0.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 6f02437a..e3a59cee 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,8 +1,8 @@ -django-debug-toolbar==2.2 +django-debug-toolbar==3.0 factory-boy==3.0.1 -Faker==4.1.1 -pytest==6.0.1 +Faker==4.1.3 +pytest==6.0.2 pytest-cov==2.10.1 -pytest-django==3.9.0 +pytest-django==3.10.0 pytest-factoryboy==2.0.3 snapshottest==0.5.1 From e130666726b888fd359c101a447a108f7085b240 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Mon, 5 Oct 2020 13:58:23 -0400 Subject: [PATCH 014/252] support only drf 3.12 (#833) --- .travis.yml | 20 +++++++------------- CHANGELOG.md | 7 ++++++- README.rst | 2 +- docs/getting-started.md | 2 +- setup.py | 2 +- tox.ini | 8 +++----- 6 files changed, 19 insertions(+), 22 deletions(-) diff --git a/.travis.yml b/.travis.yml index ad495df9..65266132 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,40 +20,34 @@ matrix: env: TOXENV=docs - python: 3.5 - env: TOXENV=py35-django22-drf310 - - python: 3.5 - env: TOXENV=py35-django22-drf311 + env: TOXENV=py35-django22-drf312 - python: 3.5 env: TOXENV=py35-django22-drfmaster - python: 3.6 - env: TOXENV=py36-django22-drf310 - - python: 3.6 - env: TOXENV=py36-django22-drf311 + env: TOXENV=py36-django22-drf312 - python: 3.6 env: TOXENV=py36-django22-drfmaster - python: 3.6 - env: TOXENV=py36-django30-drf311 + env: TOXENV=py36-django30-drf312 - python: 3.6 env: TOXENV=py36-django30-drfmaster - python: 3.7 - env: TOXENV=py37-django22-drf310 - - python: 3.7 - env: TOXENV=py37-django22-drf311 + env: TOXENV=py37-django22-drf312 - python: 3.7 env: TOXENV=py37-django22-drfmaster - python: 3.7 - env: TOXENV=py37-django30-drf311 + env: TOXENV=py37-django30-drf312 - python: 3.7 env: TOXENV=py37-django30-drfmaster - python: 3.8 - env: TOXENV=py38-django22-drf311 + env: TOXENV=py38-django22-drf312 - python: 3.8 env: TOXENV=py38-django22-drfmaster - python: 3.8 - env: TOXENV=py38-django30-drf311 + env: TOXENV=py38-django30-drf312 - python: 3.8 env: TOXENV=py38-django30-drfmaster diff --git a/CHANGELOG.md b/CHANGELOG.md index f4b110ee..4b872109 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,15 @@ any parts of the framework not mentioned in the documentation should generally b * Removed support for Django 1.11. * Removed support for Django 2.1. +* Removed support for Django REST framework 3.10, 3.11 + +### Added +* Added support for Django REST framework 3.12 + ## [3.2.0] - 2020-08-26 -This is the last release supporting Django 1.11 and Django 2.1. +This is the last release supporting Django 1.11, Django 2.1, DRF 3.10 and DRF 3.11. ### Added diff --git a/README.rst b/README.rst index 07f18a8d..89656f22 100644 --- a/README.rst +++ b/README.rst @@ -89,7 +89,7 @@ Requirements 1. Python (3.5, 3.6, 3.7, 3.8) 2. Django (2.2, 3.0) -3. Django REST Framework (3.10, 3.11) +3. Django REST Framework (3.12) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST Framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index 39ef6a88..d6c88a3d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -53,7 +53,7 @@ like the following: 1. Python (3.5, 3.6, 3.7, 3.8) 2. Django (2.2, 3.0) -3. Django REST Framework (3.10, 3.11) +3. Django REST Framework (3.12) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST Framework series. diff --git a/setup.py b/setup.py index 42d7d8c4..19c1fa74 100755 --- a/setup.py +++ b/setup.py @@ -90,7 +90,7 @@ def get_package_data(package): ], install_requires=[ 'inflection>=0.3.0', - 'djangorestframework>=3.10,<3.12', + 'djangorestframework>=3.12,<3.13', 'django>=2.2,<3.1', ], extras_require={ diff --git a/tox.ini b/tox.ini index 58956ee5..e4d1bb15 100644 --- a/tox.ini +++ b/tox.ini @@ -1,16 +1,14 @@ [tox] envlist = - py{35,36,37}-django22-drf{310,311,master}, - py38-django22-drf{311,master}, - py{36,37,38}-django30-drf{311,master}, + py{35,36,37,38}-django22-drf{312,master}, + py{36,37,38}-django30-drf{312,master}, lint,docs [testenv] deps = django22: Django>=2.2,<2.3 django30: Django>=3.0,<3.1 - drf310: djangorestframework>=3.10.2,<3.11 - drf311: djangorestframework>=3.11,<3.12 + drf312: djangorestframework>=3.12,<3.13 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip -rrequirements/requirements-testing.txt -rrequirements/requirements-optionals.txt From cf21ed482e2e9e9bbb6e2856e72ca8323226f153 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 5 Oct 2020 20:55:15 +0200 Subject: [PATCH 015/252] Scheduled biweekly dependency update for week 40 (#834) * Update flake8 from 3.8.3 to 3.8.4 * Update isort from 5.5.3 to 5.5.4 * Update django-filter from 2.3.0 to 2.4.0 * Update django-debug-toolbar from 3.0 to 3.1.1 * Update factory-boy from 3.0.1 to 3.1.0 * Update faker from 4.1.3 to 4.4.0 * Update pytest from 6.0.2 to 6.1.1 * Update snapshottest from 0.5.1 to 0.6.0 Co-authored-by: Oliver Sauder --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-optionals.txt | 2 +- requirements/requirements-testing.txt | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index d746159a..b83029e0 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,3 +1,3 @@ -flake8==3.8.3 +flake8==3.8.4 flake8-isort==4.0.0 -isort==5.5.3 +isort==5.5.4 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 24d3625d..ac092e33 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,2 +1,2 @@ -django-filter==2.3.0 +django-filter==2.4.0 django-polymorphic==3.0.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index e3a59cee..a8ee0553 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,8 +1,8 @@ -django-debug-toolbar==3.0 -factory-boy==3.0.1 -Faker==4.1.3 -pytest==6.0.2 +django-debug-toolbar==3.1.1 +factory-boy==3.1.0 +Faker==4.4.0 +pytest==6.1.1 pytest-cov==2.10.1 pytest-django==3.10.0 pytest-factoryboy==2.0.3 -snapshottest==0.5.1 +snapshottest==0.6.0 From 67cf78917033890fa60a2802b8404b7743f086ea Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 5 Oct 2020 21:46:00 +0200 Subject: [PATCH 016/252] Add support for Django 3.1 (#835) --- .travis.yml | 15 +++++++ CHANGELOG.md | 8 ++-- README.rst | 2 +- docs/getting-started.md | 2 +- example/tests/test_errors.py | 7 ++- example/tests/test_parsers.py | 5 +-- example/urls_test.py | 84 +++++++++++++++++------------------ setup.py | 3 +- tox.ini | 3 +- 9 files changed, 73 insertions(+), 56 deletions(-) diff --git a/.travis.yml b/.travis.yml index 65266132..a70727aa 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,6 +12,9 @@ matrix: - env: TOXENV=py36-django30-drfmaster - env: TOXENV=py37-django30-drfmaster - env: TOXENV=py38-django30-drfmaster + - env: TOXENV=py36-django31-drfmaster + - env: TOXENV=py37-django31-drfmaster + - env: TOXENV=py38-django31-drfmaster include: - python: 3.6 @@ -32,6 +35,10 @@ matrix: env: TOXENV=py36-django30-drf312 - python: 3.6 env: TOXENV=py36-django30-drfmaster + - python: 3.6 + env: TOXENV=py36-django31-drf312 + - python: 3.6 + env: TOXENV=py36-django31-drfmaster - python: 3.7 env: TOXENV=py37-django22-drf312 @@ -41,6 +48,10 @@ matrix: env: TOXENV=py37-django30-drf312 - python: 3.7 env: TOXENV=py37-django30-drfmaster + - python: 3.7 + env: TOXENV=py37-django31-drf312 + - python: 3.7 + env: TOXENV=py37-django31-drfmaster - python: 3.8 env: TOXENV=py38-django22-drf312 @@ -50,6 +61,10 @@ matrix: env: TOXENV=py38-django30-drf312 - python: 3.8 env: TOXENV=py38-django30-drfmaster + - python: 3.8 + env: TOXENV=py38-django31-drf312 + - python: 3.8 + env: TOXENV=py38-django31-drfmaster install: - pip install tox diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b872109..768fcba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,15 +10,17 @@ any parts of the framework not mentioned in the documentation should generally b ## [Unreleased] +### Added + +* Added support for Django REST framework 3.12 +* Added support for Django 3.1 + ### Removed * Removed support for Django 1.11. * Removed support for Django 2.1. * Removed support for Django REST framework 3.10, 3.11 -### Added -* Added support for Django REST framework 3.12 - ## [3.2.0] - 2020-08-26 diff --git a/README.rst b/README.rst index 89656f22..f1d606bb 100644 --- a/README.rst +++ b/README.rst @@ -88,7 +88,7 @@ Requirements ------------ 1. Python (3.5, 3.6, 3.7, 3.8) -2. Django (2.2, 3.0) +2. Django (2.2, 3.0, 3.1) 3. Django REST Framework (3.12) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST Framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index d6c88a3d..f16afd57 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.5, 3.6, 3.7, 3.8) -2. Django (2.2, 3.0) +2. Django (2.2, 3.0, 3.1) 3. Django REST Framework (3.12) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST Framework series. diff --git a/example/tests/test_errors.py b/example/tests/test_errors.py index 75bc8842..305e14b9 100644 --- a/example/tests/test_errors.py +++ b/example/tests/test_errors.py @@ -1,7 +1,6 @@ import pytest -from django.conf.urls import url from django.test import override_settings -from django.urls import reverse +from django.urls import path, reverse from rest_framework import views from rest_framework_json_api import serializers @@ -51,8 +50,8 @@ def post(self, request, *args, **kwargs): urlpatterns = [ - url('entries-nested', DummyTestView.as_view(), - name='entries-nested-list') + path('entries-nested', DummyTestView.as_view(), + name='entries-nested-list') ] diff --git a/example/tests/test_parsers.py b/example/tests/test_parsers.py index be2b2ce3..38d7c6d7 100644 --- a/example/tests/test_parsers.py +++ b/example/tests/test_parsers.py @@ -1,9 +1,8 @@ import json from io import BytesIO -from django.conf.urls import url from django.test import TestCase, override_settings -from django.urls import reverse +from django.urls import path, reverse from rest_framework import status, views from rest_framework.exceptions import ParseError from rest_framework.response import Response @@ -104,7 +103,7 @@ def patch(self, request, *args, **kwargs): urlpatterns = [ - url(r'repeater$', DummyAPIView.as_view(), name='repeater'), + path('repeater', DummyAPIView.as_view(), name='repeater'), ] diff --git a/example/urls_test.py b/example/urls_test.py index 020ab2f3..44ae6f58 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -1,4 +1,4 @@ -from django.conf.urls import include, url +from django.conf.urls import re_path from rest_framework import routers from .api.resources.identity import GenericIdentity, Identity @@ -41,50 +41,50 @@ router.register(r'identities', Identity) urlpatterns = [ - url(r'^', include(router.urls)), - # old tests - url(r'identities/default/(?P\d+)$', - GenericIdentity.as_view(), name='user-default'), + re_path(r'identities/default/(?P\d+)$', + GenericIdentity.as_view(), name='user-default'), - url(r'^entries/(?P[^/.]+)/blog$', - BlogViewSet.as_view({'get': 'retrieve'}), - name='entry-blog' - ), - url(r'^entries/(?P[^/.]+)/comments$', - CommentViewSet.as_view({'get': 'list'}), - name='entry-comments' - ), - url(r'^entries/(?P[^/.]+)/suggested/$', - EntryViewSet.as_view({'get': 'list'}), - name='entry-suggested' - ), - url(r'^drf-entries/(?P[^/.]+)/suggested/$', - DRFEntryViewSet.as_view({'get': 'list'}), - name='drf-entry-suggested' - ), - url(r'entries/(?P[^/.]+)/authors$', - AuthorViewSet.as_view({'get': 'list'}), - name='entry-authors'), - url(r'entries/(?P[^/.]+)/featured$', - EntryViewSet.as_view({'get': 'retrieve'}), - name='entry-featured'), + re_path(r'^entries/(?P[^/.]+)/blog$', + BlogViewSet.as_view({'get': 'retrieve'}), + name='entry-blog' + ), + re_path(r'^entries/(?P[^/.]+)/comments$', + CommentViewSet.as_view({'get': 'list'}), + name='entry-comments' + ), + re_path(r'^entries/(?P[^/.]+)/suggested/$', + EntryViewSet.as_view({'get': 'list'}), + name='entry-suggested' + ), + re_path(r'^drf-entries/(?P[^/.]+)/suggested/$', + DRFEntryViewSet.as_view({'get': 'list'}), + name='drf-entry-suggested' + ), + re_path(r'entries/(?P[^/.]+)/authors$', + AuthorViewSet.as_view({'get': 'list'}), + name='entry-authors'), + re_path(r'entries/(?P[^/.]+)/featured$', + EntryViewSet.as_view({'get': 'retrieve'}), + name='entry-featured'), - url(r'^authors/(?P[^/.]+)/(?P\w+)/$', - AuthorViewSet.as_view({'get': 'retrieve_related'}), - name='author-related'), + re_path(r'^authors/(?P[^/.]+)/(?P\w+)/$', + AuthorViewSet.as_view({'get': 'retrieve_related'}), + name='author-related'), - url(r'^entries/(?P[^/.]+)/relationships/(?P\w+)$', - EntryRelationshipView.as_view(), - name='entry-relationships'), - url(r'^blogs/(?P[^/.]+)/relationships/(?P\w+)$', - BlogRelationshipView.as_view(), - name='blog-relationships'), - url(r'^comments/(?P[^/.]+)/relationships/(?P\w+)$', - CommentRelationshipView.as_view(), - name='comment-relationships'), - url(r'^authors/(?P[^/.]+)/relationships/(?P\w+)$', - AuthorRelationshipView.as_view(), - name='author-relationships'), + re_path(r'^entries/(?P[^/.]+)/relationships/(?P\w+)$', + EntryRelationshipView.as_view(), + name='entry-relationships'), + re_path(r'^blogs/(?P[^/.]+)/relationships/(?P\w+)$', + BlogRelationshipView.as_view(), + name='blog-relationships'), + re_path(r'^comments/(?P[^/.]+)/relationships/(?P\w+)$', + CommentRelationshipView.as_view(), + name='comment-relationships'), + re_path(r'^authors/(?P[^/.]+)/relationships/(?P\w+)$', + AuthorRelationshipView.as_view(), + name='author-relationships'), ] + +urlpatterns += router.urls diff --git a/setup.py b/setup.py index 19c1fa74..5053a63c 100755 --- a/setup.py +++ b/setup.py @@ -84,6 +84,7 @@ def get_package_data(package): 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Software Development :: Libraries :: Application Frameworks', 'Topic :: Software Development :: Libraries :: Python Modules', @@ -91,7 +92,7 @@ def get_package_data(package): install_requires=[ 'inflection>=0.3.0', 'djangorestframework>=3.12,<3.13', - 'django>=2.2,<3.1', + 'django>=2.2,<3.2', ], extras_require={ 'django-polymorphic': ['django-polymorphic>=2.0'], diff --git a/tox.ini b/tox.ini index e4d1bb15..48b790ef 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,14 @@ [tox] envlist = py{35,36,37,38}-django22-drf{312,master}, - py{36,37,38}-django30-drf{312,master}, + py{36,37,38}-django{30.31}-drf{312,master}, lint,docs [testenv] deps = django22: Django>=2.2,<2.3 django30: Django>=3.0,<3.1 + django31: Django>=3.1,<3.2 drf312: djangorestframework>=3.12,<3.13 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip -rrequirements/requirements-testing.txt From ab3fbfc8020b24c5aab54d53aa08cd927992bd48 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 14 Oct 2020 18:01:49 +0200 Subject: [PATCH 017/252] Remove obsolete code for upcoming major release (#837) * Deleted outdated `source` attribute of `SerializerMethodField` * Deleted outdated strategy to render serializer as relationship --- CHANGELOG.md | 5 ++ example/settings/dev.py | 1 - example/tests/snapshots/snap_test_errors.py | 44 ++++++----- example/tests/test_errors.py | 16 ---- example/tests/unit/test_renderers.py | 9 +-- .../unit/test_serializer_method_field.py | 26 ------- rest_framework_json_api/relations.py | 6 -- rest_framework_json_api/renderers.py | 69 +----------------- rest_framework_json_api/serializers.py | 73 +------------------ rest_framework_json_api/settings.py | 1 - 10 files changed, 33 insertions(+), 217 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 768fcba4..3b8a3e77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ any parts of the framework not mentioned in the documentation should generally b ## [Unreleased] +This release is not backwards compatible. For easy migration best upgrade first to version +3.2.0 and resolve all deprecation warnings before updating to 4.0.0 + ### Added * Added support for Django REST framework 3.12 @@ -20,6 +23,8 @@ any parts of the framework not mentioned in the documentation should generally b * Removed support for Django 1.11. * Removed support for Django 2.1. * Removed support for Django REST framework 3.10, 3.11 +* Removed obsolete `source` argument of `SerializerMethodResourceRelatedField` +* Removed obsolete setting `JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE` to render nested serializers as relationships. Default is as attribute now. ## [3.2.0] - 2020-08-26 diff --git a/example/settings/dev.py b/example/settings/dev.py index 07dedf03..ade24139 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -67,7 +67,6 @@ JSON_API_FORMAT_FIELD_NAMES = 'camelize' JSON_API_FORMAT_TYPES = 'camelize' -JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE = True REST_FRAMEWORK = { 'PAGE_SIZE': 5, 'EXCEPTION_HANDLER': 'rest_framework_json_api.exceptions.exception_handler', diff --git a/example/tests/snapshots/snap_test_errors.py b/example/tests/snapshots/snap_test_errors.py index d77c1e24..48b8e474 100644 --- a/example/tests/snapshots/snap_test_errors.py +++ b/example/tests/snapshots/snap_test_errors.py @@ -32,8 +32,16 @@ ] } -snapshots['test_second_level_array_error 1'] = { +snapshots['test_many_third_level_dict_errors 1'] = { 'errors': [ + { + 'code': 'required', + 'detail': 'This field is required.', + 'source': { + 'pointer': '/data/attributes/comments/0/attachment/data' + }, + 'status': '400' + }, { 'code': 'required', 'detail': 'This field is required.', @@ -45,37 +53,37 @@ ] } -snapshots['test_second_level_dict_error 1'] = { +snapshots['test_second_level_array_error 1'] = { 'errors': [ { 'code': 'required', 'detail': 'This field is required.', 'source': { - 'pointer': '/data/attributes/comment/body' + 'pointer': '/data/attributes/comments/0/body' }, 'status': '400' } ] } -snapshots['test_third_level_array_error 1'] = { +snapshots['test_second_level_dict_error 1'] = { 'errors': [ { 'code': 'required', 'detail': 'This field is required.', 'source': { - 'pointer': '/data/attributes/comments/0/attachments/0/data' + 'pointer': '/data/attributes/comment/body' }, 'status': '400' } ] } -snapshots['test_third_level_custom_array_error 1'] = { +snapshots['test_third_level_array_error 1'] = { 'errors': [ { - 'code': 'invalid', - 'detail': 'Too short data', + 'code': 'required', + 'detail': 'This field is required.', 'source': { 'pointer': '/data/attributes/comments/0/attachments/0/data' }, @@ -84,20 +92,20 @@ ] } -snapshots['test_third_level_dict_error 1'] = { +snapshots['test_third_level_custom_array_error 1'] = { 'errors': [ { - 'code': 'required', - 'detail': 'This field is required.', + 'code': 'invalid', + 'detail': 'Too short data', 'source': { - 'pointer': '/data/attributes/comments/0/attachment/data' + 'pointer': '/data/attributes/comments/0/attachments/0/data' }, 'status': '400' } ] } -snapshots['test_many_third_level_dict_errors 1'] = { +snapshots['test_third_level_dict_error 1'] = { 'errors': [ { 'code': 'required', @@ -106,16 +114,6 @@ 'pointer': '/data/attributes/comments/0/attachment/data' }, 'status': '400' - }, - { - 'code': 'required', - 'detail': 'This field is required.', - 'source': { - 'pointer': '/data/attributes/comments/0/body' - }, - 'status': '400' } ] } - -snapshots['test_deprecation_warning 1'] = 'Rendering nested serializer as relationship is deprecated. Use `ResourceRelatedField` instead if DummyNestedSerializer in serializer example.tests.test_errors.test_deprecation_warning..DummySerializer should remain a relationship. Otherwise set JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE to True to render nested serializer as nested json attribute' diff --git a/example/tests/test_errors.py b/example/tests/test_errors.py index 305e14b9..ff2e8f95 100644 --- a/example/tests/test_errors.py +++ b/example/tests/test_errors.py @@ -62,7 +62,6 @@ def some_blog(db): def perform_error_test(client, data): with override_settings( - JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE=True, ROOT_URLCONF=__name__ ): url = reverse('entries-nested-list') @@ -222,18 +221,3 @@ def test_many_third_level_dict_errors(client, some_blog, snapshot): } snapshot.assert_match(perform_error_test(client, data)) - - -@pytest.mark.filterwarnings('default::DeprecationWarning:rest_framework_json_api.serializers') -def test_deprecation_warning(recwarn, settings, snapshot): - settings.JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE = False - - class DummyNestedSerializer(serializers.Serializer): - field = serializers.CharField() - - class DummySerializer(serializers.Serializer): - nested = DummyNestedSerializer(many=True) - - assert len(recwarn) == 1 - warning = recwarn.pop(DeprecationWarning) - snapshot.assert_match(str(warning.message)) diff --git a/example/tests/unit/test_renderers.py b/example/tests/unit/test_renderers.py index b088ee6e..034cc45f 100644 --- a/example/tests/unit/test_renderers.py +++ b/example/tests/unit/test_renderers.py @@ -1,7 +1,6 @@ import json import pytest -from django.test import override_settings from django.utils import timezone from rest_framework_json_api import serializers, views @@ -173,7 +172,7 @@ def test_extract_relation_instance(comment): assert got == comment.entry.blog -def test_attribute_rendering_strategy(db): +def test_render_serializer_as_attribute(db): # setting up blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") entry = Entry.objects.create( @@ -196,10 +195,8 @@ def test_attribute_rendering_strategy(db): author=Author.objects.first() ) - with override_settings( - JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE=True): - rendered = render_dummy_test_serialized_view(AuthorWithNestedFieldsViewSet, author) - result = json.loads(rendered.decode()) + rendered = render_dummy_test_serialized_view(AuthorWithNestedFieldsViewSet, author) + result = json.loads(rendered.decode()) expected = { "data": { diff --git a/example/tests/unit/test_serializer_method_field.py b/example/tests/unit/test_serializer_method_field.py index 22935ebb..89e18295 100644 --- a/example/tests/unit/test_serializer_method_field.py +++ b/example/tests/unit/test_serializer_method_field.py @@ -1,6 +1,5 @@ from __future__ import absolute_import -import pytest from rest_framework import serializers from rest_framework_json_api.relations import SerializerMethodResourceRelatedField @@ -39,28 +38,3 @@ def get_custom_entry(self, instance): serializer = BlogSerializer(instance=Blog()) assert serializer.data['one_entry']['id'] == '100' - - -@pytest.mark.filterwarnings("ignore::DeprecationWarning") -def test_source(): - class BlogSerializer(serializers.ModelSerializer): - one_entry = SerializerMethodResourceRelatedField( - model=Entry, - source='get_custom_entry' - ) - - class Meta: - model = Blog - fields = ['one_entry'] - - def get_custom_entry(self, instance): - return Entry(id=100) - - serializer = BlogSerializer(instance=Blog()) - assert serializer.data['one_entry']['id'] == '100' - - -@pytest.mark.filterwarnings("error::DeprecationWarning") -def test_source_is_deprecated(): - with pytest.raises(DeprecationWarning): - SerializerMethodResourceRelatedField(model=Entry, source='get_custom_entry') diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index eb7ff3a1..95df1d48 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -1,5 +1,4 @@ import json -import warnings from collections import OrderedDict import inflection @@ -349,11 +348,6 @@ def to_internal_value(self, data): class SerializerMethodFieldBase(Field): def __init__(self, method_name=None, **kwargs): - if not method_name and kwargs.get('source'): - method_name = kwargs.pop('source') - warnings.warn(DeprecationWarning( - "'source' argument of {cls} is deprecated, use 'method_name' " - "as in SerializerMethodField".format(cls=self.__class__.__name__)), stacklevel=3) self.method_name = method_name kwargs['source'] = '*' kwargs['read_only'] = True diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index cfc74f1f..b6f216be 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -11,13 +11,12 @@ from rest_framework import relations, renderers from rest_framework.fields import SkipField, get_attribute from rest_framework.relations import PKOnlyObject -from rest_framework.serializers import BaseSerializer, ListSerializer, Serializer +from rest_framework.serializers import ListSerializer, Serializer from rest_framework.settings import api_settings import rest_framework_json_api from rest_framework_json_api import utils from rest_framework_json_api.relations import HyperlinkedMixin, ResourceRelatedField, SkipDataMixin -from rest_framework_json_api.settings import json_api_settings class JSONRenderer(renderers.JSONRenderer): @@ -53,7 +52,6 @@ def extract_attributes(cls, fields, resource): Builds the `attributes` object of the JSON API resource object. """ data = OrderedDict() - render_nested_as_attribute = json_api_settings.SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE for field_name, field in iter(fields.items()): # ID is always provided in the root of JSON API so remove it from attributes if field_name == 'id': @@ -67,9 +65,6 @@ def extract_attributes(cls, fields, resource): ): continue - if isinstance(field, BaseSerializer) and not render_nested_as_attribute: - continue - # Skip read_only attribute fields when `resource` is an empty # serializer. Prevents the "Raw Data" form of the browsable API # from rendering `"foo": null` for read only fields @@ -94,7 +89,6 @@ def extract_relationships(cls, fields, resource, resource_instance): from rest_framework_json_api.relations import ResourceRelatedField data = OrderedDict() - render_nested_as_attribute = json_api_settings.SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE # Don't try to extract relationships from a non-existent resource if resource_instance is None: @@ -111,13 +105,10 @@ def extract_relationships(cls, fields, resource, resource_instance): # Skip fields without relations if not isinstance( - field, (relations.RelatedField, relations.ManyRelatedField, BaseSerializer) + field, (relations.RelatedField, relations.ManyRelatedField) ): continue - if isinstance(field, BaseSerializer) and render_nested_as_attribute: - continue - source = field.source relation_type = utils.get_related_resource_type(field) @@ -252,56 +243,6 @@ def extract_relationships(cls, fields, resource, resource_instance): }) continue - if isinstance(field, ListSerializer): - resolved, relation_instance = utils.get_relation_instance( - resource_instance, source, field.parent - ) - if not resolved: - continue - - relation_data = list() - - serializer_data = resource.get(field_name) - resource_instance_queryset = list(relation_instance) - if isinstance(serializer_data, list): - for position in range(len(serializer_data)): - nested_resource_instance = resource_instance_queryset[position] - nested_resource_instance_type = ( - relation_type or - utils.get_resource_type_from_instance(nested_resource_instance) - ) - - relation_data.append(OrderedDict([ - ('type', nested_resource_instance_type), - ('id', encoding.force_str(nested_resource_instance.pk)) - ])) - - data.update({field_name: {'data': relation_data}}) - continue - - if isinstance(field, Serializer): - relation_instance_id = getattr(resource_instance, source + "_id", None) - if not relation_instance_id: - resolved, relation_instance = utils.get_relation_instance( - resource_instance, source, field.parent - ) - if not resolved: - continue - - if relation_instance is not None: - relation_instance_id = relation_instance.pk - - data.update({ - field_name: { - 'data': ( - OrderedDict([ - ('type', relation_type), - ('id', encoding.force_str(relation_instance_id)) - ]) if resource.get(field_name) else None) - } - }) - continue - return utils.format_field_names(data) @classmethod @@ -336,7 +277,6 @@ def extract_included(cls, fields, resource, resource_instance, included_resource included_serializers = utils.get_included_serializers(current_serializer) included_resources = copy.copy(included_resources) included_resources = [inflection.underscore(value) for value in included_resources] - render_nested_as_attribute = json_api_settings.SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE for field_name, field in iter(fields.items()): # Skip URL field @@ -345,13 +285,10 @@ def extract_included(cls, fields, resource, resource_instance, included_resource # Skip fields without relations if not isinstance( - field, (relations.RelatedField, relations.ManyRelatedField, BaseSerializer) + field, (relations.RelatedField, relations.ManyRelatedField) ): continue - if isinstance(field, BaseSerializer) and render_nested_as_attribute: - continue - try: included_resources.remove(field_name) except ValueError: diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 80604b6f..f7d884ec 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -1,5 +1,3 @@ -import warnings - import inflection from django.core.exceptions import ObjectDoesNotExist from django.db.models.query import QuerySet @@ -9,7 +7,6 @@ from rest_framework_json_api.exceptions import Conflict from rest_framework_json_api.relations import ResourceRelatedField -from rest_framework_json_api.settings import json_api_settings from rest_framework_json_api.utils import ( get_included_resources, get_included_serializers, @@ -132,26 +129,7 @@ def validate_path(serializer_class, field_path, path): class SerializerMetaclass(SerializerMetaclass): - - @classmethod - def _get_declared_fields(cls, bases, attrs): - fields = super()._get_declared_fields(bases, attrs) - for field_name, field in fields.items(): - if isinstance(field, BaseSerializer) and \ - not json_api_settings.SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE: - clazz = '{}.{}'.format(attrs['__module__'], attrs['__qualname__']) - if isinstance(field, ListSerializer): - nested_class = type(field.child).__name__ - else: - nested_class = type(field).__name__ - - warnings.warn(DeprecationWarning( - "Rendering nested serializer as relationship is deprecated. " - "Use `ResourceRelatedField` instead if {} in serializer {} should remain " - "a relationship. Otherwise set " - "JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE to True to render nested " - "serializer as nested json attribute".format(nested_class, clazz))) - return fields + pass # If user imports serializer from here we can catch class definition and check @@ -235,55 +213,6 @@ def get_field_names(self, declared_fields, info): fields = super(ModelSerializer, self).get_field_names(declared, info) return list(fields) + list(getattr(self.Meta, 'meta_fields', list())) - def to_representation(self, instance): - """ - Object instance -> Dict of primitive datatypes. - """ - ret = OrderedDict() - readable_fields = [ - field for field in self.fields.values() - if not field.write_only - ] - - for field in readable_fields: - try: - field_representation = self._get_field_representation(field, instance) - ret[field.field_name] = field_representation - except SkipField: - continue - - return ret - - def _get_field_representation(self, field, instance): - request = self.context.get('request') - is_included = field.source in get_included_resources(request) - render_nested_as_attribute = json_api_settings.SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE - if not is_included and \ - isinstance(field, ModelSerializer) and \ - hasattr(instance, field.source + '_id') and \ - not render_nested_as_attribute: - attribute = getattr(instance, field.source + '_id') - - if attribute is None: - return None - - resource_type = get_resource_type_from_serializer(field) - if resource_type: - return OrderedDict([('type', resource_type), ('id', attribute)]) - - attribute = field.get_attribute(instance) - - # We skip `to_representation` for `None` values so that fields do - # not have to explicitly deal with that case. - # - # For related fields with `use_pk_only_optimization` we need to - # resolve the pk value. - check_for_none = attribute.pk if isinstance(attribute, PKOnlyObject) else attribute - if check_for_none is None: - return None - else: - return field.to_representation(attribute) - class PolymorphicSerializerMetaclass(SerializerMetaclass): """ diff --git a/rest_framework_json_api/settings.py b/rest_framework_json_api/settings.py index 74e4e8d3..1385630c 100644 --- a/rest_framework_json_api/settings.py +++ b/rest_framework_json_api/settings.py @@ -14,7 +14,6 @@ 'FORMAT_TYPES': False, 'PLURALIZE_TYPES': False, 'UNIFORM_EXCEPTIONS': False, - 'SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE': False } From 15f5424e0a622da82ba9e1146d1f4cbfb602137d Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 16 Oct 2020 16:25:44 +0200 Subject: [PATCH 018/252] Define what Python, Django and Django REST framework series are supported (#839) --- README.rst | 2 ++ docs/getting-started.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/README.rst b/README.rst index f1d606bb..a0dd0475 100644 --- a/README.rst +++ b/README.rst @@ -93,6 +93,8 @@ Requirements We **highly** recommend and only officially support the latest patch release of each Python, Django and REST Framework series. +Generally Python and Django series are supported till the official end of life. For Django REST Framework the last two series are supported. + ------------ Installation ------------ diff --git a/docs/getting-started.md b/docs/getting-started.md index f16afd57..f0feba4d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -57,6 +57,8 @@ like the following: We **highly** recommend and only officially support the latest patch release of each Python, Django and REST Framework series. +Generally Python and Django series are supported till the official end of life. For Django REST Framework the last two series are supported. + ## Installation From PyPI From e09b85dc3a7220f2e6fb1a3994ba176a2b71566f Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 16 Oct 2020 17:03:23 +0200 Subject: [PATCH 019/252] Drop EOL Python 3.5 support (#840) Co-authored-by: Alan Crosswell --- .travis.yml | 6 ------ CHANGELOG.md | 3 ++- README.rst | 2 +- docs/getting-started.md | 2 +- setup.py | 3 +-- tox.ini | 3 +-- 6 files changed, 6 insertions(+), 13 deletions(-) diff --git a/.travis.yml b/.travis.yml index a70727aa..5fa00221 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,6 @@ cache: pip # Favor explicit over implicit and use an explicit build matrix. matrix: allow_failures: - - env: TOXENV=py35-django22-drfmaster - env: TOXENV=py36-django22-drfmaster - env: TOXENV=py37-django22-drfmaster - env: TOXENV=py38-django22-drfmaster @@ -22,11 +21,6 @@ matrix: - python: 3.6 env: TOXENV=docs - - python: 3.5 - env: TOXENV=py35-django22-drf312 - - python: 3.5 - env: TOXENV=py35-django22-drfmaster - - python: 3.6 env: TOXENV=py36-django22-drf312 - python: 3.6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b8a3e77..21749009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ This release is not backwards compatible. For easy migration best upgrade first ### Removed +* Removed support for Python 3.5. * Removed support for Django 1.11. * Removed support for Django 2.1. * Removed support for Django REST framework 3.10, 3.11 @@ -29,7 +30,7 @@ This release is not backwards compatible. For easy migration best upgrade first ## [3.2.0] - 2020-08-26 -This is the last release supporting Django 1.11, Django 2.1, DRF 3.10 and DRF 3.11. +This is the last release supporting Django 1.11, Django 2.1, DRF 3.10, DRF 3.11 and Python 3.5. ### Added diff --git a/README.rst b/README.rst index a0dd0475..d85431af 100644 --- a/README.rst +++ b/README.rst @@ -87,7 +87,7 @@ As a Django REST Framework JSON API (short DJA) we are trying to address followi Requirements ------------ -1. Python (3.5, 3.6, 3.7, 3.8) +1. Python (3.6, 3.7, 3.8) 2. Django (2.2, 3.0, 3.1) 3. Django REST Framework (3.12) diff --git a/docs/getting-started.md b/docs/getting-started.md index f0feba4d..046a9b5e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -51,7 +51,7 @@ like the following: ## Requirements -1. Python (3.5, 3.6, 3.7, 3.8) +1. Python (3.6, 3.7, 3.8) 2. Django (2.2, 3.0, 3.1) 3. Django REST Framework (3.12) diff --git a/setup.py b/setup.py index 5053a63c..67fc22d1 100755 --- a/setup.py +++ b/setup.py @@ -81,7 +81,6 @@ def get_package_data(package): 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', @@ -99,6 +98,6 @@ def get_package_data(package): 'django-filter': ['django-filter>=2.0'] }, setup_requires=wheel, - python_requires=">=3.5", + python_requires=">=3.6", zip_safe=False, ) diff --git a/tox.ini b/tox.ini index 48b790ef..f28e24e6 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,6 @@ [tox] envlist = - py{35,36,37,38}-django22-drf{312,master}, - py{36,37,38}-django{30.31}-drf{312,master}, + py{36,37,38}-django{22,30,31}-drf{312,master}, lint,docs [testenv] From 7efea43d7c58adced5d07a64824fc49ec6e4320e Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 16 Oct 2020 19:05:56 +0200 Subject: [PATCH 020/252] Stopped SparseFieldsetsMixin interpretting invalid fields parameter (#842) Co-authored-by: Alan Crosswell --- CHANGELOG.md | 3 ++ .../integration/test_sparse_fieldsets.py | 34 ++++++++++++++++--- rest_framework_json_api/serializers.py | 2 +- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21749009..d008982c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ This release is not backwards compatible. For easy migration best upgrade first * Removed obsolete `source` argument of `SerializerMethodResourceRelatedField` * Removed obsolete setting `JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE` to render nested serializers as relationships. Default is as attribute now. +### Fixed + +* Stopped `SparseFieldsetsMixin` interpretting invalid fields query parameter (e.g. invalidfields[entries]=blog,headline) ## [3.2.0] - 2020-08-26 diff --git a/example/tests/integration/test_sparse_fieldsets.py b/example/tests/integration/test_sparse_fieldsets.py index c76f1efd..83b8560a 100644 --- a/example/tests/integration/test_sparse_fieldsets.py +++ b/example/tests/integration/test_sparse_fieldsets.py @@ -1,12 +1,36 @@ import pytest from django.urls import reverse +from rest_framework import status pytestmark = pytest.mark.django_db -def test_sparse_fieldset_ordered_dict_error(multiple_entries, client): +def test_sparse_fieldset_valid_fields(client, entry): base_url = reverse('entry-list') - querystring = '?fields[entries]=blog,headline' - # RuntimeError: OrderedDict mutated during iteration - response = client.get(base_url + querystring) - assert response.status_code == 200 # succeed if we didn't fail due to the above RuntimeError + response = client.get(base_url, data={'fields[entries]': 'blog,headline'}) + assert response.status_code == status.HTTP_200_OK + data = response.json()['data'] + + assert len(data) == 1 + entry = data[0] + assert entry['attributes'].keys() == {'headline'} + assert entry['relationships'].keys() == {'blog'} + + +@pytest.mark.parametrize("fields_param", ['invalidfields[entries]', 'fieldsinvalid[entries']) +def test_sparse_fieldset_invalid_fields_parameter(client, entry, fields_param): + """ + Test that invalid fields query parameter is not processed by sparse fieldset. + + rest_framework_json_api.filters.QueryParameterValidationFilter takes care of error + handling in such a case. + """ + base_url = reverse('entry-list') + response = client.get(base_url, data={'invalidfields[entries]': 'blog,headline'}) + assert response.status_code == status.HTTP_200_OK + data = response.json()['data'] + + assert len(data) == 1 + entry = data[0] + assert entry['attributes'].keys() != {'headline'} + assert entry['relationships'].keys() != {'blog'} diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index f7d884ec..31f2a86b 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -71,7 +71,7 @@ def __init__(self, *args, **kwargs): ) try: param_name = next( - key for key in request.query_params if sparse_fieldset_query_param in key + key for key in request.query_params if sparse_fieldset_query_param == key ) except StopIteration: pass From f59bb0d9ae93a7aafb086384e5866ff16356f9c3 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 20 Oct 2020 09:48:15 +0200 Subject: [PATCH 021/252] Scheduled biweekly dependency update for week 42 (#844) * Update isort from 5.5.4 to 5.6.4 * Update faker from 4.4.0 to 4.14.0 * Update pytest-django from 3.10.0 to 4.0.0 --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-testing.txt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index b83029e0..c144f975 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,3 +1,3 @@ flake8==3.8.4 flake8-isort==4.0.0 -isort==5.5.4 +isort==5.6.4 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index a8ee0553..e854996e 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,8 +1,8 @@ django-debug-toolbar==3.1.1 factory-boy==3.1.0 -Faker==4.4.0 +Faker==4.14.0 pytest==6.1.1 pytest-cov==2.10.1 -pytest-django==3.10.0 +pytest-django==4.0.0 pytest-factoryboy==2.0.3 snapshottest==0.6.0 From b192cb05d7fd1876fe6767481272fa2c0bd85a32 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sat, 24 Oct 2020 20:50:59 +0200 Subject: [PATCH 022/252] Added support for Python 3.9 (#846) * Added support for Python 3.9 * trying using 3.9-dev Co-authored-by: Alan Crosswell --- .travis.yml | 16 ++++++++++++++++ CHANGELOG.md | 1 + README.rst | 2 +- docs/getting-started.md | 2 +- setup.py | 1 + tox.ini | 2 +- 6 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 5fa00221..f5ad4aa7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,12 +8,15 @@ matrix: - env: TOXENV=py36-django22-drfmaster - env: TOXENV=py37-django22-drfmaster - env: TOXENV=py38-django22-drfmaster + - env: TOXENV=py39-django22-drfmaster - env: TOXENV=py36-django30-drfmaster - env: TOXENV=py37-django30-drfmaster - env: TOXENV=py38-django30-drfmaster + - env: TOXENV=py39-django30-drfmaster - env: TOXENV=py36-django31-drfmaster - env: TOXENV=py37-django31-drfmaster - env: TOXENV=py38-django31-drfmaster + - env: TOXENV=py39-django31-drfmaster include: - python: 3.6 @@ -60,6 +63,19 @@ matrix: - python: 3.8 env: TOXENV=py38-django31-drfmaster + - python: 3.9-dev + env: TOXENV=py39-django22-drf312 + - python: 3.9-dev + env: TOXENV=py39-django22-drfmaster + - python: 3.9-dev + env: TOXENV=py39-django30-drf312 + - python: 3.9-dev + env: TOXENV=py39-django30-drfmaster + - python: 3.9-dev + env: TOXENV=py39-django31-drf312 + - python: 3.9-dev + env: TOXENV=py39-django31-drfmaster + install: - pip install tox script: diff --git a/CHANGELOG.md b/CHANGELOG.md index d008982c..6cde1091 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ This release is not backwards compatible. For easy migration best upgrade first * Added support for Django REST framework 3.12 * Added support for Django 3.1 +* Added support for Python 3.9 ### Removed diff --git a/README.rst b/README.rst index d85431af..33332056 100644 --- a/README.rst +++ b/README.rst @@ -87,7 +87,7 @@ As a Django REST Framework JSON API (short DJA) we are trying to address followi Requirements ------------ -1. Python (3.6, 3.7, 3.8) +1. Python (3.6, 3.7, 3.8, 3.9) 2. Django (2.2, 3.0, 3.1) 3. Django REST Framework (3.12) diff --git a/docs/getting-started.md b/docs/getting-started.md index 046a9b5e..3b091662 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -51,7 +51,7 @@ like the following: ## Requirements -1. Python (3.6, 3.7, 3.8) +1. Python (3.6, 3.7, 3.8, 3.9) 2. Django (2.2, 3.0, 3.1) 3. Django REST Framework (3.12) diff --git a/setup.py b/setup.py index 67fc22d1..f6d7c292 100755 --- a/setup.py +++ b/setup.py @@ -84,6 +84,7 @@ def get_package_data(package): 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Software Development :: Libraries :: Application Frameworks', 'Topic :: Software Development :: Libraries :: Python Modules', diff --git a/tox.ini b/tox.ini index f28e24e6..c0ebf760 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{36,37,38}-django{22,30,31}-drf{312,master}, + py{36,37,38,39}-django{22,30,31}-drf{312,master}, lint,docs [testenv] From 9113ca058e11ee5a6cdb69e3159ca73f8ef87bbe Mon Sep 17 00:00:00 2001 From: Kieran Evans Date: Fri, 30 Oct 2020 13:48:01 +0000 Subject: [PATCH 023/252] OAS 3.0 schema generation (#772) initial implementation of OAS 3.0 generateschema Co-authored-by: Alan Crosswell Co-authored-by: Oliver Sauder --- AUTHORS | 1 + CHANGELOG.md | 8 + README.rst | 6 +- docs/getting-started.md | 6 +- docs/usage.md | 123 ++- example/serializers.py | 13 +- example/settings/dev.py | 2 + example/templates/swagger-ui.html | 28 + example/tests/snapshots/snap_test_openapi.py | 647 ++++++++++++++ example/tests/test_format_keys.py | 3 +- example/tests/test_openapi.py | 149 ++++ .../tests/unit/test_filter_schema_params.py | 77 ++ example/urls.py | 17 +- requirements/requirements-optionals.txt | 2 + .../django_filters/backends.py | 14 + rest_framework_json_api/schemas/__init__.py | 0 rest_framework_json_api/schemas/openapi.py | 818 ++++++++++++++++++ setup.py | 3 +- 18 files changed, 1910 insertions(+), 7 deletions(-) create mode 100644 example/templates/swagger-ui.html create mode 100644 example/tests/snapshots/snap_test_openapi.py create mode 100644 example/tests/test_openapi.py create mode 100644 example/tests/unit/test_filter_schema_params.py create mode 100644 rest_framework_json_api/schemas/__init__.py create mode 100644 rest_framework_json_api/schemas/openapi.py diff --git a/AUTHORS b/AUTHORS index 17d3de18..b876d4ae 100644 --- a/AUTHORS +++ b/AUTHORS @@ -14,6 +14,7 @@ Jason Housley Jerel Unruh Jonathan Senecal Joseba Mendivil +Kieran Evans Léo S. Luc Cary Matt Layman diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cde1091..957d5334 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,9 +18,17 @@ This release is not backwards compatible. For easy migration best upgrade first * Added support for Django REST framework 3.12 * Added support for Django 3.1 * Added support for Python 3.9 +* Added initial optional support for [openapi](https://www.openapis.org/) schema generation. Enable with: + ``` + pip install djangorestframework-jsonapi['openapi'] + ``` + This first release is a start at implementing OAS schema generation. To use the generated schema you may + still need to manually add some schema attributes but can expect future improvements here and as + upstream DRF's OAS schema generation continues to mature. ### Removed + * Removed support for Python 3.5. * Removed support for Django 1.11. * Removed support for Django 2.1. diff --git a/README.rst b/README.rst index 33332056..31e4ba2a 100644 --- a/README.rst +++ b/README.rst @@ -108,6 +108,7 @@ From PyPI $ # for optional package integrations $ pip install djangorestframework-jsonapi['django-filter'] $ pip install djangorestframework-jsonapi['django-polymorphic'] + $ pip install djangorestframework-jsonapi['openapi'] From Source @@ -135,7 +136,10 @@ installed and activated: $ django-admin loaddata drf_example --settings=example.settings $ django-admin runserver --settings=example.settings -Browse to http://localhost:8000 +Browse to +* http://localhost:8000 for the list of available collections (in a non-JSONAPI format!), +* http://localhost:8000/swagger-ui/ for a Swagger user interface to the dynamic schema view, or +* http://localhost:8000/openapi for the schema view's OpenAPI specification document. Running Tests and linting diff --git a/docs/getting-started.md b/docs/getting-started.md index 3b091662..c305c801 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -67,6 +67,7 @@ From PyPI # for optional package integrations pip install djangorestframework-jsonapi['django-filter'] pip install djangorestframework-jsonapi['django-polymorphic'] + pip install djangorestframework-jsonapi['openapi'] From Source @@ -85,7 +86,10 @@ From Source django-admin runserver --settings=example.settings -Browse to http://localhost:8000 +Browse to +* [http://localhost:8000](http://localhost:8000) for the list of available collections (in a non-JSONAPI format!), +* [http://localhost:8000/swagger-ui/](http://localhost:8000/swagger-ui/) for a Swagger user interface to the dynamic schema view, or +* [http://localhost:8000/openapi](http://localhost:8000/openapi) for the schema view's OpenAPI specification document. ## Running Tests diff --git a/docs/usage.md b/docs/usage.md index 9fd46e6b..c091cd4d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,4 +1,3 @@ - # Usage The DJA package implements a custom renderer, parser, exception handler, query filter backends, and @@ -32,6 +31,7 @@ REST_FRAMEWORK = { 'rest_framework.renderers.BrowsableAPIRenderer' ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', + 'DEFAULT_SCHEMA_CLASS': 'rest_framework_json_api.schemas.openapi.AutoSchema', 'DEFAULT_FILTER_BACKENDS': ( 'rest_framework_json_api.filters.QueryParameterValidationFilter', 'rest_framework_json_api.filters.OrderingFilter', @@ -944,3 +944,124 @@ The `prefetch_related` case will issue 4 queries, but they will be small and fas ### Relationships ### Errors --> + +## Generating an OpenAPI Specification (OAS) 3.0 schema document + +DRF >= 3.12 has a [new OAS schema functionality](https://www.django-rest-framework.org/api-guide/schemas/) to generate an +[OAS 3.0 schema](https://www.openapis.org/) as a YAML or JSON file. + +DJA extends DRF's schema support to generate an OAS schema in the JSON:API format. + +### AutoSchema Settings + +In order to produce an OAS schema that properly represents the JSON:API structure +you have to either add a `schema` attribute to each view class or set the `REST_FRAMEWORK['DEFAULT_SCHEMA_CLASS']` +to DJA's version of AutoSchema. + +#### View-based + +```python +from rest_framework_json_api.schemas.openapi import AutoSchema + +class MyViewset(ModelViewSet): + schema = AutoSchema + ... +``` + +#### Default schema class + +```python +REST_FRAMEWORK = { + # ... + 'DEFAULT_SCHEMA_CLASS': 'rest_framework_json_api.schemas.openapi.AutoSchema', +} +``` + +### Adding additional OAS schema content + +You can extend the OAS schema document by subclassing +[`SchemaGenerator`](https://www.django-rest-framework.org/api-guide/schemas/#schemagenerator) +and extending `get_schema`. + + +Here's an example that adds OAS `info` and `servers` objects. + +```python +from rest_framework_json_api.schemas.openapi import SchemaGenerator as JSONAPISchemaGenerator + + +class MySchemaGenerator(JSONAPISchemaGenerator): + """ + Describe my OAS schema info in detail (overriding what DRF put in) and list the servers where it can be found. + """ + def get_schema(self, request, public): + schema = super().get_schema(request, public) + schema['info'] = { + 'version': '1.0', + 'title': 'my demo API', + 'description': 'A demonstration of [OAS 3.0](https://www.openapis.org)', + 'contact': { + 'name': 'my name' + }, + 'license': { + 'name': 'BSD 2 clause', + 'url': 'https://github.com/django-json-api/django-rest-framework-json-api/blob/master/LICENSE', + } + } + schema['servers'] = [ + {'url': 'https://localhost/v1', 'description': 'local docker'}, + {'url': 'http://localhost:8000/v1', 'description': 'local dev'}, + {'url': 'https://api.example.com/v1', 'description': 'demo server'}, + {'url': '{serverURL}', 'description': 'provide your server URL', + 'variables': {'serverURL': {'default': 'http://localhost:8000/v1'}}} + ] + return schema +``` + +### Generate a Static Schema on Command Line + +See [DRF documentation for generateschema](https://www.django-rest-framework.org/api-guide/schemas/#generating-a-static-schema-with-the-generateschema-management-command) +To generate an OAS schema document, use something like: + +```text +$ django-admin generateschema --settings=example.settings \ + --generator_class myapp.views.MySchemaGenerator >myschema.yaml +``` + +You can then use any number of OAS tools such as +[swagger-ui-watcher](https://www.npmjs.com/package/swagger-ui-watcher) +to render the schema: +```text +$ swagger-ui-watcher myschema.yaml +``` + +Note: Swagger-ui-watcher will complain that "DELETE operations cannot have a requestBody" +but it will still work. This [error](https://github.com/OAI/OpenAPI-Specification/pull/2117) +in the OAS specification will be fixed when [OAS 3.1.0](https://www.openapis.org/blog/2020/06/18/openapi-3-1-0-rc0-its-here) +is published. + +([swagger-ui](https://www.npmjs.com/package/swagger-ui) will work silently.) + +### Generate a Dynamic Schema in a View + +See [DRF documentation for a Dynamic Schema](https://www.django-rest-framework.org/api-guide/schemas/#generating-a-dynamic-schema-with-schemaview). + +```python +from rest_framework.schemas import get_schema_view + +urlpatterns = [ + ... + path('openapi', get_schema_view( + title="Example API", + description="API for all things …", + version="1.0.0", + generator_class=MySchemaGenerator, + ), name='openapi-schema'), + path('swagger-ui/', TemplateView.as_view( + template_name='swagger-ui.html', + extra_context={'schema_url': 'openapi-schema'} + ), name='swagger-ui'), + ... +] +``` + diff --git a/example/serializers.py b/example/serializers.py index 1728b742..9dc84a4a 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -230,6 +230,16 @@ class AuthorSerializer(serializers.ModelSerializer): queryset=Comment.objects, many=True ) + secrets = serializers.HiddenField( + default='Shhhh!' + ) + defaults = serializers.CharField( + default='default', + max_length=20, + min_length=3, + write_only=True, + help_text='help for defaults', + ) included_serializers = { 'bio': AuthorBioSerializer, 'type': AuthorTypeSerializer @@ -244,7 +254,8 @@ class AuthorSerializer(serializers.ModelSerializer): class Meta: model = Author - fields = ('name', 'email', 'bio', 'entries', 'comments', 'first_entry', 'type') + fields = ('name', 'email', 'bio', 'entries', 'comments', 'first_entry', 'type', + 'secrets', 'defaults') def get_first_entry(self, obj): return obj.entries.first() diff --git a/example/settings/dev.py b/example/settings/dev.py index ade24139..961807e3 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -21,6 +21,7 @@ 'django.contrib.sites', 'django.contrib.sessions', 'django.contrib.auth', + 'rest_framework_json_api', 'rest_framework', 'polymorphic', 'example', @@ -88,6 +89,7 @@ 'rest_framework.renderers.BrowsableAPIRenderer', ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', + 'DEFAULT_SCHEMA_CLASS': 'rest_framework_json_api.schemas.openapi.AutoSchema', 'DEFAULT_FILTER_BACKENDS': ( 'rest_framework_json_api.filters.OrderingFilter', 'rest_framework_json_api.django_filters.DjangoFilterBackend', diff --git a/example/templates/swagger-ui.html b/example/templates/swagger-ui.html new file mode 100644 index 00000000..29776491 --- /dev/null +++ b/example/templates/swagger-ui.html @@ -0,0 +1,28 @@ + + + + Swagger + + + + + +
+ + + + \ No newline at end of file diff --git a/example/tests/snapshots/snap_test_openapi.py b/example/tests/snapshots/snap_test_openapi.py new file mode 100644 index 00000000..ec8da388 --- /dev/null +++ b/example/tests/snapshots/snap_test_openapi.py @@ -0,0 +1,647 @@ +# -*- coding: utf-8 -*- +# snapshottest: v1 - https://goo.gl/zC4yUc +from __future__ import unicode_literals + +from snapshottest import Snapshot + + +snapshots = Snapshot() + +snapshots['test_path_without_parameters 1'] = '''{ + "description": "", + "operationId": "List/authors/", + "parameters": [ + { + "$ref": "#/components/parameters/include" + }, + { + "$ref": "#/components/parameters/fields" + }, + { + "$ref": "#/components/parameters/sort" + }, + { + "description": "A page number within the paginated result set.", + "in": "query", + "name": "page[number]", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Number of results to return per page.", + "in": "query", + "name": "page[size]", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Which field to use when ordering the results.", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "A search term.", + "in": "query", + "name": "filter[search]", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/Author" + }, + "type": "array" + }, + "included": { + "items": { + "$ref": "#/components/schemas/resource" + }, + "type": "array", + "uniqueItems": true + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi" + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/links" + }, + { + "$ref": "#/components/schemas/pagination" + } + ], + "description": "Link members related to primary data" + } + }, + "required": [ + "data" + ], + "type": "object" + } + } + }, + "description": "List/authors/" + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not found" + } + } +}''' + +snapshots['test_path_with_id_parameter 1'] = '''{ + "description": "", + "operationId": "retrieve/authors/{id}/", + "parameters": [ + { + "description": "A unique integer value identifying this author.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/include" + }, + { + "$ref": "#/components/parameters/fields" + }, + { + "$ref": "#/components/parameters/sort" + }, + { + "description": "Which field to use when ordering the results.", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "A search term.", + "in": "query", + "name": "filter[search]", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/Author" + }, + "included": { + "items": { + "$ref": "#/components/schemas/resource" + }, + "type": "array", + "uniqueItems": true + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi" + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/links" + }, + { + "$ref": "#/components/schemas/pagination" + } + ], + "description": "Link members related to primary data" + } + }, + "required": [ + "data" + ], + "type": "object" + } + } + }, + "description": "retrieve/authors/{id}/" + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not found" + } + } +}''' + +snapshots['test_post_request 1'] = '''{ + "description": "", + "operationId": "create/authors/", + "parameters": [], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "attributes": { + "properties": { + "defaults": { + "default": "default", + "description": "help for defaults", + "maxLength": 20, + "minLength": 3, + "type": "string", + "writeOnly": true + }, + "email": { + "format": "email", + "maxLength": 254, + "type": "string" + }, + "name": { + "maxLength": 50, + "type": "string" + } + }, + "required": [ + "name", + "email" + ], + "type": "object" + }, + "id": { + "$ref": "#/components/schemas/id" + }, + "links": { + "properties": { + "self": { + "$ref": "#/components/schemas/link" + } + }, + "type": "object" + }, + "relationships": { + "properties": { + "bio": { + "$ref": "#/components/schemas/reltoone" + }, + "comments": { + "$ref": "#/components/schemas/reltomany" + }, + "entries": { + "$ref": "#/components/schemas/reltomany" + }, + "first_entry": { + "$ref": "#/components/schemas/reltoone" + }, + "type": { + "$ref": "#/components/schemas/reltoone" + } + }, + "type": "object" + }, + "type": { + "$ref": "#/components/schemas/type" + } + }, + "required": [ + "type" + ], + "type": "object" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "201": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/Author" + }, + "included": { + "items": { + "$ref": "#/components/schemas/resource" + }, + "type": "array", + "uniqueItems": true + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi" + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/links" + }, + { + "$ref": "#/components/schemas/pagination" + } + ], + "description": "Link members related to primary data" + } + }, + "required": [ + "data" + ], + "type": "object" + } + } + }, + "description": "[Created](https://jsonapi.org/format/#crud-creating-responses-201). Assigned `id` and/or any other changes are in this response." + }, + "202": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/datum" + } + } + }, + "description": "Accepted for [asynchronous processing](https://jsonapi.org/recommendations/#asynchronous-processing)" + }, + "204": { + "description": "[Created](https://jsonapi.org/format/#crud-creating-responses-204) with the supplied `id`. No other changes from what was POSTed." + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "403": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Forbidden](https://jsonapi.org/format/#crud-creating-responses-403)" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Related resource does not exist](https://jsonapi.org/format/#crud-creating-responses-404)" + }, + "409": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Conflict](https://jsonapi.org/format/#crud-creating-responses-409)" + } + } +}''' + +snapshots['test_patch_request 1'] = '''{ + "description": "", + "operationId": "update/authors/{id}", + "parameters": [ + { + "description": "A unique integer value identifying this author.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "attributes": { + "properties": { + "defaults": { + "default": "default", + "description": "help for defaults", + "maxLength": 20, + "minLength": 3, + "type": "string", + "writeOnly": true + }, + "email": { + "format": "email", + "maxLength": 254, + "type": "string" + }, + "name": { + "maxLength": 50, + "type": "string" + } + }, + "type": "object" + }, + "id": { + "$ref": "#/components/schemas/id" + }, + "links": { + "properties": { + "self": { + "$ref": "#/components/schemas/link" + } + }, + "type": "object" + }, + "relationships": { + "properties": { + "bio": { + "$ref": "#/components/schemas/reltoone" + }, + "comments": { + "$ref": "#/components/schemas/reltomany" + }, + "entries": { + "$ref": "#/components/schemas/reltomany" + }, + "first_entry": { + "$ref": "#/components/schemas/reltoone" + }, + "type": { + "$ref": "#/components/schemas/reltoone" + } + }, + "type": "object" + }, + "type": { + "$ref": "#/components/schemas/type" + } + }, + "required": [ + "type", + "id" + ], + "type": "object" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "200": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/Author" + }, + "included": { + "items": { + "$ref": "#/components/schemas/resource" + }, + "type": "array", + "uniqueItems": true + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi" + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/links" + }, + { + "$ref": "#/components/schemas/pagination" + } + ], + "description": "Link members related to primary data" + } + }, + "required": [ + "data" + ], + "type": "object" + } + } + }, + "description": "update/authors/{id}" + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "403": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Forbidden](https://jsonapi.org/format/#crud-updating-responses-403)" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Related resource does not exist](https://jsonapi.org/format/#crud-updating-responses-404)" + }, + "409": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Conflict]([Conflict](https://jsonapi.org/format/#crud-updating-responses-409)" + } + } +}''' + +snapshots['test_delete_request 1'] = '''{ + "description": "", + "operationId": "destroy/authors/{id}", + "parameters": [ + { + "description": "A unique integer value identifying this author.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/onlymeta" + } + } + }, + "description": "[OK](https://jsonapi.org/format/#crud-deleting-responses-200)" + }, + "202": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/datum" + } + } + }, + "description": "Accepted for [asynchronous processing](https://jsonapi.org/recommendations/#asynchronous-processing)" + }, + "204": { + "description": "[no content](https://jsonapi.org/format/#crud-deleting-responses-204)" + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Resource does not exist](https://jsonapi.org/format/#crud-deleting-responses-404)" + } + } +}''' diff --git a/example/tests/test_format_keys.py b/example/tests/test_format_keys.py index ba3f4920..0fd76c67 100644 --- a/example/tests/test_format_keys.py +++ b/example/tests/test_format_keys.py @@ -58,5 +58,6 @@ def test_options_format_field_names(db, client): response = client.options(reverse('author-list')) assert response.status_code == status.HTTP_200_OK data = response.json()['data'] - expected_keys = {'name', 'email', 'bio', 'entries', 'firstEntry', 'type', 'comments'} + expected_keys = {'name', 'email', 'bio', 'entries', 'firstEntry', 'type', + 'comments', 'secrets', 'defaults'} assert expected_keys == data['actions']['POST'].keys() diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py new file mode 100644 index 00000000..e7a2b6ca --- /dev/null +++ b/example/tests/test_openapi.py @@ -0,0 +1,149 @@ +# largely based on DRF's test_openapi +import json + +from django.test import RequestFactory, override_settings +from django.urls import re_path +from rest_framework.request import Request + +from rest_framework_json_api.schemas.openapi import AutoSchema, SchemaGenerator + +from example import views + + +def create_request(path): + factory = RequestFactory() + request = Request(factory.get(path)) + return request + + +def create_view_with_kw(view_cls, method, request, initkwargs): + generator = SchemaGenerator() + view = generator.create_view(view_cls.as_view(initkwargs), method, request) + return view + + +def test_path_without_parameters(snapshot): + path = '/authors/' + method = 'GET' + + view = create_view_with_kw( + views.AuthorViewSet, + method, + create_request(path), + {'get': 'list'} + ) + inspector = AutoSchema() + inspector.view = view + + operation = inspector.get_operation(path, method) + snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) + + +def test_path_with_id_parameter(snapshot): + path = '/authors/{id}/' + method = 'GET' + + view = create_view_with_kw( + views.AuthorViewSet, + method, + create_request(path), + {'get': 'retrieve'} + ) + inspector = AutoSchema() + inspector.view = view + + operation = inspector.get_operation(path, method) + snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) + + +def test_post_request(snapshot): + method = 'POST' + path = '/authors/' + + view = create_view_with_kw( + views.AuthorViewSet, + method, + create_request(path), + {'post': 'create'} + ) + inspector = AutoSchema() + inspector.view = view + + operation = inspector.get_operation(path, method) + snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) + + +def test_patch_request(snapshot): + method = 'PATCH' + path = '/authors/{id}' + + view = create_view_with_kw( + views.AuthorViewSet, + method, + create_request(path), + {'patch': 'update'} + ) + inspector = AutoSchema() + inspector.view = view + + operation = inspector.get_operation(path, method) + snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) + + +def test_delete_request(snapshot): + method = 'DELETE' + path = '/authors/{id}' + + view = create_view_with_kw( + views.AuthorViewSet, + method, + create_request(path), + {'delete': 'delete'} + ) + inspector = AutoSchema() + inspector.view = view + + operation = inspector.get_operation(path, method) + snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) + + +@override_settings(REST_FRAMEWORK={ + 'DEFAULT_SCHEMA_CLASS': 'rest_framework_json_api.schemas.openapi.AutoSchema'}) +def test_schema_construction(): + """Construction of the top level dictionary.""" + patterns = [ + re_path('^authors/?$', views.AuthorViewSet.as_view({'get': 'list'})), + ] + generator = SchemaGenerator(patterns=patterns) + + request = create_request('/') + schema = generator.get_schema(request=request) + + assert 'openapi' in schema + assert 'info' in schema + assert 'paths' in schema + assert 'components' in schema + + +def test_schema_related_serializers(): + """ + Confirm that paths are generated for related fields. For example: + /authors/{pk}/{related_field>} + /authors/{id}/comments/ + /authors/{id}/entries/ + /authors/{id}/first_entry/ + and confirm that the schema for the related field is properly rendered + """ + generator = SchemaGenerator() + request = create_request('/') + schema = generator.get_schema(request=request) + # make sure the path's relationship and related {related_field}'s got expanded + assert '/authors/{id}/relationships/{related_field}' in schema['paths'] + assert '/authors/{id}/comments/' in schema['paths'] + assert '/authors/{id}/entries/' in schema['paths'] + assert '/authors/{id}/first_entry/' in schema['paths'] + first_get = schema['paths']['/authors/{id}/first_entry/']['get']['responses']['200'] + first_schema = first_get['content']['application/vnd.api+json']['schema'] + first_props = first_schema['properties']['data'] + assert '$ref' in first_props + assert first_props['$ref'] == '#/components/schemas/Entry' diff --git a/example/tests/unit/test_filter_schema_params.py b/example/tests/unit/test_filter_schema_params.py new file mode 100644 index 00000000..2044c467 --- /dev/null +++ b/example/tests/unit/test_filter_schema_params.py @@ -0,0 +1,77 @@ +from rest_framework import filters as drf_filters + +from rest_framework_json_api import filters as dja_filters +from rest_framework_json_api.django_filters import backends + +from example.views import EntryViewSet + + +class DummyEntryViewSet(EntryViewSet): + filter_backends = (dja_filters.QueryParameterValidationFilter, dja_filters.OrderingFilter, + backends.DjangoFilterBackend, drf_filters.SearchFilter) + filterset_fields = { + 'id': ('exact',), + 'headline': ('exact', 'contains'), + 'blog__name': ('contains', ), + } + + def __init__(self, **kwargs): + # dummy up self.request since PreloadIncludesMixin expects it to be defined + self.request = None + super(DummyEntryViewSet, self).__init__(**kwargs) + + +def test_filters_get_schema_params(): + """ + test all my filters for `get_schema_operation_parameters()` + """ + # list of tuples: (filter, expected result) + filters = [ + (dja_filters.QueryParameterValidationFilter, []), + (backends.DjangoFilterBackend, [ + { + 'name': 'filter[id]', 'required': False, 'in': 'query', + 'description': 'id', 'schema': {'type': 'string'} + }, + { + 'name': 'filter[headline]', 'required': False, 'in': 'query', + 'description': 'headline', 'schema': {'type': 'string'} + }, + { + 'name': 'filter[headline.contains]', 'required': False, 'in': 'query', + 'description': 'headline__contains', 'schema': {'type': 'string'} + }, + { + 'name': 'filter[blog.name.contains]', 'required': False, 'in': 'query', + 'description': 'blog__name__contains', 'schema': {'type': 'string'} + }, + ]), + (dja_filters.OrderingFilter, [ + { + 'name': 'sort', 'required': False, 'in': 'query', + 'description': 'Which field to use when ordering the results.', + 'schema': {'type': 'string'} + } + ]), + (drf_filters.SearchFilter, [ + { + 'name': 'filter[search]', 'required': False, 'in': 'query', + 'description': 'A search term.', + 'schema': {'type': 'string'} + } + ]), + ] + view = DummyEntryViewSet() + + for c, expected in filters: + f = c() + result = f.get_schema_operation_parameters(view) + assert len(result) == len(expected) + if len(result) == 0: + continue + # py35: the result list/dict ordering isn't guaranteed + for res_item in result: + assert 'name' in res_item + for exp_item in expected: + if res_item['name'] == exp_item['name']: + assert res_item == exp_item diff --git a/example/urls.py b/example/urls.py index 72788060..9b882ce5 100644 --- a/example/urls.py +++ b/example/urls.py @@ -1,6 +1,11 @@ from django.conf import settings from django.conf.urls import include, url +from django.urls import path +from django.views.generic import TemplateView from rest_framework import routers +from rest_framework.schemas import get_schema_view + +from rest_framework_json_api.schemas.openapi import SchemaGenerator from example.views import ( AuthorRelationshipView, @@ -63,11 +68,21 @@ url(r'^authors/(?P[^/.]+)/relationships/(?P\w+)$', AuthorRelationshipView.as_view(), name='author-relationships'), + path('openapi', get_schema_view( + title="Example API", + description="API for all things …", + version="1.0.0", + generator_class=SchemaGenerator + ), name='openapi-schema'), + path('swagger-ui/', TemplateView.as_view( + template_name='swagger-ui.html', + extra_context={'schema_url': 'openapi-schema'} + ), name='swagger-ui'), ] - if settings.DEBUG: import debug_toolbar + urlpatterns = [ url(r'^__debug__/', include(debug_toolbar.urls)), ] + urlpatterns diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index ac092e33..95f36814 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,2 +1,4 @@ django-filter==2.4.0 django-polymorphic==3.0.0 +pyyaml==5.3 +uritemplate==3.0.1 diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index 29acfa5c..814a79f3 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -122,3 +122,17 @@ def get_filterset_kwargs(self, request, queryset, view): 'request': request, 'filter_keys': filter_keys, } + + def get_schema_operation_parameters(self, view): + """ + Convert backend filter `name` to JSON:API-style `filter[name]`. + For filters that are relationship paths, rewrite ORM-style `__` to our preferred `.`. + For example: `blog__name__contains` becomes `filter[blog.name.contains]`. + + This is basically the reverse of `get_filterset_kwargs` above. + """ + result = super(DjangoFilterBackend, self).get_schema_operation_parameters(view) + for res in result: + if 'name' in res: + res['name'] = 'filter[{}]'.format(res['name']).replace('__', '.') + return result diff --git a/rest_framework_json_api/schemas/__init__.py b/rest_framework_json_api/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py new file mode 100644 index 00000000..fe6b095e --- /dev/null +++ b/rest_framework_json_api/schemas/openapi.py @@ -0,0 +1,818 @@ +import warnings +from urllib.parse import urljoin + +from django.utils.module_loading import import_string as import_class_from_dotted_path +from rest_framework.fields import empty +from rest_framework.relations import ManyRelatedField +from rest_framework.schemas import openapi as drf_openapi +from rest_framework.schemas.utils import is_list_view + +from rest_framework_json_api import serializers, views + + +class SchemaGenerator(drf_openapi.SchemaGenerator): + """ + Extend DRF's SchemaGenerator to implement jsonapi-flavored generateschema command. + """ + #: These JSONAPI component definitions are referenced by the generated OAS schema. + #: If you need to add more or change these static component definitions, extend this dict. + jsonapi_components = { + 'schemas': { + 'jsonapi': { + 'type': 'object', + 'description': "The server's implementation", + 'properties': { + 'version': {'type': 'string'}, + 'meta': {'$ref': '#/components/schemas/meta'} + }, + 'additionalProperties': False + }, + 'resource': { + 'type': 'object', + 'required': ['type', 'id'], + 'additionalProperties': False, + 'properties': { + 'type': { + '$ref': '#/components/schemas/type' + }, + 'id': { + '$ref': '#/components/schemas/id' + }, + 'attributes': { + 'type': 'object', + # ... + }, + 'relationships': { + 'type': 'object', + # ... + }, + 'links': { + '$ref': '#/components/schemas/links' + }, + 'meta': {'$ref': '#/components/schemas/meta'}, + } + }, + 'link': { + 'oneOf': [ + { + 'description': "a string containing the link's URL", + 'type': 'string', + 'format': 'uri-reference' + }, + { + 'type': 'object', + 'required': ['href'], + 'properties': { + 'href': { + 'description': "a string containing the link's URL", + 'type': 'string', + 'format': 'uri-reference' + }, + 'meta': {'$ref': '#/components/schemas/meta'} + } + } + ] + }, + 'links': { + 'type': 'object', + 'additionalProperties': {'$ref': '#/components/schemas/link'} + }, + 'reltoone': { + 'description': "a singular 'to-one' relationship", + 'type': 'object', + 'properties': { + 'links': {'$ref': '#/components/schemas/relationshipLinks'}, + 'data': {'$ref': '#/components/schemas/relationshipToOne'}, + 'meta': {'$ref': '#/components/schemas/meta'} + } + }, + 'relationshipToOne': { + 'description': "reference to other resource in a to-one relationship", + 'anyOf': [ + {'$ref': '#/components/schemas/nulltype'}, + {'$ref': '#/components/schemas/linkage'} + ], + }, + 'reltomany': { + 'description': "a multiple 'to-many' relationship", + 'type': 'object', + 'properties': { + 'links': {'$ref': '#/components/schemas/relationshipLinks'}, + 'data': {'$ref': '#/components/schemas/relationshipToMany'}, + 'meta': {'$ref': '#/components/schemas/meta'} + } + }, + 'relationshipLinks': { + 'description': 'optional references to other resource objects', + 'type': 'object', + 'additionalProperties': True, + 'properties': { + 'self': {'$ref': '#/components/schemas/link'}, + 'related': {'$ref': '#/components/schemas/link'} + } + }, + 'relationshipToMany': { + 'description': "An array of objects each containing the " + "'type' and 'id' for to-many relationships", + 'type': 'array', + 'items': {'$ref': '#/components/schemas/linkage'}, + 'uniqueItems': True + }, + # A RelationshipView uses a ResourceIdentifierObjectSerializer (hence the name + # ResourceIdentifierObject returned by get_component_name()) which serializes type and + # id. These can be lists or individual items depending on whether the relationship is + # toMany or toOne so offer both options since we are not iterating over all the + # possible {related_field}'s but rather rendering one path schema which may represent + # toMany and toOne relationships. + 'ResourceIdentifierObject': { + 'oneOf': [ + {'$ref': '#/components/schemas/relationshipToOne'}, + {'$ref': '#/components/schemas/relationshipToMany'} + ] + }, + 'linkage': { + 'type': 'object', + 'description': "the 'type' and 'id'", + 'required': ['type', 'id'], + 'properties': { + 'type': {'$ref': '#/components/schemas/type'}, + 'id': {'$ref': '#/components/schemas/id'}, + 'meta': {'$ref': '#/components/schemas/meta'} + } + }, + 'pagination': { + 'type': 'object', + 'properties': { + 'first': {'$ref': '#/components/schemas/pageref'}, + 'last': {'$ref': '#/components/schemas/pageref'}, + 'prev': {'$ref': '#/components/schemas/pageref'}, + 'next': {'$ref': '#/components/schemas/pageref'}, + } + }, + 'pageref': { + 'oneOf': [ + {'type': 'string', 'format': 'uri-reference'}, + {'$ref': '#/components/schemas/nulltype'} + ] + }, + 'failure': { + 'type': 'object', + 'required': ['errors'], + 'properties': { + 'errors': {'$ref': '#/components/schemas/errors'}, + 'meta': {'$ref': '#/components/schemas/meta'}, + 'jsonapi': {'$ref': '#/components/schemas/jsonapi'}, + 'links': {'$ref': '#/components/schemas/links'} + } + }, + 'errors': { + 'type': 'array', + 'items': {'$ref': '#/components/schemas/error'}, + 'uniqueItems': True + }, + 'error': { + 'type': 'object', + 'additionalProperties': False, + 'properties': { + 'id': {'type': 'string'}, + 'status': {'type': 'string'}, + 'links': {'$ref': '#/components/schemas/links'}, + 'code': {'type': 'string'}, + 'title': {'type': 'string'}, + 'detail': {'type': 'string'}, + 'source': { + 'type': 'object', + 'properties': { + 'pointer': { + 'type': 'string', + 'description': + "A [JSON Pointer](https://tools.ietf.org/html/rfc6901) " + "to the associated entity in the request document " + "[e.g. `/data` for a primary data object, or " + "`/data/attributes/title` for a specific attribute." + }, + 'parameter': { + 'type': 'string', + 'description': + "A string indicating which query parameter " + "caused the error." + }, + 'meta': {'$ref': '#/components/schemas/meta'} + } + } + } + }, + 'onlymeta': { + 'additionalProperties': False, + 'properties': { + 'meta': {'$ref': '#/components/schemas/meta'} + } + }, + 'meta': { + 'type': 'object', + 'additionalProperties': True + }, + 'datum': { + 'description': 'singular item', + 'properties': { + 'data': {'$ref': '#/components/schemas/resource'} + } + }, + 'nulltype': { + 'type': 'object', + 'nullable': True, + 'default': None + }, + 'type': { + 'type': 'string', + 'description': + 'The [type]' + '(https://jsonapi.org/format/#document-resource-object-identification) ' + 'member is used to describe resource objects that share common attributes ' + 'and relationships.' + }, + 'id': { + 'type': 'string', + 'description': + "Each resource object’s type and id pair MUST " + "[identify]" + "(https://jsonapi.org/format/#document-resource-object-identification) " + "a single, unique resource." + }, + }, + 'parameters': { + 'include': { + 'name': 'include', + 'in': 'query', + 'description': '[list of included related resources]' + '(https://jsonapi.org/format/#fetching-includes)', + 'required': False, + 'style': 'form', + 'schema': { + 'type': 'string' + } + }, + # TODO: deepObject not well defined/supported: + # https://github.com/OAI/OpenAPI-Specification/issues/1706 + 'fields': { + 'name': 'fields', + 'in': 'query', + 'description': '[sparse fieldsets]' + '(https://jsonapi.org/format/#fetching-sparse-fieldsets).\n' + 'Use fields[\\]=field1,field2,...,fieldN', + 'required': False, + 'style': 'deepObject', + 'schema': { + 'type': 'object', + }, + 'explode': True + }, + 'sort': { + 'name': 'sort', + 'in': 'query', + 'description': '[list of fields to sort by]' + '(https://jsonapi.org/format/#fetching-sorting)', + 'required': False, + 'style': 'form', + 'schema': { + 'type': 'string' + } + }, + }, + } + + def get_schema(self, request=None, public=False): + """ + Generate a JSONAPI OpenAPI schema. + Overrides upstream DRF's get_schema. + """ + # TODO: avoid copying so much of upstream get_schema() + schema = super().get_schema(request, public) + + components_schemas = {} + + # Iterate endpoints generating per method path operations. + paths = {} + _, view_endpoints = self._get_paths_and_endpoints(None if public else request) + + #: `expanded_endpoints` is like view_endpoints with one extra field tacked on: + #: - 'action' copy of current view.action (list/fetch) as this gets reset for each request. + expanded_endpoints = [] + for path, method, view in view_endpoints: + if hasattr(view, 'action') and view.action == 'retrieve_related': + expanded_endpoints += self._expand_related(path, method, view, view_endpoints) + else: + expanded_endpoints.append((path, method, view, getattr(view, 'action', None))) + + for path, method, view, action in expanded_endpoints: + if not self.has_view_permissions(path, method, view): + continue + # kludge to preserve view.action as it is 'list' for the parent ViewSet + # but the related viewset that was expanded may be either 'fetch' (to_one) or 'list' + # (to_many). This patches the view.action appropriately so that + # view.schema.get_operation() "does the right thing" for fetch vs. list. + current_action = None + if hasattr(view, 'action'): + current_action = view.action + view.action = action + operation = view.schema.get_operation(path, method) + components = view.schema.get_components(path, method) + for k in components.keys(): + if k not in components_schemas: + continue + if components_schemas[k] == components[k]: + continue + warnings.warn( + 'Schema component "{}" has been overriden with a different value.'.format(k)) + + components_schemas.update(components) + + if hasattr(view, 'action'): + view.action = current_action + # Normalise path for any provided mount url. + if path.startswith('/'): + path = path[1:] + path = urljoin(self.url or '/', path) + + paths.setdefault(path, {}) + paths[path][method.lower()] = operation + + self.check_duplicate_operation_id(paths) + + # Compile final schema, overriding stuff from super class. + schema['paths'] = paths + schema['components'] = self.jsonapi_components + schema['components']['schemas'].update(components_schemas) + + return schema + + def _expand_related(self, path, method, view, view_endpoints): + """ + Expand path containing .../{id}/{related_field} into list of related fields + and **their** views, making sure toOne relationship's views are a 'fetch' and toMany + relationship's are a 'list'. + :param path + :param method + :param view + :param view_endpoints + :return:list[tuple(path, method, view, action)] + """ + result = [] + serializer = view.get_serializer() + # It's not obvious if it's allowed to have both included_ and related_ serializers, + # so just merge both dicts. + serializers = {} + if hasattr(serializer, 'included_serializers'): + serializers = {**serializers, **serializer.included_serializers} + if hasattr(serializer, 'related_serializers'): + serializers = {**serializers, **serializer.related_serializers} + related_fields = [fs for fs in serializers.items()] + + for field, related_serializer in related_fields: + related_view = self._find_related_view(view_endpoints, related_serializer, view) + if related_view: + action = self._field_is_one_or_many(field, view) + result.append( + (path.replace('{related_field}', field), method, related_view, action) + ) + + return result + + def _find_related_view(self, view_endpoints, related_serializer, parent_view): + """ + For a given related_serializer, try to find it's "parent" view instance in view_endpoints. + :param view_endpoints: list of all view endpoints + :param related_serializer: the related serializer for a given related field + :param parent_view: the parent view (used to find toMany vs. toOne). + TODO: not actually used. + :return:view + """ + for path, method, view in view_endpoints: + view_serializer = view.get_serializer() + if not isinstance(related_serializer, type): + related_serializer_class = import_class_from_dotted_path(related_serializer) + else: + related_serializer_class = related_serializer + if isinstance(view_serializer, related_serializer_class): + return view + + return None + + def _field_is_one_or_many(self, field, view): + serializer = view.get_serializer() + if isinstance(serializer.fields[field], ManyRelatedField): + return 'list' + else: + return 'fetch' + + +class AutoSchema(drf_openapi.AutoSchema): + """ + Extend DRF's openapi.AutoSchema for JSONAPI serialization. + """ + #: ignore all the media types and only generate a JSONAPI schema. + content_types = ['application/vnd.api+json'] + + def get_operation(self, path, method): + """ + JSONAPI adds some standard fields to the API response that are not in upstream DRF: + - some that only apply to GET/HEAD methods. + - collections + - special handling for POST, PATCH, DELETE + """ + operation = {} + operation['operationId'] = self.get_operation_id(path, method) + operation['description'] = self.get_description(path, method) + + parameters = [] + parameters += self.get_path_parameters(path, method) + # pagination, filters only apply to GET/HEAD of collections and items + if method in ['GET', 'HEAD']: + parameters += self._get_include_parameters(path, method) + parameters += self._get_fields_parameters(path, method) + parameters += self._get_sort_parameters(path, method) + parameters += self.get_pagination_parameters(path, method) + parameters += self.get_filter_parameters(path, method) + operation['parameters'] = parameters + + # get request and response code schemas + if method == 'GET': + if is_list_view(path, method, self.view): + self._add_get_collection_response(operation) + else: + self._add_get_item_response(operation) + elif method == 'POST': + self._add_post_item_response(operation, path) + elif method == 'PATCH': + self._add_patch_item_response(operation, path) + elif method == 'DELETE': + # should only allow deleting a resource, not a collection + # TODO: implement delete of a relationship in future release. + self._add_delete_item_response(operation, path) + return operation + + def get_operation_id(self, path, method): + """ + The upstream DRF version creates non-unique operationIDs, because the same view is + used for the main path as well as such as related and relationships. + This concatenates the (mapped) method name and path as the spec allows most any + """ + method_name = getattr(self.view, 'action', method.lower()) + if is_list_view(path, method, self.view): + action = 'List' + elif method_name not in self.method_mapping: + action = method_name + else: + action = self.method_mapping[method.lower()] + return action + path + + def _get_include_parameters(self, path, method): + """ + includes parameter: https://jsonapi.org/format/#fetching-includes + """ + return [{'$ref': '#/components/parameters/include'}] + + def _get_fields_parameters(self, path, method): + """ + sparse fieldsets https://jsonapi.org/format/#fetching-sparse-fieldsets + """ + # TODO: See if able to identify the specific types for fields[type]=... and return this: + # name: fields + # in: query + # description: '[sparse fieldsets](https://jsonapi.org/format/#fetching-sparse-fieldsets)' + # required: true + # style: deepObject + # schema: + # type: object + # properties: + # hello: + # type: string # noqa F821 + # world: + # type: string # noqa F821 + # explode: true + return [{'$ref': '#/components/parameters/fields'}] + + def _get_sort_parameters(self, path, method): + """ + sort parameter: https://jsonapi.org/format/#fetching-sorting + """ + return [{'$ref': '#/components/parameters/sort'}] + + def _add_get_collection_response(self, operation): + """ + Add GET 200 response for a collection to operation + """ + operation['responses'] = { + '200': self._get_toplevel_200_response(operation, collection=True) + } + self._add_get_4xx_responses(operation) + + def _add_get_item_response(self, operation): + """ + add GET 200 response for an item to operation + """ + operation['responses'] = { + '200': self._get_toplevel_200_response(operation, collection=False) + } + self._add_get_4xx_responses(operation) + + def _get_toplevel_200_response(self, operation, collection=True): + """ + return top-level JSONAPI GET 200 response + + :param collection: True for collections; False for individual items. + + Uses a $ref to the components.schemas. component definition. + """ + if collection: + data = {'type': 'array', 'items': self._get_reference(self.view.get_serializer())} + else: + data = self._get_reference(self.view.get_serializer()) + + return { + 'description': operation['operationId'], + 'content': { + 'application/vnd.api+json': { + 'schema': { + 'type': 'object', + 'required': ['data'], + 'properties': { + 'data': data, + 'included': { + 'type': 'array', + 'uniqueItems': True, + 'items': { + '$ref': '#/components/schemas/resource' + } + }, + 'links': { + 'description': 'Link members related to primary data', + 'allOf': [ + {'$ref': '#/components/schemas/links'}, + {'$ref': '#/components/schemas/pagination'} + ] + }, + 'jsonapi': { + '$ref': '#/components/schemas/jsonapi' + } + } + } + } + } + } + + def _add_post_item_response(self, operation, path): + """ + add response for POST of an item to operation + """ + operation['requestBody'] = self.get_request_body(path, 'POST') + operation['responses'] = { + '201': self._get_toplevel_200_response(operation, collection=False) + } + operation['responses']['201']['description'] = ( + '[Created](https://jsonapi.org/format/#crud-creating-responses-201). ' + 'Assigned `id` and/or any other changes are in this response.' + ) + self._add_async_response(operation) + operation['responses']['204'] = { + 'description': '[Created](https://jsonapi.org/format/#crud-creating-responses-204) ' + 'with the supplied `id`. No other changes from what was POSTed.' + } + self._add_post_4xx_responses(operation) + + def _add_patch_item_response(self, operation, path): + """ + Add PATCH response for an item to operation + """ + operation['requestBody'] = self.get_request_body(path, 'PATCH') + operation['responses'] = { + '200': self._get_toplevel_200_response(operation, collection=False) + } + self._add_patch_4xx_responses(operation) + + def _add_delete_item_response(self, operation, path): + """ + add DELETE response for item or relationship(s) to operation + """ + # Only DELETE of relationships has a requestBody + if isinstance(self.view, views.RelationshipView): + operation['requestBody'] = self.get_request_body(path, 'DELETE') + self._add_delete_responses(operation) + + def get_request_body(self, path, method): + """ + A request body is required by jsonapi for POST, PATCH, and DELETE methods. + """ + serializer = self.get_serializer(path, method) + if not isinstance(serializer, (serializers.BaseSerializer, )): + return {} + is_relationship = isinstance(self.view, views.RelationshipView) + + # DRF uses a $ref to the component schema definition, but this + # doesn't work for jsonapi due to the different required fields based on + # the method, so make those changes and inline another copy of the schema. + # TODO: A future improvement could make this DRYer with multiple component schemas: + # A base schema for each viewset that has no required fields + # One subclassed from the base that requires some fields (`type` but not `id` for POST) + # Another subclassed from base with required type/id but no required attributes (PATCH) + + if is_relationship: + item_schema = {'$ref': '#/components/schemas/ResourceIdentifierObject'} + else: + item_schema = self.map_serializer(serializer) + if method == 'POST': + # 'type' and 'id' are both required for: + # - all relationship operations + # - regular PATCH or DELETE + # Only 'type' is required for POST: system may assign the 'id'. + item_schema['required'] = ['type'] + + if 'properties' in item_schema and 'attributes' in item_schema['properties']: + # No required attributes for PATCH + if method in ['PATCH', 'PUT'] and 'required' in item_schema['properties']['attributes']: + del item_schema['properties']['attributes']['required'] + # No read_only fields for request. + for name, schema in item_schema['properties']['attributes']['properties'].copy().items(): # noqa E501 + if 'readOnly' in schema: + del item_schema['properties']['attributes']['properties'][name] + return { + 'content': { + ct: { + 'schema': { + 'required': ['data'], + 'properties': { + 'data': item_schema + } + } + } + for ct in self.content_types + } + } + + def map_serializer(self, serializer): + """ + Custom map_serializer that serializes the schema using the jsonapi spec. + Non-attributes like related and identity fields, are move to 'relationships' and 'links'. + """ + # TODO: remove attributes, etc. for relationshipView?? + required = [] + attributes = {} + relationships = {} + + for field in serializer.fields.values(): + if isinstance(field, serializers.HyperlinkedIdentityField): + # the 'url' is not an attribute but rather a self.link, so don't map it here. + continue + if isinstance(field, serializers.HiddenField): + continue + if isinstance(field, serializers.RelatedField): + relationships[field.field_name] = {'$ref': '#/components/schemas/reltoone'} + continue + if isinstance(field, serializers.ManyRelatedField): + relationships[field.field_name] = {'$ref': '#/components/schemas/reltomany'} + continue + + if field.required: + required.append(field.field_name) + + schema = self.map_field(field) + if field.read_only: + schema['readOnly'] = True + if field.write_only: + schema['writeOnly'] = True + if field.allow_null: + schema['nullable'] = True + if field.default and field.default != empty: + schema['default'] = field.default + if field.help_text: + # Ensure django gettext_lazy is rendered correctly + schema['description'] = str(field.help_text) + self.map_field_validators(field, schema) + + attributes[field.field_name] = schema + + result = { + 'type': 'object', + 'required': ['type', 'id'], + 'additionalProperties': False, + 'properties': { + 'type': {'$ref': '#/components/schemas/type'}, + 'id': {'$ref': '#/components/schemas/id'}, + 'links': { + 'type': 'object', + 'properties': { + 'self': {'$ref': '#/components/schemas/link'} + } + } + } + } + if attributes: + result['properties']['attributes'] = { + 'type': 'object', + 'properties': attributes + } + if required: + result['properties']['attributes']['required'] = required + + if relationships: + result['properties']['relationships'] = { + 'type': 'object', + 'properties': relationships + } + return result + + def _add_async_response(self, operation): + """ + Add async response to operation + """ + operation['responses']['202'] = { + 'description': 'Accepted for [asynchronous processing]' + '(https://jsonapi.org/recommendations/#asynchronous-processing)', + 'content': { + 'application/vnd.api+json': { + 'schema': {'$ref': '#/components/schemas/datum'} + } + } + } + + def _failure_response(self, reason): + """ + Return failure response reason as the description + """ + return { + 'description': reason, + 'content': { + 'application/vnd.api+json': { + 'schema': {'$ref': '#/components/schemas/failure'} + } + } + } + + def _add_generic_failure_responses(self, operation): + """ + Add generic failure response(s) to operation + """ + for code, reason in [('401', 'not authorized'), ]: + operation['responses'][code] = self._failure_response(reason) + + def _add_get_4xx_responses(self, operation): + """ + Add generic 4xx GET responses to operation + """ + self._add_generic_failure_responses(operation) + for code, reason in [('404', 'not found')]: + operation['responses'][code] = self._failure_response(reason) + + def _add_post_4xx_responses(self, operation): + """ + Add POST 4xx error responses to operation + """ + self._add_generic_failure_responses(operation) + for code, reason in [ + ('403', '[Forbidden](https://jsonapi.org/format/#crud-creating-responses-403)'), + ('404', '[Related resource does not exist]' + '(https://jsonapi.org/format/#crud-creating-responses-404)'), + ('409', '[Conflict](https://jsonapi.org/format/#crud-creating-responses-409)'), + ]: + operation['responses'][code] = self._failure_response(reason) + + def _add_patch_4xx_responses(self, operation): + """ + Add PATCH 4xx error responses to operation + """ + self._add_generic_failure_responses(operation) + for code, reason in [ + ('403', '[Forbidden](https://jsonapi.org/format/#crud-updating-responses-403)'), + ('404', '[Related resource does not exist]' + '(https://jsonapi.org/format/#crud-updating-responses-404)'), + ('409', '[Conflict]([Conflict]' + '(https://jsonapi.org/format/#crud-updating-responses-409)'), + ]: + operation['responses'][code] = self._failure_response(reason) + + def _add_delete_responses(self, operation): + """ + Add generic DELETE responses to operation + """ + # the 2xx statuses: + operation['responses'] = { + '200': { + 'description': '[OK](https://jsonapi.org/format/#crud-deleting-responses-200)', + 'content': { + 'application/vnd.api+json': { + 'schema': {'$ref': '#/components/schemas/onlymeta'} + } + } + } + } + self._add_async_response(operation) + operation['responses']['204'] = { + 'description': '[no content](https://jsonapi.org/format/#crud-deleting-responses-204)', + } + # the 4xx errors: + self._add_generic_failure_responses(operation) + for code, reason in [ + ('404', '[Resource does not exist]' + '(https://jsonapi.org/format/#crud-deleting-responses-404)'), + ]: + operation['responses'][code] = self._failure_response(reason) diff --git a/setup.py b/setup.py index f6d7c292..3bf1c728 100755 --- a/setup.py +++ b/setup.py @@ -96,7 +96,8 @@ def get_package_data(package): ], extras_require={ 'django-polymorphic': ['django-polymorphic>=2.0'], - 'django-filter': ['django-filter>=2.0'] + 'django-filter': ['django-filter>=2.0'], + 'openapi': ['pyyaml>=5.3', 'uritemplate>=3.0.1'] }, setup_requires=wheel, python_requires=">=3.6", From 1ee45f6cd968950da27ec3c2f6d2d5d21fb871e5 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 30 Oct 2020 18:29:01 +0100 Subject: [PATCH 024/252] Move to official major Python 3.9 in Travis (#847) --- .travis.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index f5ad4aa7..05feb314 100644 --- a/.travis.yml +++ b/.travis.yml @@ -63,19 +63,20 @@ matrix: - python: 3.8 env: TOXENV=py38-django31-drfmaster - - python: 3.9-dev + - python: 3.9 env: TOXENV=py39-django22-drf312 - - python: 3.9-dev + - python: 3.9 env: TOXENV=py39-django22-drfmaster - - python: 3.9-dev + - python: 3.9 env: TOXENV=py39-django30-drf312 - - python: 3.9-dev + - python: 3.9 env: TOXENV=py39-django30-drfmaster - - python: 3.9-dev + - python: 3.9 env: TOXENV=py39-django31-drf312 - - python: 3.9-dev + - python: 3.9 env: TOXENV=py39-django31-drfmaster + install: - pip install tox script: From 200bf249769d26463937004fa1522ce40c574396 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 30 Oct 2020 20:18:31 +0100 Subject: [PATCH 025/252] Release version 4.0.0 (#849) --- CHANGELOG.md | 19 ++++++++++--------- rest_framework_json_api/__init__.py | 2 +- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 957d5334..99629181 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,16 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [4.0.0] - 2020-10-31 This release is not backwards compatible. For easy migration best upgrade first to version 3.2.0 and resolve all deprecation warnings before updating to 4.0.0 ### Added -* Added support for Django REST framework 3.12 -* Added support for Django 3.1 -* Added support for Python 3.9 +* Added support for Django REST framework 3.12. +* Added support for Django 3.1. +* Added support for Python 3.9. * Added initial optional support for [openapi](https://www.openapis.org/) schema generation. Enable with: ``` pip install djangorestframework-jsonapi['openapi'] @@ -32,17 +32,18 @@ This release is not backwards compatible. For easy migration best upgrade first * Removed support for Python 3.5. * Removed support for Django 1.11. * Removed support for Django 2.1. -* Removed support for Django REST framework 3.10, 3.11 -* Removed obsolete `source` argument of `SerializerMethodResourceRelatedField` -* Removed obsolete setting `JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE` to render nested serializers as relationships. Default is as attribute now. +* Removed support for Django REST framework 3.10 and 3.11. +* Removed obsolete `source` argument of `SerializerMethodResourceRelatedField`. +* Removed obsolete setting `JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE` to render nested serializers as relationships. + Default is render as attribute now. ### Fixed -* Stopped `SparseFieldsetsMixin` interpretting invalid fields query parameter (e.g. invalidfields[entries]=blog,headline) +* Stopped `SparseFieldsetsMixin` interpretting invalid fields query parameter (e.g. invalidfields[entries]=blog,headline). ## [3.2.0] - 2020-08-26 -This is the last release supporting Django 1.11, Django 2.1, DRF 3.10, DRF 3.11 and Python 3.5. +This is the last release supporting Django 1.11, Django 2.1, Django REST Framework 3.10, Django REST Framework 3.11 and Python 3.5. ### Added diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index 28a440ce..f059f020 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- __title__ = 'djangorestframework-jsonapi' -__version__ = '3.2.0' +__version__ = '4.0.0' __author__ = '' __license__ = 'BSD' __copyright__ = '' From 3eaa033c773ebb648b6ffe5fff195daf5a771335 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 30 Oct 2020 21:09:44 +0100 Subject: [PATCH 026/252] Move to Travis CI com (#850) Co-authored-by: Alan Crosswell --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 31e4ba2a..d5d5959f 100644 --- a/README.rst +++ b/README.rst @@ -2,8 +2,8 @@ JSON API and Django Rest Framework ================================== -.. image:: https://travis-ci.org/django-json-api/django-rest-framework-json-api.svg?branch=develop - :target: https://travis-ci.org/django-json-api/django-rest-framework-json-api +.. image:: https://travis-ci.com/django-json-api/django-rest-framework-json-api.svg?branch=master + :target: https://travis-ci.com/django-json-api/django-rest-framework-json-api .. image:: https://readthedocs.org/projects/django-rest-framework-json-api/badge/?version=latest :alt: Read the docs From a67a521327c9ec62a18a875abf13504a72d8eaf0 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 3 Nov 2020 17:44:11 +0200 Subject: [PATCH 027/252] Scheduled biweekly dependency update for week 44 (#852) * Update sphinx from 3.2.1 to 3.3.0 * Update pyyaml from 5.3 to 5.3.1 * Update pytest from 6.1.1 to 6.1.2 * Update pytest-django from 4.0.0 to 4.1.0 --- requirements/requirements-documentation.txt | 2 +- requirements/requirements-optionals.txt | 2 +- requirements/requirements-testing.txt | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index f3cb3cac..e8378b09 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.6.0 -Sphinx==3.2.1 +Sphinx==3.3.0 sphinx_rtd_theme==0.5.0 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 95f36814..e5ceb15f 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,4 +1,4 @@ django-filter==2.4.0 django-polymorphic==3.0.0 -pyyaml==5.3 +pyyaml==5.3.1 uritemplate==3.0.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index e854996e..9508b0a3 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,8 +1,8 @@ django-debug-toolbar==3.1.1 factory-boy==3.1.0 Faker==4.14.0 -pytest==6.1.1 +pytest==6.1.2 pytest-cov==2.10.1 -pytest-django==4.0.0 +pytest-django==4.1.0 pytest-factoryboy==2.0.3 snapshottest==0.6.0 From 56ef6f3f6837d2517e6fdad25a9eb142a8d59586 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 6 Nov 2020 23:21:51 +0100 Subject: [PATCH 028/252] Create tests module (#855) Module used to migrate test from example --- pytest.ini | 5 ----- setup.cfg | 9 +++++++++ tests/__init__.py | 0 tests/conftest.py | 21 +++++++++++++++++++++ 4 files changed, 30 insertions(+), 5 deletions(-) delete mode 100644 pytest.ini create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 2c69372d..00000000 --- a/pytest.ini +++ /dev/null @@ -1,5 +0,0 @@ -[pytest] -DJANGO_SETTINGS_MODULE=example.settings.test -filterwarnings = - error::DeprecationWarning - error::PendingDeprecationWarning diff --git a/setup.cfg b/setup.cfg index d8247c1d..4f483e02 100644 --- a/setup.cfg +++ b/setup.cfg @@ -50,3 +50,12 @@ exclude_lines = def __str__ def __unicode__ def __repr__ + +[tool:pytest] +DJANGO_SETTINGS_MODULE=example.settings.test +filterwarnings = + error::DeprecationWarning + error::PendingDeprecationWarning +testpaths = + example + tests diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..4942bf63 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,21 @@ +import pytest + + +@pytest.fixture(autouse=True) +def use_rest_framework_json_api_defaults(settings): + """ + Enfroce default settings for tests modules. + + + As for now example and tests modules share the same settings file + some defaults which have been overwritten in the example app need + to be overwritten. This way testing actually happens on default resp. + each test defines what non default setting it wants to test. + + Once migration to tests module is finished and tests can have + its own settings file, this fixture can be removed. + """ + + settings.JSON_API_FORMAT_FIELD_NAMES = False + settings.JSON_API_FORMAT_TYPES = False + settings.JSON_API_PLURALIZE_TYPES = False From 69a720a87f3bdbabb3ce0591a45d0c9b44ab9125 Mon Sep 17 00:00:00 2001 From: David Guillot Date: Mon, 9 Nov 2020 14:48:19 +0100 Subject: [PATCH 029/252] Add apply includes option in BrowsableAPI custom BrowsableAPI to add choice of JSON:API includes --- AUTHORS | 1 + CHANGELOG.md | 7 +++ README.rst | 2 +- docs/usage.md | 2 +- example/settings/dev.py | 2 +- .../tests/integration/test_browsable_api.py | 29 +++++++++++ rest_framework_json_api/renderers.py | 51 +++++++++++++++++++ .../rest_framework_json_api/api.html | 19 +++++++ .../rest_framework_json_api/includes.html | 40 +++++++++++++++ 9 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 example/tests/integration/test_browsable_api.py create mode 100644 rest_framework_json_api/templates/rest_framework_json_api/api.html create mode 100644 rest_framework_json_api/templates/rest_framework_json_api/includes.html diff --git a/AUTHORS b/AUTHORS index b876d4ae..b440df81 100644 --- a/AUTHORS +++ b/AUTHORS @@ -6,6 +6,7 @@ Beni Keller Boris Pleshakov Charlie Allatson Christian Zosel +David Guillot, for Contexte David Vogt Felix Viernickel Greg Aker diff --git a/CHANGELOG.md b/CHANGELOG.md index 99629181..12b3e805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## [Unreleased] - TBD + +### Added + +* Ability for the user to select `included_serializers` to apply when using `BrowsableAPI`, based on available `included_serializers` defined for the current endpoint. + + ## [4.0.0] - 2020-10-31 This release is not backwards compatible. For easy migration best upgrade first to version diff --git a/README.rst b/README.rst index d5d5959f..7ffbcc0e 100644 --- a/README.rst +++ b/README.rst @@ -184,7 +184,7 @@ override ``settings.REST_FRAMEWORK`` ), 'DEFAULT_RENDERER_CLASSES': ( 'rest_framework_json_api.renderers.JSONRenderer', - 'rest_framework.renderers.BrowsableAPIRenderer', + 'rest_framework_json_api.renderers.BrowsableAPIRenderer', ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_FILTER_BACKENDS': ( diff --git a/docs/usage.md b/docs/usage.md index c091cd4d..afc8a7ac 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -28,7 +28,7 @@ REST_FRAMEWORK = { # If performance testing, enable: # 'example.utils.BrowsableAPIRendererWithoutForms', # Otherwise, to play around with the browseable API, enable: - 'rest_framework.renderers.BrowsableAPIRenderer' + 'rest_framework_json_api.renderers.BrowsableAPIRenderer' ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_SCHEMA_CLASS': 'rest_framework_json_api.schemas.openapi.AutoSchema', diff --git a/example/settings/dev.py b/example/settings/dev.py index 961807e3..d0e19fd2 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -86,7 +86,7 @@ # If performance testing, enable: # 'example.utils.BrowsableAPIRendererWithoutForms', # Otherwise, to play around with the browseable API, enable: - 'rest_framework.renderers.BrowsableAPIRenderer', + 'rest_framework_json_api.renderers.BrowsableAPIRenderer', ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 'DEFAULT_SCHEMA_CLASS': 'rest_framework_json_api.schemas.openapi.AutoSchema', diff --git a/example/tests/integration/test_browsable_api.py b/example/tests/integration/test_browsable_api.py new file mode 100644 index 00000000..d4bc3fbb --- /dev/null +++ b/example/tests/integration/test_browsable_api.py @@ -0,0 +1,29 @@ +import re + +import pytest +from django.urls import reverse + +pytestmark = pytest.mark.django_db + + +def test_browsable_api_with_included_serializers(single_entry, client): + response = client.get( + reverse( + "entry-detail", + kwargs={'pk': single_entry.pk, 'format': 'api'} + ) + ) + content = str(response.content) + assert response.status_code == 200 + assert re.search(r'JSON:API includes', content) + assert re.search( + r']* value="authors.bio"', + content + ) + + +def test_browsable_api_with_no_included_serializers(client): + response = client.get(reverse("projecttype-list", kwargs={'format': 'api'})) + content = str(response.content) + assert response.status_code == 200 + assert not re.search(r'JSON:API includes', content) diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index b6f216be..a9b8dd82 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -7,6 +7,7 @@ import inflection from django.db.models import Manager +from django.template import loader from django.utils import encoding from rest_framework import relations, renderers from rest_framework.fields import SkipField, get_attribute @@ -606,3 +607,53 @@ def render(self, data, accepted_media_type=None, renderer_context=None): return super(JSONRenderer, self).render( render_data, accepted_media_type, renderer_context ) + + +class BrowsableAPIRenderer(renderers.BrowsableAPIRenderer): + template = 'rest_framework_json_api/api.html' + includes_template = 'rest_framework_json_api/includes.html' + + def get_context(self, data, accepted_media_type, renderer_context): + context = super(BrowsableAPIRenderer, self).get_context( + data, accepted_media_type, renderer_context + ) + view = renderer_context['view'] + + context['includes_form'] = self.get_includes_form(view) + + return context + + @classmethod + def _get_included_serializers(cls, serializer, prefix='', already_seen=None): + if not already_seen: + already_seen = set() + + if serializer in already_seen: + return [] + + included_serializers = [] + already_seen.add(serializer) + + for include, included_serializer in utils.get_included_serializers(serializer).items(): + included_serializers.append(f'{prefix}{include}') + included_serializers.extend( + cls._get_included_serializers( + included_serializer, f'{prefix}{include}.', + already_seen=already_seen + ) + ) + + return included_serializers + + def get_includes_form(self, view): + try: + serializer_class = view.get_serializer_class() + except AttributeError: + return + + if not hasattr(serializer_class, 'included_serializers'): + return + + template = loader.get_template(self.includes_template) + context = {'elements': self._get_included_serializers(serializer_class)} + return template.render(context) diff --git a/rest_framework_json_api/templates/rest_framework_json_api/api.html b/rest_framework_json_api/templates/rest_framework_json_api/api.html new file mode 100644 index 00000000..a9f7fae1 --- /dev/null +++ b/rest_framework_json_api/templates/rest_framework_json_api/api.html @@ -0,0 +1,19 @@ +{% extends "rest_framework/base.html" %} +{% load i18n %} + +{% block request_forms %} + {{ block.super }} + {% if includes_form %} + + {% endif %} +{% endblock request_forms %} + +{% block script %} + {{ block.super }} + {% if includes_form %} + {{ includes_form }} + {% endif %} +{% endblock script %} diff --git a/rest_framework_json_api/templates/rest_framework_json_api/includes.html b/rest_framework_json_api/templates/rest_framework_json_api/includes.html new file mode 100644 index 00000000..a14a9285 --- /dev/null +++ b/rest_framework_json_api/templates/rest_framework_json_api/includes.html @@ -0,0 +1,40 @@ +{% load i18n %} + + + From c392a439510ef05e6b9f9554b59cb3577fd86610 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 10 Nov 2020 02:10:34 +0100 Subject: [PATCH 030/252] Convert test_utils to pytest styled tests (#856) Added generic models as by example of DRF. Those are not example models as before but generic in representing different constellations possible with Django models. --- example/tests/test_utils.py | 40 ------ example/tests/unit/test_utils.py | 129 ----------------- tests/models.py | 37 +++++ tests/test_utils.py | 240 +++++++++++++++++++++++++++++++ 4 files changed, 277 insertions(+), 169 deletions(-) delete mode 100644 example/tests/test_utils.py delete mode 100644 example/tests/unit/test_utils.py create mode 100644 tests/models.py create mode 100644 tests/test_utils.py diff --git a/example/tests/test_utils.py b/example/tests/test_utils.py deleted file mode 100644 index 4fff3225..00000000 --- a/example/tests/test_utils.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -Test rest_framework_json_api's utils functions. -""" -from rest_framework_json_api import utils - -from example.serializers import AuthorSerializer, EntrySerializer -from example.tests import TestBase - - -class GetRelatedResourceTests(TestBase): - """ - Ensure the `get_related_resource_type` function returns correct types. - """ - - def test_reverse_relation(self): - """ - Ensure reverse foreign keys have their types identified correctly. - """ - serializer = EntrySerializer() - field = serializer.fields['comments'] - - self.assertEqual(utils.get_related_resource_type(field), 'comments') - - def test_m2m_relation(self): - """ - Ensure m2ms have their types identified correctly. - """ - serializer = EntrySerializer() - field = serializer.fields['authors'] - - self.assertEqual(utils.get_related_resource_type(field), 'authors') - - def test_m2m_reverse_relation(self): - """ - Ensure reverse m2ms have their types identified correctly. - """ - serializer = AuthorSerializer() - field = serializer.fields['entries'] - - self.assertEqual(utils.get_related_resource_type(field), 'entries') diff --git a/example/tests/unit/test_utils.py b/example/tests/unit/test_utils.py deleted file mode 100644 index f2822f2b..00000000 --- a/example/tests/unit/test_utils.py +++ /dev/null @@ -1,129 +0,0 @@ -import pytest -from django.contrib.auth import get_user_model -from django.test import override_settings -from rest_framework import serializers -from rest_framework.generics import GenericAPIView -from rest_framework.response import Response -from rest_framework.views import APIView - -from rest_framework_json_api import utils -from rest_framework_json_api.utils import get_included_serializers - -from example.serializers import AuthorSerializer, BlogSerializer, CommentSerializer, EntrySerializer - -pytestmark = pytest.mark.django_db - - -class NonModelResourceSerializer(serializers.Serializer): - class Meta: - resource_name = 'users' - - -class ResourceSerializer(serializers.ModelSerializer): - class Meta: - fields = ('username',) - model = get_user_model() - - -def test_get_resource_name(): - view = APIView() - context = {'view': view} - with override_settings(JSON_API_FORMAT_TYPES=None): - assert 'APIViews' == utils.get_resource_name(context), 'not formatted' - - context = {'view': view} - with override_settings(JSON_API_FORMAT_TYPES='dasherize'): - assert 'api-views' == utils.get_resource_name(context), 'derived from view' - - view.model = get_user_model() - assert 'users' == utils.get_resource_name(context), 'derived from view model' - - view.resource_name = 'custom' - assert 'custom' == utils.get_resource_name(context), 'manually set on view' - - view.response = Response(status=403) - assert 'errors' == utils.get_resource_name(context), 'handles 4xx error' - - view.response = Response(status=500) - assert 'errors' == utils.get_resource_name(context), 'handles 500 error' - - view = GenericAPIView() - view.serializer_class = ResourceSerializer - context = {'view': view} - assert 'users' == utils.get_resource_name(context), 'derived from serializer' - - view.serializer_class.Meta.resource_name = 'rcustom' - assert 'rcustom' == utils.get_resource_name(context), 'set on serializer' - - view = GenericAPIView() - view.serializer_class = NonModelResourceSerializer - context = {'view': view} - assert 'users' == utils.get_resource_name(context), 'derived from non-model serializer' - - -@pytest.mark.parametrize("format_type,output", [ - ('camelize', {'fullName': {'last-name': 'a', 'first-name': 'b'}}), - ('capitalize', {'FullName': {'last-name': 'a', 'first-name': 'b'}}), - ('dasherize', {'full-name': {'last-name': 'a', 'first-name': 'b'}}), - ('underscore', {'full_name': {'last-name': 'a', 'first-name': 'b'}}), -]) -def test_format_field_names(settings, format_type, output): - settings.JSON_API_FORMAT_FIELD_NAMES = format_type - - value = {'full_name': {'last-name': 'a', 'first-name': 'b'}} - assert utils.format_field_names(value, format_type) == output - - -def test_format_value(): - assert utils.format_value('first_name', 'camelize') == 'firstName' - assert utils.format_value('first_name', 'capitalize') == 'FirstName' - assert utils.format_value('first_name', 'dasherize') == 'first-name' - assert utils.format_value('first-name', 'underscore') == 'first_name' - - -def test_format_resource_type(): - assert utils.format_resource_type('first_name', 'capitalize') == 'FirstNames' - assert utils.format_resource_type('first_name', 'camelize') == 'firstNames' - - -class SerializerWithIncludedSerializers(EntrySerializer): - included_serializers = { - 'blog': BlogSerializer, - 'authors': 'example.serializers.AuthorSerializer', - 'comments': 'example.serializers.CommentSerializer', - 'self': 'self' # this wouldn't make sense in practice (and would be prohibited by - # IncludedResourcesValidationMixin) but it's useful for the test - } - - -def test_get_included_serializers_against_class(): - klass = SerializerWithIncludedSerializers - included_serializers = get_included_serializers(klass) - expected_included_serializers = { - 'blog': BlogSerializer, - 'authors': AuthorSerializer, - 'comments': CommentSerializer, - 'self': klass - } - assert included_serializers.keys() == klass.included_serializers.keys(), ( - 'the keys must be preserved' - ) - - assert included_serializers == expected_included_serializers - - -def test_get_included_serializers_against_instance(): - klass = SerializerWithIncludedSerializers - instance = klass() - included_serializers = get_included_serializers(instance) - expected_included_serializers = { - 'blog': BlogSerializer, - 'authors': AuthorSerializer, - 'comments': CommentSerializer, - 'self': klass - } - assert included_serializers.keys() == klass.included_serializers.keys(), ( - 'the keys must be preserved' - ) - - assert included_serializers == expected_included_serializers diff --git a/tests/models.py b/tests/models.py new file mode 100644 index 00000000..e91911ce --- /dev/null +++ b/tests/models.py @@ -0,0 +1,37 @@ +from django.db import models + + +class DJAModel(models.Model): + """ + Base for test models that sets app_label, so they play nicely. + """ + + class Meta: + app_label = 'tests' + abstract = True + + +class BasicModel(DJAModel): + text = models.CharField(max_length=100) + + +# Models for relations tests +# ManyToMany +class ManyToManyTarget(DJAModel): + name = models.CharField(max_length=100) + + +class ManyToManySource(DJAModel): + name = models.CharField(max_length=100) + targets = models.ManyToManyField(ManyToManyTarget, related_name='sources') + + +# ForeignKey +class ForeignKeyTarget(DJAModel): + name = models.CharField(max_length=100) + + +class ForeignKeySource(DJAModel): + name = models.CharField(max_length=100) + target = models.ForeignKey(ForeignKeyTarget, related_name='sources', + on_delete=models.CASCADE) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..8f272e1a --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,240 @@ +import pytest +from django.db import models +from rest_framework import status +from rest_framework.generics import GenericAPIView +from rest_framework.response import Response +from rest_framework.views import APIView + +from rest_framework_json_api import serializers +from rest_framework_json_api.utils import ( + format_field_names, + format_resource_type, + format_value, + get_included_serializers, + get_related_resource_type, + get_resource_name +) +from tests.models import ( + BasicModel, + DJAModel, + ForeignKeySource, + ForeignKeyTarget, + ManyToManySource, + ManyToManyTarget +) + + +def test_get_resource_name_no_view(): + assert get_resource_name({}) is None + + +@pytest.mark.parametrize("format_type,pluralize_type,output", [ + (False, False, 'APIView'), + (False, True, 'APIViews'), + ('dasherize', False, 'api-view'), + ('dasherize', True, 'api-views'), +]) +def test_get_resource_name_from_view(settings, format_type, pluralize_type, output): + settings.JSON_API_FORMAT_TYPES = format_type + settings.JSON_API_PLURALIZE_TYPES = pluralize_type + + view = APIView() + context = {'view': view} + assert output == get_resource_name(context) + + +@pytest.mark.parametrize("format_type,pluralize_type", [ + (False, False), + (False, True), + ('dasherize', False), + ('dasherize', True), +]) +def test_get_resource_name_from_view_custom_resource_name(settings, format_type, pluralize_type): + settings.JSON_API_FORMAT_TYPES = format_type + settings.JSON_API_PLURALIZE_TYPES = pluralize_type + + view = APIView() + view.resource_name = 'custom' + context = {'view': view} + assert 'custom' == get_resource_name(context) + + +@pytest.mark.parametrize("format_type,pluralize_type,output", [ + (False, False, 'BasicModel'), + (False, True, 'BasicModels'), + ('dasherize', False, 'basic-model'), + ('dasherize', True, 'basic-models'), +]) +def test_get_resource_name_from_model(settings, format_type, pluralize_type, output): + settings.JSON_API_FORMAT_TYPES = format_type + settings.JSON_API_PLURALIZE_TYPES = pluralize_type + + view = APIView() + view.model = BasicModel + context = {'view': view} + assert output == get_resource_name(context) + + +@pytest.mark.parametrize("format_type,pluralize_type,output", [ + (False, False, 'BasicModel'), + (False, True, 'BasicModels'), + ('dasherize', False, 'basic-model'), + ('dasherize', True, 'basic-models'), +]) +def test_get_resource_name_from_model_serializer_class(settings, format_type, + pluralize_type, output): + class BasicModelSerializer(serializers.ModelSerializer): + class Meta: + fields = ('text',) + model = BasicModel + + settings.JSON_API_FORMAT_TYPES = format_type + settings.JSON_API_PLURALIZE_TYPES = pluralize_type + + view = GenericAPIView() + view.serializer_class = BasicModelSerializer + context = {'view': view} + assert output == get_resource_name(context) + + +@pytest.mark.parametrize("format_type,pluralize_type", [ + (False, False), + (False, True), + ('dasherize', False), + ('dasherize', True), +]) +def test_get_resource_name_from_model_serializer_class_custom_resource_name(settings, + format_type, + pluralize_type): + class BasicModelSerializer(serializers.ModelSerializer): + class Meta: + fields = ('text',) + model = BasicModel + + settings.JSON_API_FORMAT_TYPES = format_type + settings.JSON_API_PLURALIZE_TYPES = pluralize_type + + view = GenericAPIView() + view.serializer_class = BasicModelSerializer + view.serializer_class.Meta.resource_name = 'custom' + + context = {'view': view} + assert 'custom' == get_resource_name(context) + + +@pytest.mark.parametrize("format_type,pluralize_type", [ + (False, False), + (False, True), + ('dasherize', False), + ('dasherize', True), +]) +def test_get_resource_name_from_plain_serializer_class(settings, format_type, pluralize_type): + class PlainSerializer(serializers.Serializer): + class Meta: + resource_name = 'custom' + + settings.JSON_API_FORMAT_TYPES = format_type + settings.JSON_API_PLURALIZE_TYPES = pluralize_type + + view = GenericAPIView() + view.serializer_class = PlainSerializer + + context = {'view': view} + assert 'custom' == get_resource_name(context) + + +@pytest.mark.parametrize("status_code", [ + status.HTTP_400_BAD_REQUEST, + status.HTTP_403_FORBIDDEN, + status.HTTP_500_INTERNAL_SERVER_ERROR +]) +def test_get_resource_name_with_errors(status_code): + view = APIView() + context = {'view': view} + view.response = Response(status=status_code) + assert 'errors' == get_resource_name(context) + + +@pytest.mark.parametrize("format_type,output", [ + ('camelize', {'fullName': {'last-name': 'a', 'first-name': 'b'}}), + ('capitalize', {'FullName': {'last-name': 'a', 'first-name': 'b'}}), + ('dasherize', {'full-name': {'last-name': 'a', 'first-name': 'b'}}), + ('underscore', {'full_name': {'last-name': 'a', 'first-name': 'b'}}), +]) +def test_format_field_names(settings, format_type, output): + settings.JSON_API_FORMAT_FIELD_NAMES = format_type + + value = {'full_name': {'last-name': 'a', 'first-name': 'b'}} + assert format_field_names(value, format_type) == output + + +@pytest.mark.parametrize("format_type,output", [ + (None, 'first_name'), + ('camelize', 'firstName'), + ('capitalize', 'FirstName'), + ('dasherize', 'first-name'), + ('underscore', 'first_name') +]) +def test_format_value(settings, format_type, output): + assert format_value('first_name', format_type) == output + + +@pytest.mark.parametrize("resource_type,pluralize,output", [ + (None, None, 'ResourceType'), + ('camelize', False, 'resourceType'), + ('camelize', True, 'resourceTypes'), +]) +def test_format_resource_type(settings, resource_type, pluralize, output): + assert format_resource_type('ResourceType', resource_type, pluralize) == output + + +@pytest.mark.parametrize('model_class,field,output', [ + (ManyToManySource, 'targets', 'ManyToManyTarget'), + (ManyToManyTarget, 'sources', 'ManyToManySource'), + (ForeignKeySource, 'target', 'ForeignKeyTarget'), + (ForeignKeyTarget, 'sources', 'ForeignKeySource'), +]) +def test_get_related_resource_type(model_class, field, output): + class RelatedResourceTypeSerializer(serializers.ModelSerializer): + class Meta: + model = model_class + fields = (field,) + + serializer = RelatedResourceTypeSerializer() + field = serializer.fields[field] + assert get_related_resource_type(field) == output + + +class ManyToManyTargetSerializer(serializers.ModelSerializer): + class Meta: + model = ManyToManyTarget + + +def test_get_included_serializers(): + class IncludedSerializersModel(DJAModel): + self = models.ForeignKey('self', on_delete=models.CASCADE) + target = models.ForeignKey(ManyToManyTarget, on_delete=models.CASCADE) + other_target = models.ForeignKey(ManyToManyTarget, on_delete=models.CASCADE) + + class Meta: + app_label = 'tests' + + class IncludedSerializersSerializer(serializers.ModelSerializer): + included_serializers = { + 'self': 'self', + 'target': ManyToManyTargetSerializer, + 'other_target': 'tests.test_utils.ManyToManyTargetSerializer' + } + + class Meta: + model = IncludedSerializersModel + fields = ('self', 'other_target', 'target') + + included_serializers = get_included_serializers(IncludedSerializersSerializer) + expected_included_serializers = { + 'self': IncludedSerializersSerializer, + 'target': ManyToManyTargetSerializer, + 'other_target': ManyToManyTargetSerializer + } + + assert included_serializers == expected_included_serializers From 1e2594f1272aa07775ea67667a8186b5bfa48187 Mon Sep 17 00:00:00 2001 From: Ulrich Schuster <62716187+uliSchuster@users.noreply.github.com> Date: Thu, 12 Nov 2020 00:54:02 +0100 Subject: [PATCH 031/252] Allow users to overwrite `get_serializer_class` while using related urls (#860) * This attempt to fix the issues breaks tests An attempt to fix #859, which unfortunately breaks two tests in test_views. * Removed misleading comment Tha basic method was copied over from the GenericAPIView. The comment here does not fit, though. * bein more explicit about the changes * Use correct serializer for rendering Ensure that the correct resource is returned during rendering, which in turn requires to use the correct serializer, depending on if it is a related resource or the parent resource. sliverc pointed out the issue here. * Add provision for tests * Describe the fix. * Fixed failing tests As pointed out by n2ygk, the schema names here must reflect the Serializer class names, which I changed in the two affected test cases. * Update CHANGELOG.md Clarified changelog entry * Fixed linting issues Co-authored-by: Ulrich Schuster Co-authored-by: Oliver Sauder --- AUTHORS | 1 + CHANGELOG.md | 4 ++++ example/serializers.py | 8 ++++++++ example/tests/snapshots/snap_test_openapi.py | 4 ++-- example/tests/test_views.py | 14 +++++++------- example/views.py | 13 ++++++++++++- rest_framework_json_api/utils.py | 5 ++++- rest_framework_json_api/views.py | 12 +++++++++--- 8 files changed, 47 insertions(+), 14 deletions(-) diff --git a/AUTHORS b/AUTHORS index b440df81..d4c03b2c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -33,4 +33,5 @@ Sergey Kolomenkin Stas S. Tim Selman Tom Glowka +Ulrich Schuster Yaniv Peer diff --git a/CHANGELOG.md b/CHANGELOG.md index 12b3e805..4613aa18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ any parts of the framework not mentioned in the documentation should generally b * Ability for the user to select `included_serializers` to apply when using `BrowsableAPI`, based on available `included_serializers` defined for the current endpoint. +### Fixed + +* Allow users to overwrite a view's `get_serializer_class()` method when using [related urls](https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#related-urls) + ## [4.0.0] - 2020-10-31 diff --git a/example/serializers.py b/example/serializers.py index 9dc84a4a..566f39d5 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -261,6 +261,14 @@ def get_first_entry(self, obj): return obj.entries.first() +class AuthorListSerializer(AuthorSerializer): + pass + + +class AuthorDetailSerializer(AuthorSerializer): + pass + + class WriterSerializer(serializers.ModelSerializer): included_serializers = { 'bio': AuthorBioSerializer diff --git a/example/tests/snapshots/snap_test_openapi.py b/example/tests/snapshots/snap_test_openapi.py index ec8da388..aca41d70 100644 --- a/example/tests/snapshots/snap_test_openapi.py +++ b/example/tests/snapshots/snap_test_openapi.py @@ -65,7 +65,7 @@ "properties": { "data": { "items": { - "$ref": "#/components/schemas/Author" + "$ref": "#/components/schemas/AuthorList" }, "type": "array" }, @@ -171,7 +171,7 @@ "schema": { "properties": { "data": { - "$ref": "#/components/schemas/Author" + "$ref": "#/components/schemas/AuthorDetail" }, "included": { "items": { diff --git a/example/tests/test_views.py b/example/tests/test_views.py index 9cd493f7..25eeca89 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -367,16 +367,16 @@ def test_get_related_instance_model_field(self): got = view.get_related_instance() self.assertEqual(got, self.author.id) - def test_get_serializer_class(self): + def test_get_related_serializer_class(self): kwargs = {'pk': self.author.id, 'related_field': 'bio'} view = self._get_view(kwargs) - got = view.get_serializer_class() + got = view.get_related_serializer_class() self.assertEqual(got, AuthorBioSerializer) - def test_get_serializer_class_many(self): + def test_get_related_serializer_class_many(self): kwargs = {'pk': self.author.id, 'related_field': 'entries'} view = self._get_view(kwargs) - got = view.get_serializer_class() + got = view.get_related_serializer_class() self.assertEqual(got, EntrySerializer) def test_get_serializer_comes_from_included_serializers(self): @@ -384,15 +384,15 @@ def test_get_serializer_comes_from_included_serializers(self): view = self._get_view(kwargs) related_serializers = view.serializer_class.related_serializers delattr(view.serializer_class, 'related_serializers') - got = view.get_serializer_class() + got = view.get_related_serializer_class() self.assertEqual(got, AuthorTypeSerializer) view.serializer_class.related_serializers = related_serializers - def test_get_serializer_class_raises_error(self): + def test_get_related_serializer_class_raises_error(self): kwargs = {'pk': self.author.id, 'related_field': 'unknown'} view = self._get_view(kwargs) - self.assertRaises(NotFound, view.get_serializer_class) + self.assertRaises(NotFound, view.get_related_serializer_class) def test_retrieve_related_single_reverse_lookup(self): url = reverse('author-related', kwargs={'pk': self.author.pk, 'related_field': 'bio'}) diff --git a/example/views.py b/example/views.py index 8c80d145..99a54193 100644 --- a/example/views.py +++ b/example/views.py @@ -15,6 +15,8 @@ from example.models import Author, Blog, Comment, Company, Entry, Project, ProjectType from example.serializers import ( + AuthorDetailSerializer, + AuthorListSerializer, AuthorSerializer, BlogDRFSerializer, BlogSerializer, @@ -185,7 +187,16 @@ class NoFiltersetEntryViewSet(EntryViewSet): class AuthorViewSet(ModelViewSet): queryset = Author.objects.all() - serializer_class = AuthorSerializer + serializer_classes = { + "list": AuthorListSerializer, + "retrieve": AuthorDetailSerializer} + serializer_class = AuthorSerializer # fallback + + def get_serializer_class(self): + try: + return self.serializer_classes.get(self.action, self.serializer_class) + except AttributeError: + return self.serializer_class class CommentViewSet(ModelViewSet): diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 2443575f..b99de91a 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -52,7 +52,10 @@ def get_resource_name(context, expand_polymorphic_types=False): resource_name = getattr(view, 'resource_name') except AttributeError: try: - serializer = view.get_serializer_class() + if 'kwargs' in context and 'related_field' in context['kwargs']: + serializer = view.get_related_serializer_class() + else: + serializer = view.get_serializer_class() if expand_polymorphic_types and issubclass(serializer, PolymorphicModelSerializer): return serializer.get_polymorphic_types() else: diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index 2e0b22ef..7c874e7a 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -144,10 +144,15 @@ def retrieve_related(self, request, *args, **kwargs): if isinstance(instance, Iterable): serializer_kwargs['many'] = True - serializer = self.get_serializer(instance, **serializer_kwargs) + serializer = self.get_related_serializer(instance, **serializer_kwargs) return Response(serializer.data) - def get_serializer_class(self): + def get_related_serializer(self, instance, **kwargs): + serializer_class = self.get_related_serializer_class() + kwargs.setdefault('context', self.get_serializer_context()) + return serializer_class(instance, **kwargs) + + def get_related_serializer_class(self): parent_serializer_class = super(RelatedMixin, self).get_serializer_class() if 'related_field' in self.kwargs: @@ -179,7 +184,8 @@ def get_related_field_name(self): def get_related_instance(self): parent_obj = self.get_object() - parent_serializer = self.serializer_class(parent_obj) + parent_serializer_class = self.get_serializer_class() + parent_serializer = parent_serializer_class(parent_obj) field_name = self.get_related_field_name() field = parent_serializer.fields.get(field_name, None) From 5c40b980e201fe3b7ee899c6d271574009504a25 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 13 Nov 2020 16:56:41 +0100 Subject: [PATCH 032/252] Add configuration to format with black --- .travis.yml | 2 ++ requirements/requirements-codestyle.txt | 1 + setup.cfg | 25 +++++++++++++++---------- tox.ini | 6 ++++++ 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/.travis.yml b/.travis.yml index 05feb314..65ea1cc8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,6 +19,8 @@ matrix: - env: TOXENV=py39-django31-drfmaster include: + - python: 3.6 + env: TOXENV=black - python: 3.6 env: TOXENV=lint - python: 3.6 diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index c144f975..cdb8b514 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,3 +1,4 @@ +black==20.8b1 flake8==3.8.4 flake8-isort==4.0.0 isort==5.6.4 diff --git a/setup.cfg b/setup.cfg index 4f483e02..07b279c8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,29 +5,34 @@ test = pytest universal = 1 [flake8] -ignore = F405,W504 -max-line-length = 100 +max-line-length = 88 +extend-ignore = + # whitespace before ':' - disabled as not PEP8 compliant + E203, + # line too long (managed by black) + E501, + # usage of star imports + # TODO mark star imports directly in code to ignore this error + F405 exclude = - snapshots build/lib, - docs/conf.py, - migrations, .eggs .tox, env .venv [isort] -indent = 4 +multi_line_output = 3 +include_trailing_comma = True +force_grid_wrap = 0 +use_parentheses = True +ensure_newline_before_comments = True +line_length = 88 known_first_party = rest_framework_json_api # This is to "trick" isort into putting example below DJA imports. known_localfolder = example -line_length = 100 -multi_line_output = 3 skip= build/lib, - docs/conf.py, - migrations, .eggs .tox, env diff --git a/tox.ini b/tox.ini index c0ebf760..dc808c01 100644 --- a/tox.ini +++ b/tox.ini @@ -20,6 +20,12 @@ setenv = commands = pytest --cov --no-cov-on-fail --cov-report xml {posargs} +[testenv:black] +basepython = python3.6 +deps = + -rrequirements/requirements-codestyle.txt +commands = black --check . + [testenv:lint] basepython = python3.6 deps = From 0146890ecf4ba62d242c7981fdf932c01a7c9f8f Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 13 Nov 2020 17:00:05 +0100 Subject: [PATCH 033/252] Format code with black --- docs/conf.py | 173 ++-- example/api/resources/identity.py | 23 +- example/api/serializers/identity.py | 15 +- example/api/serializers/post.py | 1 + example/factories.py | 8 +- example/migrations/0001_initial.py | 157 ++-- example/migrations/0002_taggeditem.py | 36 +- example/migrations/0003_polymorphics.py | 114 ++- example/migrations/0004_auto_20171011_0631.py | 64 +- example/migrations/0005_auto_20180922_1508.py | 46 +- example/migrations/0006_auto_20181228_0752.py | 45 +- .../migrations/0007_artproject_description.py | 6 +- example/migrations/0008_labresults.py | 29 +- example/models.py | 40 +- example/serializers.py | 257 +++--- example/settings/dev.py | 116 ++- example/settings/test.py | 20 +- example/tests/__init__.py | 16 +- example/tests/conftest.py | 6 +- .../tests/integration/test_browsable_api.py | 14 +- example/tests/integration/test_includes.py | 272 ++++--- example/tests/integration/test_meta.py | 62 +- .../integration/test_model_resource_name.py | 169 ++-- .../test_non_paginated_responses.py | 76 +- example/tests/integration/test_pagination.py | 66 +- .../tests/integration/test_polymorphism.py | 261 +++--- .../integration/test_sparse_fieldsets.py | 24 +- example/tests/snapshots/snap_test_errors.py | 123 ++- example/tests/snapshots/snap_test_openapi.py | 31 +- example/tests/test_errors.py | 180 ++--- example/tests/test_filters.py | 610 ++++++++------ example/tests/test_format_keys.py | 56 +- example/tests/test_generic_validation.py | 18 +- example/tests/test_generic_viewset.py | 112 +-- example/tests/test_model_viewsets.py | 203 ++--- example/tests/test_openapi.py | 84 +- example/tests/test_parsers.py | 87 +- example/tests/test_performance.py | 40 +- example/tests/test_relations.py | 199 +++-- example/tests/test_serializers.py | 149 ++-- example/tests/test_sideload_resources.py | 9 +- example/tests/test_views.py | 613 +++++++------- .../unit/test_default_drf_serializers.py | 127 ++- example/tests/unit/test_factories.py | 34 +- .../tests/unit/test_filter_schema_params.py | 107 ++- example/tests/unit/test_pagination.py | 62 +- .../tests/unit/test_renderer_class_methods.py | 92 +-- example/tests/unit/test_renderers.py | 94 +-- .../unit/test_serializer_method_field.py | 11 +- example/tests/unit/test_settings.py | 4 +- example/urls.py | 125 +-- example/urls_test.py | 129 +-- example/utils.py | 2 +- example/views.py | 96 ++- rest_framework_json_api/__init__.py | 10 +- .../django_filters/backends.py | 36 +- rest_framework_json_api/exceptions.py | 7 +- rest_framework_json_api/filters.py | 43 +- rest_framework_json_api/metadata.py | 160 ++-- rest_framework_json_api/pagination.py | 88 +- rest_framework_json_api/parsers.py | 98 ++- rest_framework_json_api/relations.py | 192 +++-- rest_framework_json_api/renderers.py | 376 +++++---- rest_framework_json_api/schemas/openapi.py | 760 +++++++++--------- rest_framework_json_api/serializers.py | 157 ++-- rest_framework_json_api/settings.py | 20 +- rest_framework_json_api/utils.py | 166 ++-- rest_framework_json_api/views.py | 128 +-- setup.py | 98 +-- tests/models.py | 9 +- tests/test_utils.py | 246 +++--- 71 files changed, 4453 insertions(+), 3654 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index ee435d36..0b8caa69 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,7 +15,6 @@ import datetime import os -import shlex import sys import django @@ -26,44 +25,44 @@ # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -sys.path.insert(0, os.path.abspath('..')) -os.environ['DJANGO_SETTINGS_MODULE'] = 'example.settings' +sys.path.insert(0, os.path.abspath("..")) +os.environ["DJANGO_SETTINGS_MODULE"] = "example.settings" django.setup() # Auto-generate API documentation. -main(['-o', 'apidoc', '-f', '-e', '-T', '-M', '../rest_framework_json_api']) +main(["-o", "apidoc", "-f", "-e", "-T", "-M", "../rest_framework_json_api"]) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ['sphinx.ext.autodoc', 'recommonmark'] -autodoc_member_order = 'bysource' +extensions = ["sphinx.ext.autodoc", "recommonmark"] +autodoc_member_order = "bysource" autodoc_inherit_docstrings = False # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # source_suffix = ['.rst', '.md'] -source_suffix = ['.rst', '.md'] +source_suffix = [".rst", ".md"] # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = 'Django REST Framework JSON API' +project = "Django REST Framework JSON API" year = datetime.date.today().year -copyright = '{}, Django REST Framework JSON API contributors'.format(year) -author = 'Django REST Framework JSON API contributors' +copyright = "{}, Django REST Framework JSON API contributors".format(year) +author = "Django REST Framework JSON API contributors" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -83,37 +82,37 @@ # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build', 'pull_request_template.md'] +exclude_patterns = ["_build", "pull_request_template.md"] # The reST default role (used for this markup: `text`) to use for all # documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'default' +pygments_style = "default" # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False +# keep_warnings = False # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False @@ -123,150 +122,153 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' +html_theme = "default" -on_rtd = os.environ.get('READTHEDOCS', None) == 'True' +on_rtd = os.environ.get("READTHEDOCS", None) == "True" if not on_rtd: # only import and set the theme if we're building docs locally import sphinx_rtd_theme - html_theme = 'sphinx_rtd_theme' + + html_theme = "sphinx_rtd_theme" html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. -#html_extra_path = [] +# html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Language to be used for generating the HTML full-text search index. # Sphinx supports the following languages: # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr' -#html_search_language = 'en' +# html_search_language = 'en' # A dictionary with options for the search language support, empty by default. # Now only 'ja' uses this config value -#html_search_options = {'type': 'default'} +# html_search_options = {'type': 'default'} # The name of a javascript file (relative to the configuration directory) that # implements a search results scorer. If empty, the default will be used. -#html_search_scorer = 'scorer.js' +# html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. -htmlhelp_basename = 'DjangoRESTFrameworkJSONAPIdoc' +htmlhelp_basename = "DjangoRESTFrameworkJSONAPIdoc" # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', - -# Latex figure (float) alignment -#'figure_align': 'htbp', + # The paper size ('letterpaper' or 'a4paper'). + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # 'preamble': '', + # Latex figure (float) alignment + # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'DjangoRESTFrameworkJSONAPI.tex', 'Django REST Framework JSON API Documentation', - 'Django REST Framework JSON API contributors', 'manual'), + ( + master_doc, + "DjangoRESTFrameworkJSONAPI.tex", + "Django REST Framework JSON API Documentation", + "Django REST Framework JSON API contributors", + "manual", + ), ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output --------------------------------------- @@ -274,12 +276,17 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - (master_doc, 'djangorestframeworkjsonapi', 'Django REST Framework JSON API Documentation', - [author], 1) + ( + master_doc, + "djangorestframeworkjsonapi", + "Django REST Framework JSON API Documentation", + [author], + 1, + ) ] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------- @@ -288,19 +295,25 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'DjangoRESTFrameworkJSONAPI', 'Django REST Framework JSON API Documentation', - author, 'DjangoRESTFrameworkJSONAPI', 'One line description of project.', - 'Miscellaneous'), + ( + master_doc, + "DjangoRESTFrameworkJSONAPI", + "Django REST Framework JSON API Documentation", + author, + "DjangoRESTFrameworkJSONAPI", + "One line description of project.", + "Miscellaneous", + ), ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +# texinfo_no_detailmenu = False diff --git a/example/api/resources/identity.py b/example/api/resources/identity.py index 6785e5d9..a291ba4f 100644 --- a/example/api/resources/identity.py +++ b/example/api/resources/identity.py @@ -11,7 +11,7 @@ class Identity(viewsets.ModelViewSet): - queryset = auth_models.User.objects.all().order_by('pk') + queryset = auth_models.User.objects.all().order_by("pk") serializer_class = IdentitySerializer # demonstrate sideloading data for use at app boot time @@ -20,22 +20,24 @@ def posts(self, request): self.resource_name = False identities = self.queryset - posts = [{'id': 1, 'title': 'Test Blog Post'}] + posts = [{"id": 1, "title": "Test Blog Post"}] data = { - encoding.force_str('identities'): IdentitySerializer(identities, many=True).data, - encoding.force_str('posts'): PostSerializer(posts, many=True).data, + encoding.force_str("identities"): IdentitySerializer( + identities, many=True + ).data, + encoding.force_str("posts"): PostSerializer(posts, many=True).data, } - return Response(utils.format_field_names(data, format_type='camelize')) + return Response(utils.format_field_names(data, format_type="camelize")) @action(detail=True) def manual_resource_name(self, request, *args, **kwargs): - self.resource_name = 'data' + self.resource_name = "data" return super(Identity, self).retrieve(request, args, kwargs) @action(detail=True) def validation(self, request, *args, **kwargs): - raise serializers.ValidationError('Oh nohs!') + raise serializers.ValidationError("Oh nohs!") class GenericIdentity(generics.GenericAPIView): @@ -44,10 +46,11 @@ class GenericIdentity(generics.GenericAPIView): GET /identities/generic """ + serializer_class = IdentitySerializer - allowed_methods = ['GET'] - renderer_classes = (renderers.JSONRenderer, ) - parser_classes = (parsers.JSONParser, ) + allowed_methods = ["GET"] + renderer_classes = (renderers.JSONRenderer,) + parser_classes = (parsers.JSONParser,) def get_queryset(self): return auth_models.User.objects.all() diff --git a/example/api/serializers/identity.py b/example/api/serializers/identity.py index 2538c6df..069259d2 100644 --- a/example/api/serializers/identity.py +++ b/example/api/serializers/identity.py @@ -9,17 +9,16 @@ class IdentitySerializer(serializers.ModelSerializer): def validate_first_name(self, data): if len(data) > 10: - raise serializers.ValidationError( - 'There\'s a problem with first name') + raise serializers.ValidationError("There's a problem with first name") return data def validate_last_name(self, data): if len(data) > 10: raise serializers.ValidationError( { - 'id': 'armageddon101', - 'detail': 'Hey! You need a last name!', - 'meta': 'something', + "id": "armageddon101", + "detail": "Hey! You need a last name!", + "meta": "something", } ) return data @@ -27,4 +26,8 @@ def validate_last_name(self, data): class Meta: model = auth_models.User fields = ( - 'id', 'first_name', 'last_name', 'email', ) + "id", + "first_name", + "last_name", + "email", + ) diff --git a/example/api/serializers/post.py b/example/api/serializers/post.py index bf0cf463..dbd78cfc 100644 --- a/example/api/serializers/post.py +++ b/example/api/serializers/post.py @@ -5,4 +5,5 @@ class PostSerializer(serializers.Serializer): """ Blog post serializer """ + title = serializers.CharField(max_length=50) diff --git a/example/factories.py b/example/factories.py index 0639a0dd..0de561f6 100644 --- a/example/factories.py +++ b/example/factories.py @@ -15,7 +15,7 @@ Entry, ProjectType, ResearchProject, - TaggedItem + TaggedItem, ) faker = FakerFactory.create() @@ -43,7 +43,7 @@ class Meta: name = factory.LazyAttribute(lambda x: faker.name()) email = factory.LazyAttribute(lambda x: faker.email()) - bio = factory.RelatedFactory('example.factories.AuthorBioFactory', 'author') + bio = factory.RelatedFactory("example.factories.AuthorBioFactory", "author") type = factory.SubFactory(AuthorTypeFactory) @@ -54,7 +54,9 @@ class Meta: author = factory.SubFactory(AuthorFactory) body = factory.LazyAttribute(lambda x: faker.text()) - metadata = factory.RelatedFactory('example.factories.AuthorBioMetadataFactory', 'bio') + metadata = factory.RelatedFactory( + "example.factories.AuthorBioMetadataFactory", "bio" + ) class AuthorBioMetadataFactory(factory.django.DjangoModelFactory): diff --git a/example/migrations/0001_initial.py b/example/migrations/0001_initial.py index 38cc0be9..35e01afe 100644 --- a/example/migrations/0001_initial.py +++ b/example/migrations/0001_initial.py @@ -2,93 +2,154 @@ # Generated by Django 1.9.5 on 2016-05-02 08:26 from __future__ import unicode_literals -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='Author', + name="Author", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=50)), - ('email', models.EmailField(max_length=254)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=50)), + ("email", models.EmailField(max_length=254)), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='AuthorBio', + name="AuthorBio", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('body', models.TextField()), - ('author', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='bio', to='example.Author')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("body", models.TextField()), + ( + "author", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="bio", + to="example.Author", + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='Blog', + name="Blog", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=100)), - ('tagline', models.TextField()), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=100)), + ("tagline", models.TextField()), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='Comment', + name="Comment", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('body', models.TextField()), - ('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='example.Author')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("body", models.TextField()), + ( + "author", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="example.Author", + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='Entry', + name="Entry", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('headline', models.CharField(max_length=255)), - ('body_text', models.TextField(null=True)), - ('pub_date', models.DateField(null=True)), - ('mod_date', models.DateField(null=True)), - ('n_comments', models.IntegerField(default=0)), - ('n_pingbacks', models.IntegerField(default=0)), - ('rating', models.IntegerField(default=0)), - ('authors', models.ManyToManyField(to='example.Author')), - ('blog', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='example.Blog')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("headline", models.CharField(max_length=255)), + ("body_text", models.TextField(null=True)), + ("pub_date", models.DateField(null=True)), + ("mod_date", models.DateField(null=True)), + ("n_comments", models.IntegerField(default=0)), + ("n_pingbacks", models.IntegerField(default=0)), + ("rating", models.IntegerField(default=0)), + ("authors", models.ManyToManyField(to="example.Author")), + ( + "blog", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="example.Blog" + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.AddField( - model_name='comment', - name='entry', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='example.Entry'), + model_name="comment", + name="entry", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="example.Entry" + ), ), ] diff --git a/example/migrations/0002_taggeditem.py b/example/migrations/0002_taggeditem.py index 46a79de9..3a57d22f 100644 --- a/example/migrations/0002_taggeditem.py +++ b/example/migrations/0002_taggeditem.py @@ -2,30 +2,44 @@ # Generated by Django 1.10.5 on 2017-02-01 08:34 from __future__ import unicode_literals -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), - ('example', '0001_initial'), + ("contenttypes", "0002_remove_content_type_name"), + ("example", "0001_initial"), ] operations = [ migrations.CreateModel( - name='TaggedItem', + name="TaggedItem", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('tag', models.SlugField()), - ('object_id', models.PositiveIntegerField()), - ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("tag", models.SlugField()), + ("object_id", models.PositiveIntegerField()), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.ContentType", + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), ] diff --git a/example/migrations/0003_polymorphics.py b/example/migrations/0003_polymorphics.py index 9020176b..46919dbb 100644 --- a/example/migrations/0003_polymorphics.py +++ b/example/migrations/0003_polymorphics.py @@ -2,75 +2,125 @@ # Generated by Django 1.11.1 on 2017-05-17 14:49 from __future__ import unicode_literals -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), - ('example', '0002_taggeditem'), + ("contenttypes", "0002_remove_content_type_name"), + ("example", "0002_taggeditem"), ] operations = [ migrations.CreateModel( - name='Company', + name="Company", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), ], ), migrations.CreateModel( - name='Project', + name="Project", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('topic', models.CharField(max_length=30)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("topic", models.CharField(max_length=30)), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.AlterField( - model_name='comment', - name='entry', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='example.Entry'), + model_name="comment", + name="entry", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to="example.Entry", + ), ), migrations.CreateModel( - name='ArtProject', + name="ArtProject", fields=[ - ('project_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='example.Project')), - ('artist', models.CharField(max_length=30)), + ( + "project_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="example.Project", + ), + ), + ("artist", models.CharField(max_length=30)), ], options={ - 'abstract': False, + "abstract": False, }, - bases=('example.project',), + bases=("example.project",), ), migrations.CreateModel( - name='ResearchProject', + name="ResearchProject", fields=[ - ('project_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='example.Project')), - ('supervisor', models.CharField(max_length=30)), + ( + "project_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="example.Project", + ), + ), + ("supervisor", models.CharField(max_length=30)), ], options={ - 'abstract': False, + "abstract": False, }, - bases=('example.project',), + bases=("example.project",), ), migrations.AddField( - model_name='project', - name='polymorphic_ctype', - field=models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_example.project_set+', to='contenttypes.ContentType'), + model_name="project", + name="polymorphic_ctype", + field=models.ForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="polymorphic_example.project_set+", + to="contenttypes.ContentType", + ), ), migrations.AddField( - model_name='company', - name='current_project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='companies', to='example.Project'), + model_name="company", + name="current_project", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="companies", + to="example.Project", + ), ), migrations.AddField( - model_name='company', - name='future_projects', - field=models.ManyToManyField(to='example.Project'), + model_name="company", + name="future_projects", + field=models.ManyToManyField(to="example.Project"), ), ] diff --git a/example/migrations/0004_auto_20171011_0631.py b/example/migrations/0004_auto_20171011_0631.py index 96df2aa7..b1035dfe 100644 --- a/example/migrations/0004_auto_20171011_0631.py +++ b/example/migrations/0004_auto_20171011_0631.py @@ -2,61 +2,73 @@ # Generated by Django 1.11.6 on 2017-10-11 06:31 from __future__ import unicode_literals -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('example', '0003_polymorphics'), + ("example", "0003_polymorphics"), ] operations = [ migrations.CreateModel( - name='AuthorType', + name="AuthorType", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=50)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=50)), ], options={ - 'ordering': ('id',), + "ordering": ("id",), }, ), migrations.AlterModelOptions( - name='author', - options={'ordering': ('id',)}, + name="author", + options={"ordering": ("id",)}, ), migrations.AlterModelOptions( - name='authorbio', - options={'ordering': ('id',)}, + name="authorbio", + options={"ordering": ("id",)}, ), migrations.AlterModelOptions( - name='blog', - options={'ordering': ('id',)}, + name="blog", + options={"ordering": ("id",)}, ), migrations.AlterModelOptions( - name='comment', - options={'ordering': ('id',)}, + name="comment", + options={"ordering": ("id",)}, ), migrations.AlterModelOptions( - name='entry', - options={'ordering': ('id',)}, + name="entry", + options={"ordering": ("id",)}, ), migrations.AlterModelOptions( - name='taggeditem', - options={'ordering': ('id',)}, + name="taggeditem", + options={"ordering": ("id",)}, ), migrations.AlterField( - model_name='entry', - name='authors', - field=models.ManyToManyField(related_name='entries', to='example.Author'), + model_name="entry", + name="authors", + field=models.ManyToManyField(related_name="entries", to="example.Author"), ), migrations.AddField( - model_name='author', - name='type', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='example.AuthorType'), + model_name="author", + name="type", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="example.AuthorType", + ), ), ] diff --git a/example/migrations/0005_auto_20180922_1508.py b/example/migrations/0005_auto_20180922_1508.py index 99d397f6..58b2808d 100644 --- a/example/migrations/0005_auto_20180922_1508.py +++ b/example/migrations/0005_auto_20180922_1508.py @@ -1,43 +1,55 @@ # Generated by Django 2.1.1 on 2018-09-22 15:08 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('example', '0004_auto_20171011_0631'), + ("example", "0004_auto_20171011_0631"), ] operations = [ migrations.CreateModel( - name='ProjectType', + name="ProjectType", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=50)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=50)), ], options={ - 'ordering': ('id',), + "ordering": ("id",), }, ), migrations.AlterModelOptions( - name='artproject', - options={'base_manager_name': 'objects'}, + name="artproject", + options={"base_manager_name": "objects"}, ), migrations.AlterModelOptions( - name='project', - options={'base_manager_name': 'objects'}, + name="project", + options={"base_manager_name": "objects"}, ), migrations.AlterModelOptions( - name='researchproject', - options={'base_manager_name': 'objects'}, + name="researchproject", + options={"base_manager_name": "objects"}, ), migrations.AddField( - model_name='project', - name='project_type', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='example.ProjectType'), + model_name="project", + name="project_type", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="example.ProjectType", + ), ), ] diff --git a/example/migrations/0006_auto_20181228_0752.py b/example/migrations/0006_auto_20181228_0752.py index 2cfb0c29..4126c797 100644 --- a/example/migrations/0006_auto_20181228_0752.py +++ b/example/migrations/0006_auto_20181228_0752.py @@ -1,32 +1,53 @@ # Generated by Django 2.1.4 on 2018-12-28 07:52 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('example', '0005_auto_20180922_1508'), + ("example", "0005_auto_20180922_1508"), ] operations = [ migrations.CreateModel( - name='AuthorBioMetadata', + name="AuthorBioMetadata", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('modified_at', models.DateTimeField(auto_now=True)), - ('body', models.TextField()), - ('bio', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='metadata', to='example.AuthorBio')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("modified_at", models.DateTimeField(auto_now=True)), + ("body", models.TextField()), + ( + "bio", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="metadata", + to="example.AuthorBio", + ), + ), ], options={ - 'ordering': ('id',), + "ordering": ("id",), }, ), migrations.AlterField( - model_name='comment', - name='author', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='example.Author'), + model_name="comment", + name="author", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to="example.Author", + ), ), ] diff --git a/example/migrations/0007_artproject_description.py b/example/migrations/0007_artproject_description.py index 8dec0124..20f9d42e 100644 --- a/example/migrations/0007_artproject_description.py +++ b/example/migrations/0007_artproject_description.py @@ -6,13 +6,13 @@ class Migration(migrations.Migration): dependencies = [ - ('example', '0006_auto_20181228_0752'), + ("example", "0006_auto_20181228_0752"), ] operations = [ migrations.AddField( - model_name='artproject', - name='description', + model_name="artproject", + name="description", field=models.CharField(max_length=100, null=True), ), ] diff --git a/example/migrations/0008_labresults.py b/example/migrations/0008_labresults.py index 89323d77..e0a1d6ba 100644 --- a/example/migrations/0008_labresults.py +++ b/example/migrations/0008_labresults.py @@ -1,23 +1,38 @@ # Generated by Django 3.0.3 on 2020-02-06 10:24 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('example', '0007_artproject_description'), + ("example", "0007_artproject_description"), ] operations = [ migrations.CreateModel( - name='LabResults', + name="LabResults", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('date', models.DateField()), - ('measurements', models.TextField()), - ('research_project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lab_results', to='example.ResearchProject')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("date", models.DateField()), + ("measurements", models.TextField()), + ( + "research_project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="lab_results", + to="example.ResearchProject", + ), + ), ], ), ] diff --git a/example/models.py b/example/models.py index 4df4dc27..47537b57 100644 --- a/example/models.py +++ b/example/models.py @@ -11,6 +11,7 @@ class BaseModel(models.Model): """ I hear RoR has this by default, who doesn't need these two fields! """ + created_at = models.DateTimeField(auto_now_add=True) modified_at = models.DateTimeField(auto_now=True) @@ -22,13 +23,13 @@ class TaggedItem(BaseModel): tag = models.SlugField() content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.PositiveIntegerField() - content_object = GenericForeignKey('content_type', 'object_id') + content_object = GenericForeignKey("content_type", "object_id") def __str__(self): return self.tag class Meta: - ordering = ('id',) + ordering = ("id",) class Blog(BaseModel): @@ -40,7 +41,7 @@ def __str__(self): return self.name class Meta: - ordering = ('id',) + ordering = ("id",) class AuthorType(BaseModel): @@ -50,7 +51,7 @@ def __str__(self): return self.name class Meta: - ordering = ('id',) + ordering = ("id",) class Author(BaseModel): @@ -62,32 +63,35 @@ def __str__(self): return self.name class Meta: - ordering = ('id',) + ordering = ("id",) class AuthorBio(BaseModel): - author = models.OneToOneField(Author, related_name='bio', on_delete=models.CASCADE) + author = models.OneToOneField(Author, related_name="bio", on_delete=models.CASCADE) body = models.TextField() def __str__(self): return self.author.name class Meta: - ordering = ('id',) + ordering = ("id",) class AuthorBioMetadata(BaseModel): """ Just a class to have a relation with author bio """ - bio = models.OneToOneField(AuthorBio, related_name='metadata', on_delete=models.CASCADE) + + bio = models.OneToOneField( + AuthorBio, related_name="metadata", on_delete=models.CASCADE + ) body = models.TextField() def __str__(self): return self.bio.author.name class Meta: - ordering = ('id',) + ordering = ("id",) class Entry(BaseModel): @@ -96,7 +100,7 @@ class Entry(BaseModel): body_text = models.TextField(null=True) pub_date = models.DateField(null=True) mod_date = models.DateField(null=True) - authors = models.ManyToManyField(Author, related_name='entries') + authors = models.ManyToManyField(Author, related_name="entries") n_comments = models.IntegerField(default=0) n_pingbacks = models.IntegerField(default=0) rating = models.IntegerField(default=0) @@ -106,25 +110,25 @@ def __str__(self): return self.headline class Meta: - ordering = ('id',) + ordering = ("id",) class Comment(BaseModel): - entry = models.ForeignKey(Entry, related_name='comments', on_delete=models.CASCADE) + entry = models.ForeignKey(Entry, related_name="comments", on_delete=models.CASCADE) body = models.TextField() author = models.ForeignKey( Author, null=True, blank=True, on_delete=models.CASCADE, - related_name='comments', + related_name="comments", ) def __str__(self): return self.body class Meta: - ordering = ('id',) + ordering = ("id",) class ProjectType(BaseModel): @@ -134,7 +138,7 @@ def __str__(self): return self.name class Meta: - ordering = ('id',) + ordering = ("id",) class Project(PolymorphicModel): @@ -153,7 +157,8 @@ class ResearchProject(Project): class LabResults(models.Model): research_project = models.ForeignKey( - ResearchProject, related_name='lab_results', on_delete=models.CASCADE) + ResearchProject, related_name="lab_results", on_delete=models.CASCADE + ) date = models.DateField() measurements = models.TextField() @@ -161,7 +166,8 @@ class LabResults(models.Model): class Company(models.Model): name = models.CharField(max_length=100) current_project = models.ForeignKey( - Project, related_name='companies', on_delete=models.CASCADE) + Project, related_name="companies", on_delete=models.CASCADE + ) future_projects = models.ManyToManyField(Project) def __str__(self): diff --git a/example/serializers.py b/example/serializers.py index 566f39d5..7a923d4e 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -19,23 +19,24 @@ Project, ProjectType, ResearchProject, - TaggedItem + TaggedItem, ) class TaggedItemSerializer(serializers.ModelSerializer): class Meta: model = TaggedItem - fields = ('tag',) + fields = ("tag",) class TaggedItemDRFSerializer(drf_serilazers.ModelSerializer): """ DRF default serializer to test default DRF functionalities """ + class Meta: model = TaggedItem - fields = ('tag',) + fields = ("tag",) class BlogSerializer(serializers.ModelSerializer): @@ -43,28 +44,27 @@ class BlogSerializer(serializers.ModelSerializer): tags = relations.ResourceRelatedField(many=True, read_only=True) included_serializers = { - 'tags': 'example.serializers.TaggedItemSerializer', + "tags": "example.serializers.TaggedItemSerializer", } def get_copyright(self, resource): return datetime.now().year def get_root_meta(self, resource, many): - return { - 'api_docs': '/docs/api/blogs' - } + return {"api_docs": "/docs/api/blogs"} class Meta: model = Blog - fields = ('name', 'url', 'tags') - read_only_fields = ('tags',) - meta_fields = ('copyright',) + fields = ("name", "url", "tags") + read_only_fields = ("tags",) + meta_fields = ("copyright",) class BlogDRFSerializer(drf_serilazers.ModelSerializer): """ DRF default serializer to test default DRF functionalities """ + copyright = serializers.SerializerMethodField() tags = TaggedItemDRFSerializer(many=True, read_only=True) @@ -72,15 +72,13 @@ def get_copyright(self, resource): return datetime.now().year def get_root_meta(self, resource, many): - return { - 'api_docs': '/docs/api/blogs' - } + return {"api_docs": "/docs/api/blogs"} class Meta: model = Blog - fields = ('name', 'url', 'tags', 'copyright') - read_only_fields = ('tags',) - meta_fields = ('copyright',) + fields = ("name", "url", "tags", "copyright") + read_only_fields = ("tags",) + meta_fields = ("copyright",) class EntrySerializer(serializers.ModelSerializer): @@ -88,52 +86,51 @@ def __init__(self, *args, **kwargs): super(EntrySerializer, self).__init__(*args, **kwargs) # to make testing more concise we'll only output the # `featured` field when it's requested via `include` - request = kwargs.get('context', {}).get('request') - if request and 'featured' not in request.query_params.get('include', []): - self.fields.pop('featured', None) + request = kwargs.get("context", {}).get("request") + if request and "featured" not in request.query_params.get("include", []): + self.fields.pop("featured", None) included_serializers = { - 'authors': 'example.serializers.AuthorSerializer', - 'comments': 'example.serializers.CommentSerializer', - 'featured': 'example.serializers.EntrySerializer', - 'suggested': 'example.serializers.EntrySerializer', - 'tags': 'example.serializers.TaggedItemSerializer', + "authors": "example.serializers.AuthorSerializer", + "comments": "example.serializers.CommentSerializer", + "featured": "example.serializers.EntrySerializer", + "suggested": "example.serializers.EntrySerializer", + "tags": "example.serializers.TaggedItemSerializer", } body_format = serializers.SerializerMethodField() # single related from model blog_hyperlinked = relations.HyperlinkedRelatedField( - related_link_view_name='entry-blog', - related_link_url_kwarg='entry_pk', - self_link_view_name='entry-relationships', + related_link_view_name="entry-blog", + related_link_url_kwarg="entry_pk", + self_link_view_name="entry-relationships", read_only=True, - source='blog' + source="blog", ) # many related from model - comments = relations.ResourceRelatedField( - many=True, read_only=True) + comments = relations.ResourceRelatedField(many=True, read_only=True) # many related hyperlinked from model comments_hyperlinked = relations.HyperlinkedRelatedField( - related_link_view_name='entry-comments', - related_link_url_kwarg='entry_pk', - self_link_view_name='entry-relationships', + related_link_view_name="entry-comments", + related_link_url_kwarg="entry_pk", + self_link_view_name="entry-relationships", many=True, read_only=True, - source='comments' + source="comments", ) # many related from serializer suggested = relations.SerializerMethodResourceRelatedField( - related_link_view_name='entry-suggested', - related_link_url_kwarg='entry_pk', - self_link_view_name='entry-relationships', + related_link_view_name="entry-suggested", + related_link_url_kwarg="entry_pk", + self_link_view_name="entry-relationships", model=Entry, many=True, ) # many related hyperlinked from serializer suggested_hyperlinked = relations.SerializerMethodHyperlinkedRelatedField( - related_link_view_name='entry-suggested', - related_link_url_kwarg='entry_pk', - self_link_view_name='entry-relationships', + related_link_view_name="entry-suggested", + related_link_url_kwarg="entry_pk", + self_link_view_name="entry-relationships", model=Entry, many=True, ) @@ -141,11 +138,11 @@ def __init__(self, *args, **kwargs): featured = relations.SerializerMethodResourceRelatedField(model=Entry) # single related hyperlinked from serializer featured_hyperlinked = relations.SerializerMethodHyperlinkedRelatedField( - related_link_view_name='entry-featured', - related_link_url_kwarg='entry_pk', - self_link_view_name='entry-relationships', + related_link_view_name="entry-featured", + related_link_url_kwarg="entry_pk", + self_link_view_name="entry-relationships", model=Entry, - read_only=True + read_only=True, ) tags = relations.ResourceRelatedField(many=True, read_only=True) @@ -156,106 +153,126 @@ def get_featured(self, obj): return Entry.objects.exclude(pk=obj.pk).first() def get_body_format(self, obj): - return 'text' + return "text" class Meta: model = Entry - fields = ('blog', 'blog_hyperlinked', 'headline', 'body_text', 'pub_date', 'mod_date', - 'authors', 'comments', 'comments_hyperlinked', 'featured', 'suggested', - 'suggested_hyperlinked', 'tags', 'featured_hyperlinked') - read_only_fields = ('tags',) - meta_fields = ('body_format',) + fields = ( + "blog", + "blog_hyperlinked", + "headline", + "body_text", + "pub_date", + "mod_date", + "authors", + "comments", + "comments_hyperlinked", + "featured", + "suggested", + "suggested_hyperlinked", + "tags", + "featured_hyperlinked", + ) + read_only_fields = ("tags",) + meta_fields = ("body_format",) class JSONAPIMeta: - included_resources = ['comments'] + included_resources = ["comments"] class EntryDRFSerializers(drf_serilazers.ModelSerializer): tags = TaggedItemDRFSerializer(many=True, read_only=True) url = drf_serilazers.HyperlinkedIdentityField( - view_name='drf-entry-blog-detail', - lookup_url_kwarg='entry_pk', + view_name="drf-entry-blog-detail", + lookup_url_kwarg="entry_pk", read_only=True, ) class Meta: model = Entry - fields = ('tags', 'url',) - read_only_fields = ('tags',) + fields = ( + "tags", + "url", + ) + read_only_fields = ("tags",) class AuthorTypeSerializer(serializers.ModelSerializer): class Meta: model = AuthorType - fields = ('name', ) + fields = ("name",) class AuthorBioSerializer(serializers.ModelSerializer): class Meta: model = AuthorBio - fields = ('author', 'body', 'metadata') + fields = ("author", "body", "metadata") included_serializers = { - 'metadata': 'example.serializers.AuthorBioMetadataSerializer', + "metadata": "example.serializers.AuthorBioMetadataSerializer", } class AuthorBioMetadataSerializer(serializers.ModelSerializer): class Meta: model = AuthorBioMetadata - fields = ('body',) + fields = ("body",) class AuthorSerializer(serializers.ModelSerializer): bio = relations.ResourceRelatedField( - related_link_view_name='author-related', - self_link_view_name='author-relationships', + related_link_view_name="author-related", + self_link_view_name="author-relationships", queryset=AuthorBio.objects, ) entries = relations.ResourceRelatedField( - related_link_view_name='author-related', - self_link_view_name='author-relationships', + related_link_view_name="author-related", + self_link_view_name="author-relationships", queryset=Entry.objects, - many=True + many=True, ) first_entry = relations.SerializerMethodResourceRelatedField( - related_link_view_name='author-related', - self_link_view_name='author-relationships', + related_link_view_name="author-related", + self_link_view_name="author-relationships", model=Entry, ) comments = relations.HyperlinkedRelatedField( - related_link_view_name='author-related', - self_link_view_name='author-relationships', + related_link_view_name="author-related", + self_link_view_name="author-relationships", queryset=Comment.objects, - many=True - ) - secrets = serializers.HiddenField( - default='Shhhh!' + many=True, ) + secrets = serializers.HiddenField(default="Shhhh!") defaults = serializers.CharField( - default='default', + default="default", max_length=20, min_length=3, write_only=True, - help_text='help for defaults', + help_text="help for defaults", ) - included_serializers = { - 'bio': AuthorBioSerializer, - 'type': AuthorTypeSerializer - } + included_serializers = {"bio": AuthorBioSerializer, "type": AuthorTypeSerializer} related_serializers = { - 'bio': 'example.serializers.AuthorBioSerializer', - 'type': 'example.serializers.AuthorTypeSerializer', - 'comments': 'example.serializers.CommentSerializer', - 'entries': 'example.serializers.EntrySerializer', - 'first_entry': 'example.serializers.EntrySerializer' + "bio": "example.serializers.AuthorBioSerializer", + "type": "example.serializers.AuthorTypeSerializer", + "comments": "example.serializers.CommentSerializer", + "entries": "example.serializers.EntrySerializer", + "first_entry": "example.serializers.EntrySerializer", } class Meta: model = Author - fields = ('name', 'email', 'bio', 'entries', 'comments', 'first_entry', 'type', - 'secrets', 'defaults') + fields = ( + "name", + "email", + "bio", + "entries", + "comments", + "first_entry", + "type", + "secrets", + "defaults", + ) def get_first_entry(self, obj): return obj.entries.first() @@ -270,48 +287,52 @@ class AuthorDetailSerializer(AuthorSerializer): class WriterSerializer(serializers.ModelSerializer): - included_serializers = { - 'bio': AuthorBioSerializer - } + included_serializers = {"bio": AuthorBioSerializer} class Meta: model = Author - fields = ('name', 'email', 'bio') - resource_name = 'writers' + fields = ("name", "email", "bio") + resource_name = "writers" class CommentSerializer(serializers.ModelSerializer): # testing remapping of related name - writer = relations.ResourceRelatedField(source='author', read_only=True) + writer = relations.ResourceRelatedField(source="author", read_only=True) included_serializers = { - 'entry': EntrySerializer, - 'author': AuthorSerializer, - 'writer': WriterSerializer + "entry": EntrySerializer, + "author": AuthorSerializer, + "writer": WriterSerializer, } class Meta: model = Comment - exclude = ('created_at', 'modified_at',) + exclude = ( + "created_at", + "modified_at", + ) # fields = ('entry', 'body', 'author',) class ProjectTypeSerializer(serializers.ModelSerializer): class Meta: model = ProjectType - fields = ('name', 'url',) + fields = ( + "name", + "url", + ) class BaseProjectSerializer(serializers.ModelSerializer): included_serializers = { - 'project_type': ProjectTypeSerializer, + "project_type": ProjectTypeSerializer, } class ArtProjectSerializer(BaseProjectSerializer): class Meta: model = ArtProject - exclude = ('polymorphic_ctype',) + exclude = ("polymorphic_ctype",) class ResearchProjectSerializer(BaseProjectSerializer): @@ -320,37 +341,35 @@ class ResearchProjectSerializer(BaseProjectSerializer): class Meta: model = ResearchProject - exclude = ('polymorphic_ctype',) + exclude = ("polymorphic_ctype",) class LabResultsSerializer(serializers.ModelSerializer): class Meta: model = LabResults - fields = ('date', 'measurements') + fields = ("date", "measurements") class ProjectSerializer(serializers.PolymorphicModelSerializer): included_serializers = { - 'project_type': ProjectTypeSerializer, + "project_type": ProjectTypeSerializer, } polymorphic_serializers = [ArtProjectSerializer, ResearchProjectSerializer] class Meta: model = Project - exclude = ('polymorphic_ctype',) + exclude = ("polymorphic_ctype",) class CurrentProjectRelatedField(relations.PolymorphicResourceRelatedField): def get_attribute(self, instance): obj = super(CurrentProjectRelatedField, self).get_attribute(instance) - is_art = ( - self.field_name == 'current_art_project' and - isinstance(obj, ArtProject) + is_art = self.field_name == "current_art_project" and isinstance( + obj, ArtProject ) - is_res = ( - self.field_name == 'current_research_project' and - isinstance(obj, ResearchProject) + is_res = self.field_name == "current_research_project" and isinstance( + obj, ResearchProject ) if is_art or is_res: @@ -361,21 +380,25 @@ def get_attribute(self, instance): class CompanySerializer(serializers.ModelSerializer): current_project = relations.PolymorphicResourceRelatedField( - ProjectSerializer, queryset=Project.objects.all()) + ProjectSerializer, queryset=Project.objects.all() + ) current_art_project = CurrentProjectRelatedField( - ProjectSerializer, source='current_project', read_only=True) + ProjectSerializer, source="current_project", read_only=True + ) current_research_project = CurrentProjectRelatedField( - ProjectSerializer, source='current_project', read_only=True) + ProjectSerializer, source="current_project", read_only=True + ) future_projects = relations.PolymorphicResourceRelatedField( - ProjectSerializer, queryset=Project.objects.all(), many=True) + ProjectSerializer, queryset=Project.objects.all(), many=True + ) included_serializers = { - 'current_project': ProjectSerializer, - 'future_projects': ProjectSerializer, - 'current_art_project': ProjectSerializer, - 'current_research_project': ProjectSerializer + "current_project": ProjectSerializer, + "future_projects": ProjectSerializer, + "current_art_project": ProjectSerializer, + "current_research_project": ProjectSerializer, } class Meta: model = Company - fields = '__all__' + fields = "__all__" diff --git a/example/settings/dev.py b/example/settings/dev.py index d0e19fd2..2db2c259 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -4,100 +4,96 @@ DEBUG = True MEDIA_ROOT = os.path.normcase(os.path.dirname(os.path.abspath(__file__))) -MEDIA_URL = '/media/' +MEDIA_URL = "/media/" -DATABASE_ENGINE = 'sqlite3' +DATABASE_ENGINE = "sqlite3" DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': 'drf_example', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": "drf_example", } } INSTALLED_APPS = [ - 'django.contrib.contenttypes', - 'django.contrib.staticfiles', - 'django.contrib.sites', - 'django.contrib.sessions', - 'django.contrib.auth', - 'rest_framework_json_api', - 'rest_framework', - 'polymorphic', - 'example', - 'debug_toolbar', - 'django_filters', + "django.contrib.contenttypes", + "django.contrib.staticfiles", + "django.contrib.sites", + "django.contrib.sessions", + "django.contrib.auth", + "rest_framework_json_api", + "rest_framework", + "polymorphic", + "example", + "debug_toolbar", + "django_filters", ] TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [ + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [ # insert your TEMPLATE_DIRS here ], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ # Insert your TEMPLATE_CONTEXT_PROCESSORS here or use this # list if you haven't customized them: - 'django.contrib.auth.context_processors.auth', - 'django.template.context_processors.debug', - 'django.template.context_processors.i18n', - 'django.template.context_processors.media', - 'django.template.context_processors.static', - 'django.template.context_processors.tz', - 'django.contrib.messages.context_processors.messages', + "django.contrib.auth.context_processors.auth", + "django.template.context_processors.debug", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.static", + "django.template.context_processors.tz", + "django.contrib.messages.context_processors.messages", ], }, }, ] -STATIC_URL = '/static/' +STATIC_URL = "/static/" -ROOT_URLCONF = 'example.urls' +ROOT_URLCONF = "example.urls" -SECRET_KEY = 'abc123' +SECRET_KEY = "abc123" -PASSWORD_HASHERS = ('django.contrib.auth.hashers.UnsaltedMD5PasswordHasher', ) +PASSWORD_HASHERS = ("django.contrib.auth.hashers.UnsaltedMD5PasswordHasher",) -MIDDLEWARE = ( - 'debug_toolbar.middleware.DebugToolbarMiddleware', -) +MIDDLEWARE = ("debug_toolbar.middleware.DebugToolbarMiddleware",) -INTERNAL_IPS = ('127.0.0.1', ) +INTERNAL_IPS = ("127.0.0.1",) -JSON_API_FORMAT_FIELD_NAMES = 'camelize' -JSON_API_FORMAT_TYPES = 'camelize' +JSON_API_FORMAT_FIELD_NAMES = "camelize" +JSON_API_FORMAT_TYPES = "camelize" REST_FRAMEWORK = { - 'PAGE_SIZE': 5, - 'EXCEPTION_HANDLER': 'rest_framework_json_api.exceptions.exception_handler', - 'DEFAULT_PAGINATION_CLASS': - 'rest_framework_json_api.pagination.JsonApiPageNumberPagination', - 'DEFAULT_PARSER_CLASSES': ( - 'rest_framework_json_api.parsers.JSONParser', - 'rest_framework.parsers.FormParser', - 'rest_framework.parsers.MultiPartParser' + "PAGE_SIZE": 5, + "EXCEPTION_HANDLER": "rest_framework_json_api.exceptions.exception_handler", + "DEFAULT_PAGINATION_CLASS": "rest_framework_json_api.pagination.JsonApiPageNumberPagination", + "DEFAULT_PARSER_CLASSES": ( + "rest_framework_json_api.parsers.JSONParser", + "rest_framework.parsers.FormParser", + "rest_framework.parsers.MultiPartParser", ), - 'DEFAULT_RENDERER_CLASSES': ( - 'rest_framework_json_api.renderers.JSONRenderer', - + "DEFAULT_RENDERER_CLASSES": ( + "rest_framework_json_api.renderers.JSONRenderer", # If you're performance testing, you will want to use the browseable API # without forms, as the forms can generate their own queries. # If performance testing, enable: # 'example.utils.BrowsableAPIRendererWithoutForms', # Otherwise, to play around with the browseable API, enable: - 'rest_framework_json_api.renderers.BrowsableAPIRenderer', + "rest_framework_json_api.renderers.BrowsableAPIRenderer", ), - 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', - 'DEFAULT_SCHEMA_CLASS': 'rest_framework_json_api.schemas.openapi.AutoSchema', - 'DEFAULT_FILTER_BACKENDS': ( - 'rest_framework_json_api.filters.OrderingFilter', - 'rest_framework_json_api.django_filters.DjangoFilterBackend', - 'rest_framework.filters.SearchFilter', + "DEFAULT_METADATA_CLASS": "rest_framework_json_api.metadata.JSONAPIMetadata", + "DEFAULT_SCHEMA_CLASS": "rest_framework_json_api.schemas.openapi.AutoSchema", + "DEFAULT_FILTER_BACKENDS": ( + "rest_framework_json_api.filters.OrderingFilter", + "rest_framework_json_api.django_filters.DjangoFilterBackend", + "rest_framework.filters.SearchFilter", ), - 'SEARCH_PARAM': 'filter[search]', - 'TEST_REQUEST_RENDERER_CLASSES': ( - 'rest_framework_json_api.renderers.JSONRenderer', + "SEARCH_PARAM": "filter[search]", + "TEST_REQUEST_RENDERER_CLASSES": ( + "rest_framework_json_api.renderers.JSONRenderer", ), - 'TEST_REQUEST_DEFAULT_FORMAT': 'vnd.api+json' + "TEST_REQUEST_DEFAULT_FORMAT": "vnd.api+json", } diff --git a/example/settings/test.py b/example/settings/test.py index b47c3fe5..5fa040d6 100644 --- a/example/settings/test.py +++ b/example/settings/test.py @@ -1,18 +1,20 @@ from .dev import * # noqa DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", } } -ROOT_URLCONF = 'example.urls_test' +ROOT_URLCONF = "example.urls_test" -JSON_API_FORMAT_FIELD_NAMES = 'camelize' -JSON_API_FORMAT_TYPES = 'camelize' +JSON_API_FORMAT_FIELD_NAMES = "camelize" +JSON_API_FORMAT_TYPES = "camelize" JSON_API_PLURALIZE_TYPES = True -REST_FRAMEWORK.update({ # noqa - 'PAGE_SIZE': 1, -}) +REST_FRAMEWORK.update( + { # noqa + "PAGE_SIZE": 1, + } +) diff --git a/example/tests/__init__.py b/example/tests/__init__.py index 30a04e22..daca2310 100644 --- a/example/tests/__init__.py +++ b/example/tests/__init__.py @@ -1,4 +1,3 @@ - from django.contrib.auth import get_user_model from rest_framework.test import APITestCase @@ -15,15 +14,12 @@ def setUp(self): super(TestBase, self).setUp() self.create_users() - def create_user(self, username, email, password="pw", - first_name='', last_name=''): + def create_user(self, username, email, password="pw", first_name="", last_name=""): """ Helper method to create a user """ User = get_user_model() - user = User.objects.create_user( - username, email, password=password - ) + user = User.objects.create_user(username, email, password=password) if first_name or last_name: user.first_name = first_name user.last_name = last_name @@ -35,8 +31,8 @@ def create_users(self): Create a couple users """ self.john = self.create_user( - 'trane', 'john@example.com', - first_name='John', last_name="Coltrane") + "trane", "john@example.com", first_name="John", last_name="Coltrane" + ) self.miles = self.create_user( - 'miles', 'miles@example.com', - first_name="Miles", last_name="Davis") + "miles", "miles@example.com", first_name="Miles", last_name="Davis" + ) diff --git a/example/tests/conftest.py b/example/tests/conftest.py index de2bea19..df5bbdfc 100644 --- a/example/tests/conftest.py +++ b/example/tests/conftest.py @@ -13,7 +13,7 @@ CompanyFactory, EntryFactory, ResearchProjectFactory, - TaggedItemFactory + TaggedItemFactory, ) register(BlogFactory) @@ -59,7 +59,9 @@ def single_comment(blog, author, entry_factory, comment_factory): @pytest.fixture def single_company(art_project_factory, research_project_factory, company_factory): - company = company_factory(future_projects=(research_project_factory(), art_project_factory())) + company = company_factory( + future_projects=(research_project_factory(), art_project_factory()) + ) return company diff --git a/example/tests/integration/test_browsable_api.py b/example/tests/integration/test_browsable_api.py index d4bc3fbb..9156eb92 100644 --- a/example/tests/integration/test_browsable_api.py +++ b/example/tests/integration/test_browsable_api.py @@ -8,22 +8,18 @@ def test_browsable_api_with_included_serializers(single_entry, client): response = client.get( - reverse( - "entry-detail", - kwargs={'pk': single_entry.pk, 'format': 'api'} - ) + reverse("entry-detail", kwargs={"pk": single_entry.pk, "format": "api"}) ) content = str(response.content) assert response.status_code == 200 - assert re.search(r'JSON:API includes', content) + assert re.search(r"JSON:API includes", content) assert re.search( - r']* value="authors.bio"', - content + r']* value="authors.bio"', content ) def test_browsable_api_with_no_included_serializers(client): - response = client.get(reverse("projecttype-list", kwargs={'format': 'api'})) + response = client.get(reverse("projecttype-list", kwargs={"format": "api"})) content = str(response.content) assert response.status_code == 200 - assert not re.search(r'JSON:API includes', content) + assert not re.search(r"JSON:API includes", content) diff --git a/example/tests/integration/test_includes.py b/example/tests/integration/test_includes.py index 953052de..0ee0d4fd 100644 --- a/example/tests/integration/test_includes.py +++ b/example/tests/integration/test_includes.py @@ -5,174 +5,256 @@ def test_included_data_on_list(multiple_entries, client): - response = client.get(reverse("entry-list"), data={'include': 'comments', 'page[size]': 5}) - included = response.json().get('included') - - assert len(response.json()['data']) == len(multiple_entries), ( - 'Incorrect entry count' + response = client.get( + reverse("entry-list"), data={"include": "comments", "page[size]": 5} ) - assert [x.get('type') for x in included] == ['comments', 'comments'], ( - 'List included types are incorrect' + included = response.json().get("included") + + assert len(response.json()["data"]) == len( + multiple_entries + ), "Incorrect entry count" + assert [x.get("type") for x in included] == [ + "comments", + "comments", + ], "List included types are incorrect" + + comment_count = len( + [resource for resource in included if resource["type"] == "comments"] ) - - comment_count = len([resource for resource in included if resource["type"] == "comments"]) expected_comment_count = sum([entry.comments.count() for entry in multiple_entries]) - assert comment_count == expected_comment_count, 'List comment count is incorrect' + assert comment_count == expected_comment_count, "List comment count is incorrect" def test_included_data_on_list_with_one_to_one_relations(multiple_entries, client): - response = client.get(reverse("entry-list"), - data={'include': 'authors.bio.metadata', 'page[size]': 5}) - included = response.json().get('included') - - assert len(response.json()['data']) == len(multiple_entries), ( - 'Incorrect entry count' + response = client.get( + reverse("entry-list"), data={"include": "authors.bio.metadata", "page[size]": 5} ) + included = response.json().get("included") + + assert len(response.json()["data"]) == len( + multiple_entries + ), "Incorrect entry count" expected_include_types = [ - 'authorBioMetadata', 'authorBioMetadata', - 'authorBios', 'authorBios', - 'authors', 'authors' + "authorBioMetadata", + "authorBioMetadata", + "authorBios", + "authorBios", + "authors", + "authors", ] - include_types = [x.get('type') for x in included] - assert include_types == expected_include_types, ( - 'List included types are incorrect' - ) + include_types = [x.get("type") for x in included] + assert include_types == expected_include_types, "List included types are incorrect" def test_default_included_data_on_detail(single_entry, client): - return test_included_data_on_detail(single_entry=single_entry, client=client, query='') + return test_included_data_on_detail( + single_entry=single_entry, client=client, query="" + ) -def test_included_data_on_detail(single_entry, client, query='?include=comments'): - response = client.get(reverse("entry-detail", kwargs={'pk': single_entry.pk}) + query) - included = response.json().get('included') +def test_included_data_on_detail(single_entry, client, query="?include=comments"): + response = client.get( + reverse("entry-detail", kwargs={"pk": single_entry.pk}) + query + ) + included = response.json().get("included") - assert [x.get('type') for x in included] == ['comments'], 'Detail included types are incorrect' + assert [x.get("type") for x in included] == [ + "comments" + ], "Detail included types are incorrect" - comment_count = len([resource for resource in included if resource["type"] == "comments"]) + comment_count = len( + [resource for resource in included if resource["type"] == "comments"] + ) expected_comment_count = single_entry.comments.count() - assert comment_count == expected_comment_count, 'Detail comment count is incorrect' + assert comment_count == expected_comment_count, "Detail comment count is incorrect" def test_dynamic_related_data_is_included(single_entry, entry_factory, client): entry_factory() response = client.get( - reverse("entry-detail", kwargs={'pk': single_entry.pk}) + '?include=featured' + reverse("entry-detail", kwargs={"pk": single_entry.pk}) + "?include=featured" ) - included = response.json().get('included') + included = response.json().get("included") - assert [x.get('type') for x in included] == ['entries'], 'Dynamic included types are incorrect' - assert len(included) == 1, 'The dynamically included blog entries are of an incorrect count' + assert [x.get("type") for x in included] == [ + "entries" + ], "Dynamic included types are incorrect" + assert ( + len(included) == 1 + ), "The dynamically included blog entries are of an incorrect count" def test_dynamic_many_related_data_is_included(single_entry, entry_factory, client): entry_factory() response = client.get( - reverse("entry-detail", kwargs={'pk': single_entry.pk}) + '?include=suggested' + reverse("entry-detail", kwargs={"pk": single_entry.pk}) + "?include=suggested" ) - included = response.json().get('included') + included = response.json().get("included") assert included - assert [x.get('type') for x in included] == ['entries'], 'Dynamic included types are incorrect' + assert [x.get("type") for x in included] == [ + "entries" + ], "Dynamic included types are incorrect" def test_missing_field_not_included(author_bio_factory, author_factory, client): # First author does not have a bio author = author_factory(bio=None) - response = client.get(reverse('author-detail', args=[author.pk]) + '?include=bio') - assert 'included' not in response.json() + response = client.get(reverse("author-detail", args=[author.pk]) + "?include=bio") + assert "included" not in response.json() # Second author does author = author_factory() - response = client.get(reverse('author-detail', args=[author.pk]) + '?include=bio') + response = client.get(reverse("author-detail", args=[author.pk]) + "?include=bio") data = response.json() - assert 'included' in data - assert len(data['included']) == 1 - assert data['included'][0]['attributes']['body'] == author.bio.body + assert "included" in data + assert len(data["included"]) == 1 + assert data["included"][0]["attributes"]["body"] == author.bio.body def test_deep_included_data_on_list(multiple_entries, client): - response = client.get(reverse("entry-list") + '?include=comments,comments.author,' - 'comments.author.bio,comments.writer&page[size]=5') - included = response.json().get('included') - - assert len(response.json()['data']) == len(multiple_entries), ( - 'Incorrect entry count' + response = client.get( + reverse("entry-list") + "?include=comments,comments.author," + "comments.author.bio,comments.writer&page[size]=5" + ) + included = response.json().get("included") + + assert len(response.json()["data"]) == len( + multiple_entries + ), "Incorrect entry count" + assert [x.get("type") for x in included] == [ + "authorBios", + "authorBios", + "authors", + "authors", + "comments", + "comments", + "writers", + "writers", + ], "List included types are incorrect" + + comment_count = len( + [resource for resource in included if resource["type"] == "comments"] ) - assert [x.get('type') for x in included] == [ - 'authorBios', 'authorBios', 'authors', 'authors', - 'comments', 'comments', 'writers', 'writers' - ], 'List included types are incorrect' - - comment_count = len([resource for resource in included if resource["type"] == "comments"]) expected_comment_count = sum([entry.comments.count() for entry in multiple_entries]) - assert comment_count == expected_comment_count, 'List comment count is incorrect' + assert comment_count == expected_comment_count, "List comment count is incorrect" - author_count = len([resource for resource in included if resource["type"] == "authors"]) + author_count = len( + [resource for resource in included if resource["type"] == "authors"] + ) expected_author_count = sum( - [entry.comments.filter(author__isnull=False).count() for entry in multiple_entries]) - assert author_count == expected_author_count, 'List author count is incorrect' + [ + entry.comments.filter(author__isnull=False).count() + for entry in multiple_entries + ] + ) + assert author_count == expected_author_count, "List author count is incorrect" - author_bio_count = len([resource for resource in included if resource["type"] == "authorBios"]) - expected_author_bio_count = sum([entry.comments.filter( - author__bio__isnull=False).count() for entry in multiple_entries]) - assert author_bio_count == expected_author_bio_count, 'List author bio count is incorrect' + author_bio_count = len( + [resource for resource in included if resource["type"] == "authorBios"] + ) + expected_author_bio_count = sum( + [ + entry.comments.filter(author__bio__isnull=False).count() + for entry in multiple_entries + ] + ) + assert ( + author_bio_count == expected_author_bio_count + ), "List author bio count is incorrect" writer_count = len( [resource for resource in included if resource["type"] == "writers"] ) expected_writer_count = sum( - [entry.comments.filter(author__isnull=False).count() for entry in multiple_entries]) - assert writer_count == expected_writer_count, 'List writer count is incorrect' + [ + entry.comments.filter(author__isnull=False).count() + for entry in multiple_entries + ] + ) + assert writer_count == expected_writer_count, "List writer count is incorrect" # Also include entry authors - response = client.get(reverse("entry-list") + '?include=authors,comments,comments.author,' - 'comments.author.bio&page[size]=5') - included = response.json().get('included') - - assert len(response.json()['data']) == len(multiple_entries), ( - 'Incorrect entry count' + response = client.get( + reverse("entry-list") + "?include=authors,comments,comments.author," + "comments.author.bio&page[size]=5" + ) + included = response.json().get("included") + + assert len(response.json()["data"]) == len( + multiple_entries + ), "Incorrect entry count" + assert [x.get("type") for x in included] == [ + "authorBios", + "authorBios", + "authors", + "authors", + "authors", + "authors", + "comments", + "comments", + ], "List included types are incorrect" + + author_count = len( + [resource for resource in included if resource["type"] == "authors"] ) - assert [x.get('type') for x in included] == [ - 'authorBios', 'authorBios', 'authors', 'authors', 'authors', 'authors', - 'comments', 'comments'], 'List included types are incorrect' - - author_count = len([resource for resource in included if resource["type"] == "authors"]) expected_author_count = sum( - [entry.authors.count() for entry in multiple_entries] + - [entry.comments.filter(author__isnull=False).count() for entry in multiple_entries]) - assert author_count == expected_author_count, 'List author count is incorrect' + [entry.authors.count() for entry in multiple_entries] + + [ + entry.comments.filter(author__isnull=False).count() + for entry in multiple_entries + ] + ) + assert author_count == expected_author_count, "List author count is incorrect" def test_deep_included_data_on_detail(single_entry, client): # Same test as in list but also ensures that intermediate resources (here comments' authors) # are returned along with the leaf nodes - response = client.get(reverse("entry-detail", kwargs={'pk': single_entry.pk}) + - '?include=comments,comments.author.bio') - included = response.json().get('included') + response = client.get( + reverse("entry-detail", kwargs={"pk": single_entry.pk}) + + "?include=comments,comments.author.bio" + ) + included = response.json().get("included") - assert [x.get('type') for x in included] == ['authorBios', 'authors', 'comments'], \ - 'Detail included types are incorrect' + assert [x.get("type") for x in included] == [ + "authorBios", + "authors", + "comments", + ], "Detail included types are incorrect" - comment_count = len([resource for resource in included if resource["type"] == "comments"]) + comment_count = len( + [resource for resource in included if resource["type"] == "comments"] + ) expected_comment_count = single_entry.comments.count() - assert comment_count == expected_comment_count, 'Detail comment count is incorrect' + assert comment_count == expected_comment_count, "Detail comment count is incorrect" - author_bio_count = len([resource for resource in included if resource["type"] == "authorBios"]) - expected_author_bio_count = single_entry.comments.filter(author__bio__isnull=False).count() - assert author_bio_count == expected_author_bio_count, 'Detail author bio count is incorrect' + author_bio_count = len( + [resource for resource in included if resource["type"] == "authorBios"] + ) + expected_author_bio_count = single_entry.comments.filter( + author__bio__isnull=False + ).count() + assert ( + author_bio_count == expected_author_bio_count + ), "Detail author bio count is incorrect" def test_data_resource_not_included_again(single_comment, client): # This test makes sure that the resource which is in the data field is excluded # from the included field. - response = client.get(reverse("comment-detail", kwargs={'pk': single_comment.pk}) + - '?include=entry.comments') + response = client.get( + reverse("comment-detail", kwargs={"pk": single_comment.pk}) + + "?include=entry.comments" + ) - included = response.json().get('included') + included = response.json().get("included") - included_comments = [resource for resource in included if resource["type"] == "comments"] - assert single_comment.pk not in [int(x.get('id')) for x in included_comments], \ - "Resource of the data field duplicated in included" + included_comments = [ + resource for resource in included if resource["type"] == "comments" + ] + assert single_comment.pk not in [ + int(x.get("id")) for x in included_comments + ], "Resource of the data field duplicated in included" comment_count = len(included_comments) expected_comment_count = single_comment.entry.comments.count() diff --git a/example/tests/integration/test_meta.py b/example/tests/integration/test_meta.py index 25457b1c..f54cda8e 100644 --- a/example/tests/integration/test_meta.py +++ b/example/tests/integration/test_meta.py @@ -9,30 +9,26 @@ def test_top_level_meta_for_list_view(blog, client): expected = { - "data": [{ - "type": "blogs", - "id": "1", - "attributes": { - "name": blog.name - }, - "links": { - "self": 'http://testserver/blogs/1' - }, - 'relationships': {'tags': {'data': [], 'meta': {'count': 0}}}, - "meta": { - "copyright": datetime.now().year - }, - }], - 'links': { - 'first': 'http://testserver/blogs?page%5Bnumber%5D=1', - 'last': 'http://testserver/blogs?page%5Bnumber%5D=1', - 'next': None, - 'prev': None + "data": [ + { + "type": "blogs", + "id": "1", + "attributes": {"name": blog.name}, + "links": {"self": "http://testserver/blogs/1"}, + "relationships": {"tags": {"data": [], "meta": {"count": 0}}}, + "meta": {"copyright": datetime.now().year}, + } + ], + "links": { + "first": "http://testserver/blogs?page%5Bnumber%5D=1", + "last": "http://testserver/blogs?page%5Bnumber%5D=1", + "next": None, + "prev": None, + }, + "meta": { + "pagination": {"count": 1, "page": 1, "pages": 1}, + "apiDocs": "/docs/api/blogs", }, - 'meta': { - 'pagination': {'count': 1, 'page': 1, 'pages': 1}, - 'apiDocs': '/docs/api/blogs' - } } response = client.get(reverse("blog-list")) @@ -46,22 +42,14 @@ def test_top_level_meta_for_detail_view(blog, client): "data": { "type": "blogs", "id": "1", - "attributes": { - "name": blog.name - }, - 'relationships': {'tags': {'data': [], 'meta': {'count': 0}}}, - "links": { - "self": "http://testserver/blogs/1" - }, - "meta": { - "copyright": datetime.now().year - }, - }, - "meta": { - "apiDocs": "/docs/api/blogs" + "attributes": {"name": blog.name}, + "relationships": {"tags": {"data": [], "meta": {"count": 0}}}, + "links": {"self": "http://testserver/blogs/1"}, + "meta": {"copyright": datetime.now().year}, }, + "meta": {"apiDocs": "/docs/api/blogs"}, } - response = client.get(reverse("blog-detail", kwargs={'pk': blog.pk})) + response = client.get(reverse("blog-detail", kwargs={"pk": blog.pk})) assert expected == response.json() diff --git a/example/tests/integration/test_model_resource_name.py b/example/tests/integration/test_model_resource_name.py index a69503ae..2dacd2d5 100644 --- a/example/tests/integration/test_model_resource_name.py +++ b/example/tests/integration/test_model_resource_name.py @@ -18,90 +18,100 @@ def _check_resource_and_relationship_comment_type_match(django_client): entry_response = django_client.get(reverse("entry-list")) comment_response = django_client.get(reverse("comment-list")) - comment_resource_type = comment_response.json().get('data')[0].get('type') - comment_relationship_type = entry_response.json().get( - 'data')[0].get('relationships').get('comments').get('data')[0].get('type') - - assert comment_resource_type == comment_relationship_type, ( - "The resource type seen in the relationships and head resource do not match" + comment_resource_type = comment_response.json().get("data")[0].get("type") + comment_relationship_type = ( + entry_response.json() + .get("data")[0] + .get("relationships") + .get("comments") + .get("data")[0] + .get("type") ) + assert ( + comment_resource_type == comment_relationship_type + ), "The resource type seen in the relationships and head resource do not match" + def _check_relationship_and_included_comment_type_are_the_same(django_client, url): response = django_client.get(url + "?include=comments") - data = response.json().get('data')[0] - comment = response.json().get('included')[0] - - comment_relationship_type = data.get('relationships').get('comments').get('data')[0].get('type') - comment_included_type = comment.get('type') + data = response.json().get("data")[0] + comment = response.json().get("included")[0] - assert comment_relationship_type == comment_included_type, ( - "The resource type seen in the relationships and included do not match" + comment_relationship_type = ( + data.get("relationships").get("comments").get("data")[0].get("type") ) + comment_included_type = comment.get("type") + + assert ( + comment_relationship_type == comment_included_type + ), "The resource type seen in the relationships and included do not match" @pytest.mark.usefixtures("single_entry") class TestModelResourceName: create_data = { - 'data': { - 'type': 'resource_name_from_JSONAPIMeta', - 'id': None, - 'attributes': { - 'body': 'example', + "data": { + "type": "resource_name_from_JSONAPIMeta", + "id": None, + "attributes": { + "body": "example", + }, + "relationships": { + "entry": {"data": {"type": "resource_name_from_JSONAPIMeta", "id": 1}} }, - 'relationships': { - 'entry': { - 'data': { - 'type': 'resource_name_from_JSONAPIMeta', - 'id': 1 - } - } - } } } def test_model_resource_name_on_list(self, client): models.Comment.__bases__ += (_PatchedModel,) response = client.get(reverse("comment-list")) - data = response.json()['data'][0] + data = response.json()["data"][0] # name should be super-author instead of model name RenamedAuthor - assert (data.get('type') == 'resource_name_from_JSONAPIMeta'), ( - 'resource_name from model incorrect on list') + assert ( + data.get("type") == "resource_name_from_JSONAPIMeta" + ), "resource_name from model incorrect on list" # Precedence tests def test_resource_name_precendence(self, client, monkeypatch): # default response = client.get(reverse("comment-list")) - data = response.json()['data'][0] - assert (data.get('type') == 'comments'), ( - 'resource_name from model incorrect on list') + data = response.json()["data"][0] + assert ( + data.get("type") == "comments" + ), "resource_name from model incorrect on list" # model > default models.Comment.__bases__ += (_PatchedModel,) response = client.get(reverse("comment-list")) - data = response.json()['data'][0] - assert (data.get('type') == 'resource_name_from_JSONAPIMeta'), ( - 'resource_name from model incorrect on list') + data = response.json()["data"][0] + assert ( + data.get("type") == "resource_name_from_JSONAPIMeta" + ), "resource_name from model incorrect on list" # serializer > model monkeypatch.setattr( serializers.CommentSerializer.Meta, - 'resource_name', - 'resource_name_from_serializer', - False + "resource_name", + "resource_name_from_serializer", + False, ) response = client.get(reverse("comment-list")) - data = response.json()['data'][0] - assert (data.get('type') == 'resource_name_from_serializer'), ( - 'resource_name from serializer incorrect on list') + data = response.json()["data"][0] + assert ( + data.get("type") == "resource_name_from_serializer" + ), "resource_name from serializer incorrect on list" # view > serializer > model - monkeypatch.setattr(views.CommentViewSet, 'resource_name', 'resource_name_from_view', False) + monkeypatch.setattr( + views.CommentViewSet, "resource_name", "resource_name_from_view", False + ) response = client.get(reverse("comment-list")) - data = response.json()['data'][0] - assert (data.get('type') == 'resource_name_from_view'), ( - 'resource_name from view incorrect on list') + data = response.json()["data"][0] + assert ( + data.get("type") == "resource_name_from_view" + ), "resource_name from view incorrect on list" def test_model_resource_name_create(self, client): models.Comment.__bases__ += (_PatchedModel,) @@ -113,19 +123,18 @@ def test_model_resource_name_create(self, client): def test_serializer_resource_name_create(self, client, monkeypatch): monkeypatch.setattr( serializers.CommentSerializer.Meta, - 'resource_name', - 'renamed_comments', - False + "resource_name", + "renamed_comments", + False, ) monkeypatch.setattr( - serializers.EntrySerializer.Meta, - 'resource_name', - 'renamed_entries', - False + serializers.EntrySerializer.Meta, "resource_name", "renamed_entries", False ) create_data = deepcopy(self.create_data) - create_data['data']['type'] = 'renamed_comments' - create_data['data']['relationships']['entry']['data']['type'] = 'renamed_entries' + create_data["data"]["type"] = "renamed_comments" + create_data["data"]["relationships"]["entry"]["data"][ + "type" + ] = "renamed_entries" response = client.post(reverse("comment-list"), create_data) @@ -141,37 +150,59 @@ class TestResourceNameConsistency: # Included rename tests def test_type_match_on_included_and_inline_base(self, client): - _check_relationship_and_included_comment_type_are_the_same(client, reverse("entry-list")) + _check_relationship_and_included_comment_type_are_the_same( + client, reverse("entry-list") + ) def test_type_match_on_included_and_inline_with_JSONAPIMeta(self, client): models.Comment.__bases__ += (_PatchedModel,) - _check_relationship_and_included_comment_type_are_the_same(client, reverse("entry-list")) + _check_relationship_and_included_comment_type_are_the_same( + client, reverse("entry-list") + ) - def test_type_match_on_included_and_inline_with_serializer_resource_name(self, client): - serializers.CommentSerializer.Meta.resource_name = "resource_name_from_serializer" + def test_type_match_on_included_and_inline_with_serializer_resource_name( + self, client + ): + serializers.CommentSerializer.Meta.resource_name = ( + "resource_name_from_serializer" + ) - _check_relationship_and_included_comment_type_are_the_same(client, reverse("entry-list")) + _check_relationship_and_included_comment_type_are_the_same( + client, reverse("entry-list") + ) - def test_type_match_on_included_and_inline_without_serializer_resource_name(self, client): + def test_type_match_on_included_and_inline_without_serializer_resource_name( + self, client + ): serializers.CommentSerializer.Meta.resource_name = None - _check_relationship_and_included_comment_type_are_the_same(client, reverse("entry-list")) + _check_relationship_and_included_comment_type_are_the_same( + client, reverse("entry-list") + ) def test_type_match_on_included_and_inline_with_serializer_resource_name_and_JSONAPIMeta( - self, client + self, client ): models.Comment.__bases__ += (_PatchedModel,) - serializers.CommentSerializer.Meta.resource_name = "resource_name_from_serializer" + serializers.CommentSerializer.Meta.resource_name = ( + "resource_name_from_serializer" + ) - _check_relationship_and_included_comment_type_are_the_same(client, reverse("entry-list")) + _check_relationship_and_included_comment_type_are_the_same( + client, reverse("entry-list") + ) # Relation rename tests def test_resource_and_relationship_type_match(self, client): _check_resource_and_relationship_comment_type_match(client) - def test_resource_and_relationship_type_match_with_serializer_resource_name(self, client): - serializers.CommentSerializer.Meta.resource_name = "resource_name_from_serializer" + def test_resource_and_relationship_type_match_with_serializer_resource_name( + self, client + ): + serializers.CommentSerializer.Meta.resource_name = ( + "resource_name_from_serializer" + ) _check_resource_and_relationship_comment_type_match(client) @@ -181,10 +212,12 @@ def test_resource_and_relationship_type_match_with_JSONAPIMeta(self, client): _check_resource_and_relationship_comment_type_match(client) def test_resource_and_relationship_type_match_with_serializer_resource_name_and_JSONAPIMeta( - self, client + self, client ): models.Comment.__bases__ += (_PatchedModel,) - serializers.CommentSerializer.Meta.resource_name = "resource_name_from_serializer" + serializers.CommentSerializer.Meta.resource_name = ( + "resource_name_from_serializer" + ) _check_resource_and_relationship_comment_type_match(client) diff --git a/example/tests/integration/test_non_paginated_responses.py b/example/tests/integration/test_non_paginated_responses.py index b73eae5e..5a2e59c8 100644 --- a/example/tests/integration/test_non_paginated_responses.py +++ b/example/tests/integration/test_non_paginated_responses.py @@ -7,9 +7,9 @@ @mock.patch( - 'rest_framework_json_api.utils' - '.get_default_included_resources_from_serializer', - new=lambda s: []) + "rest_framework_json_api.utils" ".get_default_included_resources_from_serializer", + new=lambda s: [], +) def test_multiple_entries_no_pagination(multiple_entries, client): expected = { @@ -17,82 +17,70 @@ def test_multiple_entries_no_pagination(multiple_entries, client): { "type": "posts", "id": "1", - "attributes": - { + "attributes": { "headline": multiple_entries[0].headline, "bodyText": multiple_entries[0].body_text, "pubDate": None, - "modDate": None + "modDate": None, }, - "meta": { - "bodyFormat": "text" - }, - "relationships": - { - "blog": { - "data": {"type": "blogs", "id": "1"} - }, + "meta": {"bodyFormat": "text"}, + "relationships": { + "blog": {"data": {"type": "blogs", "id": "1"}}, "blogHyperlinked": { "links": { "related": "http://testserver/entries/1/blog", - "self": "http://testserver/entries/1/relationships/blog_hyperlinked" + "self": "http://testserver/entries/1/relationships/blog_hyperlinked", } }, "authors": { "meta": {"count": 1}, - "data": [{"type": "authors", "id": "1"}] + "data": [{"type": "authors", "id": "1"}], }, "comments": { "meta": {"count": 1}, - "data": [{"type": "comments", "id": "1"}] + "data": [{"type": "comments", "id": "1"}], }, "commentsHyperlinked": { "links": { "related": "http://testserver/entries/1/comments", - "self": "http://testserver/entries/1/relationships/comments_hyperlinked" + "self": "http://testserver/entries/1/relationships/comments_hyperlinked", } }, "suggested": { "data": [{"type": "entries", "id": "2"}], "links": { "related": "http://testserver/entries/1/suggested/", - "self": "http://testserver/entries/1/relationships/suggested" - } + "self": "http://testserver/entries/1/relationships/suggested", + }, }, "suggestedHyperlinked": { "links": { "related": "http://testserver/entries/1/suggested/", "self": "http://testserver/entries/1" - "/relationships/suggested_hyperlinked" + "/relationships/suggested_hyperlinked", } }, "featuredHyperlinked": { "links": { "related": "http://testserver/entries/1/featured", - "self": "http://testserver/entries/1/relationships/featured_hyperlinked" + "self": "http://testserver/entries/1/relationships/featured_hyperlinked", } }, - 'tags': {'data': [], 'meta': {'count': 0}}, - } + "tags": {"data": [], "meta": {"count": 0}}, + }, }, { "type": "posts", "id": "2", - "attributes": - { + "attributes": { "headline": multiple_entries[1].headline, "bodyText": multiple_entries[1].body_text, "pubDate": None, - "modDate": None + "modDate": None, }, - "meta": { - "bodyFormat": "text" - }, - "relationships": - { - "blog": { - "data": {"type": "blogs", "id": "2"} - }, + "meta": {"bodyFormat": "text"}, + "relationships": { + "blog": {"data": {"type": "blogs", "id": "2"}}, "blogHyperlinked": { "links": { "related": "http://testserver/entries/2/blog", @@ -101,40 +89,40 @@ def test_multiple_entries_no_pagination(multiple_entries, client): }, "authors": { "meta": {"count": 1}, - "data": [{"type": "authors", "id": "2"}] + "data": [{"type": "authors", "id": "2"}], }, "comments": { "meta": {"count": 1}, - "data": [{"type": "comments", "id": "2"}] + "data": [{"type": "comments", "id": "2"}], }, "commentsHyperlinked": { "links": { "related": "http://testserver/entries/2/comments", - "self": "http://testserver/entries/2/relationships/comments_hyperlinked" + "self": "http://testserver/entries/2/relationships/comments_hyperlinked", } }, "suggested": { "data": [{"type": "entries", "id": "1"}], "links": { "related": "http://testserver/entries/2/suggested/", - "self": "http://testserver/entries/2/relationships/suggested" - } + "self": "http://testserver/entries/2/relationships/suggested", + }, }, "suggestedHyperlinked": { "links": { "related": "http://testserver/entries/2/suggested/", "self": "http://testserver/entries/2" - "/relationships/suggested_hyperlinked" + "/relationships/suggested_hyperlinked", } }, "featuredHyperlinked": { "links": { "related": "http://testserver/entries/2/featured", - "self": "http://testserver/entries/2/relationships/featured_hyperlinked" + "self": "http://testserver/entries/2/relationships/featured_hyperlinked", } }, - 'tags': {'data': [], 'meta': {'count': 0}}, - } + "tags": {"data": [], "meta": {"count": 0}}, + }, }, ] } diff --git a/example/tests/integration/test_pagination.py b/example/tests/integration/test_pagination.py index 1b12a0d2..aedf8c6c 100644 --- a/example/tests/integration/test_pagination.py +++ b/example/tests/integration/test_pagination.py @@ -7,9 +7,9 @@ @mock.patch( - 'rest_framework_json_api.utils' - '.get_default_included_resources_from_serializer', - new=lambda s: []) + "rest_framework_json_api.utils" ".get_default_included_resources_from_serializer", + new=lambda s: [], +) def test_pagination_with_single_entry(single_entry, client): expected = { @@ -17,21 +17,15 @@ def test_pagination_with_single_entry(single_entry, client): { "type": "posts", "id": "1", - "attributes": - { + "attributes": { "headline": single_entry.headline, "bodyText": single_entry.body_text, "pubDate": None, - "modDate": None + "modDate": None, }, - "meta": { - "bodyFormat": "text" - }, - "relationships": - { - "blog": { - "data": {"type": "blogs", "id": "1"} - }, + "meta": {"bodyFormat": "text"}, + "relationships": { + "blog": {"data": {"type": "blogs", "id": "1"}}, "blogHyperlinked": { "links": { "related": "http://testserver/entries/1/blog", @@ -40,64 +34,52 @@ def test_pagination_with_single_entry(single_entry, client): }, "authors": { "meta": {"count": 1}, - "data": [{"type": "authors", "id": "1"}] + "data": [{"type": "authors", "id": "1"}], }, "comments": { "meta": {"count": 1}, - "data": [{"type": "comments", "id": "1"}] + "data": [{"type": "comments", "id": "1"}], }, "commentsHyperlinked": { "links": { "related": "http://testserver/entries/1/comments", - "self": "http://testserver/entries/1/relationships/comments_hyperlinked" + "self": "http://testserver/entries/1/relationships/comments_hyperlinked", } }, "suggested": { "data": [], "links": { "related": "http://testserver/entries/1/suggested/", - "self": "http://testserver/entries/1/relationships/suggested" - } + "self": "http://testserver/entries/1/relationships/suggested", + }, }, "suggestedHyperlinked": { "links": { "related": "http://testserver/entries/1/suggested/", "self": "http://testserver/entries/1" - "/relationships/suggested_hyperlinked" + "/relationships/suggested_hyperlinked", } }, "featuredHyperlinked": { "links": { "related": "http://testserver/entries/1/featured", - "self": "http://testserver/entries/1/relationships/featured_hyperlinked" + "self": "http://testserver/entries/1/relationships/featured_hyperlinked", } }, "tags": { - 'meta': {'count': 1}, - "data": [ - { - "id": "1", - "type": "taggedItems" - } - ] - } - } - }], + "meta": {"count": 1}, + "data": [{"id": "1", "type": "taggedItems"}], + }, + }, + } + ], "links": { - 'first': 'http://testserver/entries?page%5Bnumber%5D=1', - 'last': 'http://testserver/entries?page%5Bnumber%5D=1', + "first": "http://testserver/entries?page%5Bnumber%5D=1", + "last": "http://testserver/entries?page%5Bnumber%5D=1", "next": None, "prev": None, }, - "meta": - { - "pagination": - { - "page": 1, - "pages": 1, - "count": 1 - } - } + "meta": {"pagination": {"page": 1, "pages": 1, "count": 1}}, } response = client.get(reverse("entry-list")) diff --git a/example/tests/integration/test_polymorphism.py b/example/tests/integration/test_polymorphism.py index 5c64776a..bd41f203 100644 --- a/example/tests/integration/test_polymorphism.py +++ b/example/tests/integration/test_polymorphism.py @@ -10,153 +10,157 @@ def test_polymorphism_on_detail(single_art_project, client): - response = client.get(reverse("project-detail", kwargs={'pk': single_art_project.pk})) + response = client.get( + reverse("project-detail", kwargs={"pk": single_art_project.pk}) + ) content = response.json() assert content["data"]["type"] == "artProjects" def test_polymorphism_on_detail_relations(single_company, client): - response = client.get(reverse("company-detail", kwargs={'pk': single_company.pk})) + response = client.get(reverse("company-detail", kwargs={"pk": single_company.pk})) content = response.json() - assert content["data"]["relationships"]["currentProject"]["data"]["type"] == "artProjects" assert ( - set([rel["type"] for rel in content["data"]["relationships"]["futureProjects"]["data"]]) == - set(["researchProjects", "artProjects"]) + content["data"]["relationships"]["currentProject"]["data"]["type"] + == "artProjects" ) + assert set( + [ + rel["type"] + for rel in content["data"]["relationships"]["futureProjects"]["data"] + ] + ) == set(["researchProjects", "artProjects"]) def test_polymorphism_on_included_relations(single_company, client): response = client.get( - reverse("company-detail", kwargs={'pk': single_company.pk}) + - '?include=current_project,future_projects,current_art_project,current_research_project') + reverse("company-detail", kwargs={"pk": single_company.pk}) + + "?include=current_project,future_projects,current_art_project,current_research_project" + ) content = response.json() - assert content["data"]["relationships"]["currentProject"]["data"]["type"] == "artProjects" - assert content["data"]["relationships"]["currentArtProject"]["data"]["type"] == "artProjects" - assert content["data"]["relationships"]["currentResearchProject"]["data"] is None assert ( - set([rel["type"] for rel in content["data"]["relationships"]["futureProjects"]["data"]]) == - set(["researchProjects", "artProjects"]) + content["data"]["relationships"]["currentProject"]["data"]["type"] + == "artProjects" ) - assert set([x.get('type') for x in content.get('included')]) == set([ - 'artProjects', 'artProjects', 'researchProjects']), 'Detail included types are incorrect' + assert ( + content["data"]["relationships"]["currentArtProject"]["data"]["type"] + == "artProjects" + ) + assert content["data"]["relationships"]["currentResearchProject"]["data"] is None + assert set( + [ + rel["type"] + for rel in content["data"]["relationships"]["futureProjects"]["data"] + ] + ) == set(["researchProjects", "artProjects"]) + assert set([x.get("type") for x in content.get("included")]) == set( + ["artProjects", "artProjects", "researchProjects"] + ), "Detail included types are incorrect" # Ensure that the child fields are present. - assert content.get('included')[0].get('attributes').get('artist') is not None - assert content.get('included')[1].get('attributes').get('artist') is not None - assert content.get('included')[2].get('attributes').get('supervisor') is not None + assert content.get("included")[0].get("attributes").get("artist") is not None + assert content.get("included")[1].get("attributes").get("artist") is not None + assert content.get("included")[2].get("attributes").get("supervisor") is not None def test_polymorphism_on_polymorphic_model_detail_patch(single_art_project, client): - url = reverse("project-detail", kwargs={'pk': single_art_project.pk}) + url = reverse("project-detail", kwargs={"pk": single_art_project.pk}) response = client.get(url) content = response.json() - test_topic = 'test-{}'.format(random.randint(0, 999999)) - test_artist = 'test-{}'.format(random.randint(0, 999999)) - content['data']['attributes']['topic'] = test_topic - content['data']['attributes']['artist'] = test_artist + test_topic = "test-{}".format(random.randint(0, 999999)) + test_artist = "test-{}".format(random.randint(0, 999999)) + content["data"]["attributes"]["topic"] = test_topic + content["data"]["attributes"]["artist"] = test_artist response = client.patch(url, data=content) new_content = response.json() - assert new_content['data']['type'] == "artProjects" - assert new_content['data']['attributes']['topic'] == test_topic - assert new_content['data']['attributes']['artist'] == test_artist + assert new_content["data"]["type"] == "artProjects" + assert new_content["data"]["attributes"]["topic"] == test_topic + assert new_content["data"]["attributes"]["artist"] == test_artist -def test_patch_on_polymorphic_model_without_including_required_field(single_art_project, client): - url = reverse("project-detail", kwargs={'pk': single_art_project.pk}) +def test_patch_on_polymorphic_model_without_including_required_field( + single_art_project, client +): + url = reverse("project-detail", kwargs={"pk": single_art_project.pk}) data = { - 'data': { - 'id': single_art_project.pk, - 'type': 'artProjects', - 'attributes': { - 'description': 'New description' - } + "data": { + "id": single_art_project.pk, + "type": "artProjects", + "attributes": {"description": "New description"}, } } response = client.patch(url, data) assert response.status_code == status.HTTP_200_OK - assert response.json()['data']['attributes']['description'] == 'New description' + assert response.json()["data"]["attributes"]["description"] == "New description" def test_polymorphism_on_polymorphic_model_list_post(client): - test_topic = 'New test topic {}'.format(random.randint(0, 999999)) - test_artist = 'test-{}'.format(random.randint(0, 999999)) + test_topic = "New test topic {}".format(random.randint(0, 999999)) + test_artist = "test-{}".format(random.randint(0, 999999)) test_project_type = ProjectTypeFactory() - url = reverse('project-list') + url = reverse("project-list") data = { - 'data': { - 'type': 'artProjects', - 'attributes': { - 'topic': test_topic, - 'artist': test_artist - }, - 'relationships': { - 'projectType': { - 'data': { - 'type': 'projectTypes', - 'id': test_project_type.pk - } + "data": { + "type": "artProjects", + "attributes": {"topic": test_topic, "artist": test_artist}, + "relationships": { + "projectType": { + "data": {"type": "projectTypes", "id": test_project_type.pk} } - } + }, } } response = client.post(url, data=data) content = response.json() - assert content['data']['id'] is not None - assert content['data']['type'] == "artProjects" - assert content['data']['attributes']['topic'] == test_topic - assert content['data']['attributes']['artist'] == test_artist - assert content['data']['relationships']['projectType']['data']['id'] == \ - str(test_project_type.pk) + assert content["data"]["id"] is not None + assert content["data"]["type"] == "artProjects" + assert content["data"]["attributes"]["topic"] == test_topic + assert content["data"]["attributes"]["artist"] == test_artist + assert content["data"]["relationships"]["projectType"]["data"]["id"] == str( + test_project_type.pk + ) def test_polymorphism_on_polymorphic_model_w_included_serializers(client): test_project = ArtProjectFactory() - query = '?include=projectType' - url = reverse('project-list') + query = "?include=projectType" + url = reverse("project-list") response = client.get(url + query) content = response.json() - assert content['data'][0]['id'] == str(test_project.pk) - assert content['data'][0]['type'] == 'artProjects' - assert content['data'][0]['relationships']['projectType']['data']['id'] == \ - str(test_project.project_type.pk) - assert content['included'][0]['type'] == 'projectTypes' - assert content['included'][0]['id'] == str(test_project.project_type.pk) + assert content["data"][0]["id"] == str(test_project.pk) + assert content["data"][0]["type"] == "artProjects" + assert content["data"][0]["relationships"]["projectType"]["data"]["id"] == str( + test_project.project_type.pk + ) + assert content["included"][0]["type"] == "projectTypes" + assert content["included"][0]["id"] == str(test_project.project_type.pk) def test_polymorphic_model_without_any_instance(client): expected = { "links": { - 'first': 'http://testserver/projects?page%5Bnumber%5D=1', - 'last': 'http://testserver/projects?page%5Bnumber%5D=1', + "first": "http://testserver/projects?page%5Bnumber%5D=1", + "last": "http://testserver/projects?page%5Bnumber%5D=1", "next": None, - "prev": None + "prev": None, }, "data": [], - "meta": { - "pagination": { - "page": 1, - "pages": 1, - "count": 0 - } - } + "meta": {"pagination": {"page": 1, "pages": 1, "count": 0}}, } - response = client.get(reverse('project-list')) + response = client.get(reverse("project-list")) assert response.status_code == 200 content = response.json() assert expected == content def test_invalid_type_on_polymorphic_model(client): - test_topic = 'New test topic {}'.format(random.randint(0, 999999)) - test_artist = 'test-{}'.format(random.randint(0, 999999)) - url = reverse('project-list') + test_topic = "New test topic {}".format(random.randint(0, 999999)) + test_artist = "test-{}".format(random.randint(0, 999999)) + url = reverse("project-list") data = { - 'data': { - 'type': 'invalidProjects', - 'attributes': { - 'topic': test_topic, - 'artist': test_artist - } + "data": { + "type": "invalidProjects", + "attributes": {"topic": test_topic, "artist": test_artist}, } } response = client.post(url, data=data) @@ -165,72 +169,103 @@ def test_invalid_type_on_polymorphic_model(client): assert len(content["errors"]) == 1 assert content["errors"][0]["status"] == "409" try: - assert content["errors"][0]["detail"] == \ - "The resource object's type (invalidProjects) is not the type that constitute the " \ + assert ( + content["errors"][0]["detail"] + == "The resource object's type (invalidProjects) is not the type that constitute the " "collection represented by the endpoint (one of [researchProjects, artProjects])." + ) except AssertionError: # Available type list order isn't enforced - assert content["errors"][0]["detail"] == \ - "The resource object's type (invalidProjects) is not the type that constitute the " \ + assert ( + content["errors"][0]["detail"] + == "The resource object's type (invalidProjects) is not the type that constitute the " "collection represented by the endpoint (one of [artProjects, researchProjects])." + ) -def test_polymorphism_relations_update(single_company, research_project_factory, client): - response = client.get(reverse("company-detail", kwargs={'pk': single_company.pk})) +def test_polymorphism_relations_update( + single_company, research_project_factory, client +): + response = client.get(reverse("company-detail", kwargs={"pk": single_company.pk})) content = response.json() - assert content["data"]["relationships"]["currentProject"]["data"]["type"] == "artProjects" + assert ( + content["data"]["relationships"]["currentProject"]["data"]["type"] + == "artProjects" + ) research_project = research_project_factory() content["data"]["relationships"]["currentProject"]["data"] = { "type": "researchProjects", - "id": research_project.pk + "id": research_project.pk, } - response = client.patch(reverse("company-detail", kwargs={'pk': single_company.pk}), - data=content) + response = client.patch( + reverse("company-detail", kwargs={"pk": single_company.pk}), data=content + ) assert response.status_code == 200 content = response.json() - assert content["data"]["relationships"]["currentProject"]["data"]["type"] == "researchProjects" - assert int(content["data"]["relationships"]["currentProject"]["data"]["id"]) == \ - research_project.pk + assert ( + content["data"]["relationships"]["currentProject"]["data"]["type"] + == "researchProjects" + ) + assert ( + int(content["data"]["relationships"]["currentProject"]["data"]["id"]) + == research_project.pk + ) -def test_polymorphism_relations_put_405(single_company, research_project_factory, client): - response = client.get(reverse("company-detail", kwargs={'pk': single_company.pk})) +def test_polymorphism_relations_put_405( + single_company, research_project_factory, client +): + response = client.get(reverse("company-detail", kwargs={"pk": single_company.pk})) content = response.json() - assert content["data"]["relationships"]["currentProject"]["data"]["type"] == "artProjects" + assert ( + content["data"]["relationships"]["currentProject"]["data"]["type"] + == "artProjects" + ) research_project = research_project_factory() content["data"]["relationships"]["currentProject"]["data"] = { "type": "researchProjects", - "id": research_project.pk + "id": research_project.pk, } - response = client.put(reverse("company-detail", kwargs={'pk': single_company.pk}), - data=content) + response = client.put( + reverse("company-detail", kwargs={"pk": single_company.pk}), data=content + ) assert response.status_code == 405 -def test_invalid_type_on_polymorphic_relation(single_company, research_project_factory, client): - response = client.get(reverse("company-detail", kwargs={'pk': single_company.pk})) +def test_invalid_type_on_polymorphic_relation( + single_company, research_project_factory, client +): + response = client.get(reverse("company-detail", kwargs={"pk": single_company.pk})) content = response.json() - assert content["data"]["relationships"]["currentProject"]["data"]["type"] == "artProjects" + assert ( + content["data"]["relationships"]["currentProject"]["data"]["type"] + == "artProjects" + ) research_project = research_project_factory() content["data"]["relationships"]["currentProject"]["data"] = { "type": "invalidProjects", - "id": research_project.pk + "id": research_project.pk, } - response = client.patch(reverse("company-detail", kwargs={'pk': single_company.pk}), - data=content) + response = client.patch( + reverse("company-detail", kwargs={"pk": single_company.pk}), data=content + ) assert response.status_code == 409 content = response.json() assert len(content["errors"]) == 1 assert content["errors"][0]["status"] == "409" try: - assert content["errors"][0]["detail"] == \ - "Incorrect relation type. Expected one of [researchProjects, artProjects], " \ + assert ( + content["errors"][0]["detail"] + == "Incorrect relation type. Expected one of [researchProjects, artProjects], " "received invalidProjects." + ) except AssertionError: # Available type list order isn't enforced - assert content["errors"][0]["detail"] == \ - "Incorrect relation type. Expected one of [artProjects, researchProjects], " \ + assert ( + content["errors"][0]["detail"] + == "Incorrect relation type. Expected one of [artProjects, researchProjects], " "received invalidProjects." + ) diff --git a/example/tests/integration/test_sparse_fieldsets.py b/example/tests/integration/test_sparse_fieldsets.py index 83b8560a..605d218d 100644 --- a/example/tests/integration/test_sparse_fieldsets.py +++ b/example/tests/integration/test_sparse_fieldsets.py @@ -6,18 +6,20 @@ def test_sparse_fieldset_valid_fields(client, entry): - base_url = reverse('entry-list') - response = client.get(base_url, data={'fields[entries]': 'blog,headline'}) + base_url = reverse("entry-list") + response = client.get(base_url, data={"fields[entries]": "blog,headline"}) assert response.status_code == status.HTTP_200_OK - data = response.json()['data'] + data = response.json()["data"] assert len(data) == 1 entry = data[0] - assert entry['attributes'].keys() == {'headline'} - assert entry['relationships'].keys() == {'blog'} + assert entry["attributes"].keys() == {"headline"} + assert entry["relationships"].keys() == {"blog"} -@pytest.mark.parametrize("fields_param", ['invalidfields[entries]', 'fieldsinvalid[entries']) +@pytest.mark.parametrize( + "fields_param", ["invalidfields[entries]", "fieldsinvalid[entries"] +) def test_sparse_fieldset_invalid_fields_parameter(client, entry, fields_param): """ Test that invalid fields query parameter is not processed by sparse fieldset. @@ -25,12 +27,12 @@ def test_sparse_fieldset_invalid_fields_parameter(client, entry, fields_param): rest_framework_json_api.filters.QueryParameterValidationFilter takes care of error handling in such a case. """ - base_url = reverse('entry-list') - response = client.get(base_url, data={'invalidfields[entries]': 'blog,headline'}) + base_url = reverse("entry-list") + response = client.get(base_url, data={"invalidfields[entries]": "blog,headline"}) assert response.status_code == status.HTTP_200_OK - data = response.json()['data'] + data = response.json()["data"] assert len(data) == 1 entry = data[0] - assert entry['attributes'].keys() != {'headline'} - assert entry['relationships'].keys() != {'blog'} + assert entry["attributes"].keys() != {"headline"} + assert entry["relationships"].keys() != {"blog"} diff --git a/example/tests/snapshots/snap_test_errors.py b/example/tests/snapshots/snap_test_errors.py index 48b8e474..528b831b 100644 --- a/example/tests/snapshots/snap_test_errors.py +++ b/example/tests/snapshots/snap_test_errors.py @@ -4,116 +4,97 @@ from snapshottest import Snapshot - snapshots = Snapshot() -snapshots['test_first_level_attribute_error 1'] = { - 'errors': [ +snapshots["test_first_level_attribute_error 1"] = { + "errors": [ { - 'code': 'required', - 'detail': 'This field is required.', - 'source': { - 'pointer': '/data/attributes/headline' - }, - 'status': '400' + "code": "required", + "detail": "This field is required.", + "source": {"pointer": "/data/attributes/headline"}, + "status": "400", } ] } -snapshots['test_first_level_custom_attribute_error 1'] = { - 'errors': [ +snapshots["test_first_level_custom_attribute_error 1"] = { + "errors": [ { - 'detail': 'Too short', - 'source': { - 'pointer': '/data/attributes/body-text' - }, - 'title': 'Too Short title' + "detail": "Too short", + "source": {"pointer": "/data/attributes/body-text"}, + "title": "Too Short title", } ] } -snapshots['test_many_third_level_dict_errors 1'] = { - 'errors': [ +snapshots["test_many_third_level_dict_errors 1"] = { + "errors": [ { - 'code': 'required', - 'detail': 'This field is required.', - 'source': { - 'pointer': '/data/attributes/comments/0/attachment/data' - }, - 'status': '400' + "code": "required", + "detail": "This field is required.", + "source": {"pointer": "/data/attributes/comments/0/attachment/data"}, + "status": "400", }, { - 'code': 'required', - 'detail': 'This field is required.', - 'source': { - 'pointer': '/data/attributes/comments/0/body' - }, - 'status': '400' - } + "code": "required", + "detail": "This field is required.", + "source": {"pointer": "/data/attributes/comments/0/body"}, + "status": "400", + }, ] } -snapshots['test_second_level_array_error 1'] = { - 'errors': [ +snapshots["test_second_level_array_error 1"] = { + "errors": [ { - 'code': 'required', - 'detail': 'This field is required.', - 'source': { - 'pointer': '/data/attributes/comments/0/body' - }, - 'status': '400' + "code": "required", + "detail": "This field is required.", + "source": {"pointer": "/data/attributes/comments/0/body"}, + "status": "400", } ] } -snapshots['test_second_level_dict_error 1'] = { - 'errors': [ +snapshots["test_second_level_dict_error 1"] = { + "errors": [ { - 'code': 'required', - 'detail': 'This field is required.', - 'source': { - 'pointer': '/data/attributes/comment/body' - }, - 'status': '400' + "code": "required", + "detail": "This field is required.", + "source": {"pointer": "/data/attributes/comment/body"}, + "status": "400", } ] } -snapshots['test_third_level_array_error 1'] = { - 'errors': [ +snapshots["test_third_level_array_error 1"] = { + "errors": [ { - 'code': 'required', - 'detail': 'This field is required.', - 'source': { - 'pointer': '/data/attributes/comments/0/attachments/0/data' - }, - 'status': '400' + "code": "required", + "detail": "This field is required.", + "source": {"pointer": "/data/attributes/comments/0/attachments/0/data"}, + "status": "400", } ] } -snapshots['test_third_level_custom_array_error 1'] = { - 'errors': [ +snapshots["test_third_level_custom_array_error 1"] = { + "errors": [ { - 'code': 'invalid', - 'detail': 'Too short data', - 'source': { - 'pointer': '/data/attributes/comments/0/attachments/0/data' - }, - 'status': '400' + "code": "invalid", + "detail": "Too short data", + "source": {"pointer": "/data/attributes/comments/0/attachments/0/data"}, + "status": "400", } ] } -snapshots['test_third_level_dict_error 1'] = { - 'errors': [ +snapshots["test_third_level_dict_error 1"] = { + "errors": [ { - 'code': 'required', - 'detail': 'This field is required.', - 'source': { - 'pointer': '/data/attributes/comments/0/attachment/data' - }, - 'status': '400' + "code": "required", + "detail": "This field is required.", + "source": {"pointer": "/data/attributes/comments/0/attachment/data"}, + "status": "400", } ] } diff --git a/example/tests/snapshots/snap_test_openapi.py b/example/tests/snapshots/snap_test_openapi.py index aca41d70..7c0b3b98 100644 --- a/example/tests/snapshots/snap_test_openapi.py +++ b/example/tests/snapshots/snap_test_openapi.py @@ -4,10 +4,11 @@ from snapshottest import Snapshot - snapshots = Snapshot() -snapshots['test_path_without_parameters 1'] = '''{ +snapshots[ + "test_path_without_parameters 1" +] = """{ "description": "", "operationId": "List/authors/", "parameters": [ @@ -121,9 +122,11 @@ "description": "not found" } } -}''' +}""" -snapshots['test_path_with_id_parameter 1'] = '''{ +snapshots[ + "test_path_with_id_parameter 1" +] = """{ "description": "", "operationId": "retrieve/authors/{id}/", "parameters": [ @@ -225,9 +228,11 @@ "description": "not found" } } -}''' +}""" -snapshots['test_post_request 1'] = '''{ +snapshots[ + "test_post_request 1" +] = """{ "description": "", "operationId": "create/authors/", "parameters": [], @@ -407,9 +412,11 @@ "description": "[Conflict](https://jsonapi.org/format/#crud-creating-responses-409)" } } -}''' +}""" -snapshots['test_patch_request 1'] = '''{ +snapshots[ + "test_patch_request 1" +] = """{ "description": "", "operationId": "update/authors/{id}", "parameters": [ @@ -583,9 +590,11 @@ "description": "[Conflict]([Conflict](https://jsonapi.org/format/#crud-updating-responses-409)" } } -}''' +}""" -snapshots['test_delete_request 1'] = '''{ +snapshots[ + "test_delete_request 1" +] = """{ "description": "", "operationId": "destroy/authors/{id}", "parameters": [ @@ -644,4 +653,4 @@ "description": "[Resource does not exist](https://jsonapi.org/format/#crud-deleting-responses-404)" } } -}''' +}""" diff --git a/example/tests/test_errors.py b/example/tests/test_errors.py index ff2e8f95..93cdb235 100644 --- a/example/tests/test_errors.py +++ b/example/tests/test_errors.py @@ -14,7 +14,7 @@ class CommentAttachmentSerializer(serializers.Serializer): def validate_data(self, value): if value and len(value) < 10: - raise serializers.ValidationError('Too short data') + raise serializers.ValidationError("Too short data") class CommentSerializer(serializers.Serializer): @@ -32,17 +32,17 @@ class EntrySerializer(serializers.Serializer): body_text = serializers.CharField() def validate(self, attrs): - body_text = attrs['body_text'] + body_text = attrs["body_text"] if len(body_text) < 5: - raise serializers.ValidationError({'body_text': { - 'title': 'Too Short title', 'detail': 'Too short'} - }) + raise serializers.ValidationError( + {"body_text": {"title": "Too Short title", "detail": "Too short"}} + ) # view class DummyTestView(views.APIView): serializer_class = EntrySerializer - resource_name = 'entries' + resource_name = "entries" def post(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) @@ -50,21 +50,18 @@ def post(self, request, *args, **kwargs): urlpatterns = [ - path('entries-nested', DummyTestView.as_view(), - name='entries-nested-list') + path("entries-nested", DummyTestView.as_view(), name="entries-nested-list") ] -@pytest.fixture(scope='function') +@pytest.fixture(scope="function") def some_blog(db): - return Blog.objects.create(name='Some Blog', tagline="It's a blog") + return Blog.objects.create(name="Some Blog", tagline="It's a blog") def perform_error_test(client, data): - with override_settings( - ROOT_URLCONF=__name__ - ): - url = reverse('entries-nested-list') + with override_settings(ROOT_URLCONF=__name__): + url = reverse("entries-nested-list") response = client.post(url, data=data) return response.json() @@ -72,12 +69,12 @@ def perform_error_test(client, data): def test_first_level_attribute_error(client, some_blog, snapshot): data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': some_blog.pk, - 'bodyText': 'body_text', - } + "data": { + "type": "entries", + "attributes": { + "blog": some_blog.pk, + "bodyText": "body_text", + }, } } snapshot.assert_match(perform_error_test(client, data)) @@ -85,32 +82,29 @@ def test_first_level_attribute_error(client, some_blog, snapshot): def test_first_level_custom_attribute_error(client, some_blog, snapshot): data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': some_blog.pk, - 'body-text': 'body', - 'headline': 'headline' - } + "data": { + "type": "entries", + "attributes": { + "blog": some_blog.pk, + "body-text": "body", + "headline": "headline", + }, } } - with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'): + with override_settings(JSON_API_FORMAT_FIELD_NAMES="dasherize"): snapshot.assert_match(perform_error_test(client, data)) def test_second_level_array_error(client, some_blog, snapshot): data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': some_blog.pk, - 'bodyText': 'body_text', - 'headline': 'headline', - 'comments': [ - { - } - ] - } + "data": { + "type": "entries", + "attributes": { + "blog": some_blog.pk, + "bodyText": "body_text", + "headline": "headline", + "comments": [{}], + }, } } @@ -119,14 +113,14 @@ def test_second_level_array_error(client, some_blog, snapshot): def test_second_level_dict_error(client, some_blog, snapshot): data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': some_blog.pk, - 'bodyText': 'body_text', - 'headline': 'headline', - 'comment': {} - } + "data": { + "type": "entries", + "attributes": { + "blog": some_blog.pk, + "bodyText": "body_text", + "headline": "headline", + "comment": {}, + }, } } @@ -135,22 +129,14 @@ def test_second_level_dict_error(client, some_blog, snapshot): def test_third_level_array_error(client, some_blog, snapshot): data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': some_blog.pk, - 'bodyText': 'body_text', - 'headline': 'headline', - 'comments': [ - { - 'body': 'test comment', - 'attachments': [ - { - } - ] - } - ] - } + "data": { + "type": "entries", + "attributes": { + "blog": some_blog.pk, + "bodyText": "body_text", + "headline": "headline", + "comments": [{"body": "test comment", "attachments": [{}]}], + }, } } @@ -159,23 +145,16 @@ def test_third_level_array_error(client, some_blog, snapshot): def test_third_level_custom_array_error(client, some_blog, snapshot): data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': some_blog.pk, - 'bodyText': 'body_text', - 'headline': 'headline', - 'comments': [ - { - 'body': 'test comment', - 'attachments': [ - { - 'data': 'text' - } - ] - } - ] - } + "data": { + "type": "entries", + "attributes": { + "blog": some_blog.pk, + "bodyText": "body_text", + "headline": "headline", + "comments": [ + {"body": "test comment", "attachments": [{"data": "text"}]} + ], + }, } } @@ -184,19 +163,14 @@ def test_third_level_custom_array_error(client, some_blog, snapshot): def test_third_level_dict_error(client, some_blog, snapshot): data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': some_blog.pk, - 'bodyText': 'body_text', - 'headline': 'headline', - 'comments': [ - { - 'body': 'test comment', - 'attachment': {} - } - ] - } + "data": { + "type": "entries", + "attributes": { + "blog": some_blog.pk, + "bodyText": "body_text", + "headline": "headline", + "comments": [{"body": "test comment", "attachment": {}}], + }, } } @@ -205,18 +179,14 @@ def test_third_level_dict_error(client, some_blog, snapshot): def test_many_third_level_dict_errors(client, some_blog, snapshot): data = { - 'data': { - 'type': 'entries', - 'attributes': { - 'blog': some_blog.pk, - 'bodyText': 'body_text', - 'headline': 'headline', - 'comments': [ - { - 'attachment': {} - } - ] - } + "data": { + "type": "entries", + "attributes": { + "blog": some_blog.pk, + "bodyText": "body_text", + "headline": "headline", + "comments": [{"attachment": {}}], + }, } } diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index c3b1c42d..68ad1452 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -8,24 +8,26 @@ class DJATestFilters(APITestCase): """ tests of JSON:API filter backends """ - fixtures = ('blogentry',) + + fixtures = ("blogentry",) def setUp(self): self.entries = Entry.objects.all() self.blogs = Blog.objects.all() - self.url = reverse('nopage-entry-list') - self.fs_url = reverse('filterset-entry-list') - self.no_fs_url = reverse('nofilterset-entry-list') + self.url = reverse("nopage-entry-list") + self.fs_url = reverse("filterset-entry-list") + self.no_fs_url = reverse("nofilterset-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")) + 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']] + headlines = [c["attributes"]["headline"] for c in dja_response["data"]] sorted_headlines = sorted(headlines) self.assertEqual(headlines, sorted_headlines) @@ -33,11 +35,12 @@ 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")) + 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']] + headlines = [c["attributes"]["headline"] for c in dja_response["data"]] sorted_headlines = sorted(headlines) self.assertNotEqual(headlines, sorted_headlines) @@ -45,11 +48,12 @@ 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")) + 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']] + headlines = [c["attributes"]["headline"] for c in dja_response["data"]] sorted_headlines = sorted(headlines) self.assertNotEqual(headlines, sorted_headlines) @@ -57,23 +61,28 @@ 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")) + 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") + 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")) + 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']] + 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) @@ -84,11 +93,12 @@ def test_sort_underscore(self): "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")) + 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']] + 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) @@ -97,12 +107,15 @@ 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")) + 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']] + 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) @@ -110,46 +123,50 @@ def test_filter_exact(self): """ filter for an exact match """ - response = self.client.get(self.url, data={'filter[headline]': 'CHEM3271X'}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"filter[headline]": "CHEM3271X"}) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(len(dja_response['data']), 1) + self.assertEqual(len(dja_response["data"]), 1) def test_filter_exact_fail(self): """ failed search for an exact match """ - response = self.client.get(self.url, data={'filter[headline]': 'XXXXX'}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"filter[headline]": "XXXXX"}) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(len(dja_response['data']), 0) + self.assertEqual(len(dja_response["data"]), 0) def test_filter_isnull(self): """ search for null value """ - response = self.client.get(self.url, data={'filter[bodyText.isnull]': 'true'}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"filter[bodyText.isnull]": "true"}) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() self.assertEqual( - len(dja_response['data']), - len([k for k in self.entries if k.body_text is None]) + len(dja_response["data"]), + len([k for k in self.entries if k.body_text is None]), ) def test_filter_not_null(self): """ search for not null """ - response = self.client.get(self.url, data={'filter[bodyText.isnull]': 'false'}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"filter[bodyText.isnull]": "false"}) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() self.assertEqual( - len(dja_response['data']), - len([k for k in self.entries if k.body_text is not None]) + len(dja_response["data"]), + len([k for k in self.entries if k.body_text is not None]), ) def test_filter_isempty(self): @@ -157,26 +174,35 @@ def test_filter_isempty(self): search for an empty value (different from null!) the easiest way to do this is search for r'^$' """ - response = self.client.get(self.url, data={'filter[bodyText.regex]': '^$'}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"filter[bodyText.regex]": "^$"}) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(len(dja_response['data']), - len([k for k in self.entries - if k.body_text is not None and - len(k.body_text) == 0])) + self.assertEqual( + len(dja_response["data"]), + len( + [ + k + for k in self.entries + if k.body_text is not None and len(k.body_text) == 0 + ] + ), + ) def test_filter_related(self): """ filter via a relationship chain """ - response = self.client.get(self.url, data={'filter[blog.name]': 'ANTB'}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"filter[blog.name]": "ANTB"}) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(len(dja_response['data']), - len([k for k in self.entries - if k.blog.name == 'ANTB'])) + self.assertEqual( + len(dja_response["data"]), + len([k for k in self.entries if k.blog.name == "ANTB"]), + ) def test_filter_related_fieldset_class(self): """ @@ -184,133 +210,164 @@ def test_filter_related_fieldset_class(self): This tests a shortcut for a longer ORM path: `bname` is a shortcut name for `blog.name`. """ - response = self.client.get(self.fs_url, data={'filter[bname]': 'ANTB'}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + response = self.client.get(self.fs_url, data={"filter[bname]": "ANTB"}) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(len(dja_response['data']), - len([k for k in self.entries - if k.blog.name == 'ANTB'])) + self.assertEqual( + len(dja_response["data"]), + len([k for k in self.entries if k.blog.name == "ANTB"]), + ) def test_filter_related_missing_fieldset_class(self): """ filter via with neither filterset_fields nor filterset_class This should return an error for any filter[] """ - response = self.client.get(self.no_fs_url, data={'filter[bname]': 'ANTB'}) - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) + response = self.client.get(self.no_fs_url, data={"filter[bname]": "ANTB"}) + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "invalid filter[bname]") + self.assertEqual(dja_response["errors"][0]["detail"], "invalid filter[bname]") def test_filter_fields_union_list(self): """ test field for a list of values(ORed): ?filter[field.in]': 'val1,val2,val3 """ - response = self.client.get(self.url, - data={'filter[headline.in]': 'CLCV2442V,XXX,BIOL3594X'}) + response = self.client.get( + self.url, data={"filter[headline.in]": "CLCV2442V,XXX,BIOL3594X"} + ) dja_response = response.json() - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) self.assertEqual( - len(dja_response['data']), - len([k for k in self.entries if k.headline == 'CLCV2442V']) + - len([k for k in self.entries if k.headline == 'XXX']) + - len([k for k in self.entries if k.headline == 'BIOL3594X']), - msg="filter field list (union)") + response.status_code, 200, msg=response.content.decode("utf-8") + ) + self.assertEqual( + len(dja_response["data"]), + len([k for k in self.entries if k.headline == "CLCV2442V"]) + + len([k for k in self.entries if k.headline == "XXX"]) + + len([k for k in self.entries if k.headline == "BIOL3594X"]), + msg="filter field list (union)", + ) def test_filter_fields_intersection(self): """ test fields (ANDed): ?filter[field1]': 'val1&filter[field2]'='val2 """ # - response = self.client.get(self.url, - data={'filter[headline.regex]': '^A', - 'filter[body_text.icontains]': 'in'}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + response = self.client.get( + self.url, + data={"filter[headline.regex]": "^A", "filter[body_text.icontains]": "in"}, + ) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertGreater(len(dja_response['data']), 1) + self.assertGreater(len(dja_response["data"]), 1) self.assertEqual( - len(dja_response['data']), - len([k for k in self.entries if k.headline.startswith('A') and - 'in' in k.body_text.lower()])) + len(dja_response["data"]), + len( + [ + k + for k in self.entries + if k.headline.startswith("A") and "in" in k.body_text.lower() + ] + ), + ) def test_filter_invalid_association_name(self): """ test for filter with invalid filter association name """ - response = self.client.get(self.url, data={'filter[nonesuch]': 'CHEM3271X'}) - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"filter[nonesuch]": "CHEM3271X"}) + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "invalid filter[nonesuch]") + self.assertEqual( + dja_response["errors"][0]["detail"], "invalid filter[nonesuch]" + ) def test_filter_empty_association_name(self): """ test for filter with missing association name error texts are different depending on whether QueryParameterValidationFilter is in use. """ - response = self.client.get(self.url, data={'filter[]': 'foobar'}) - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"filter[]": "foobar"}) + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], "invalid query parameter: filter[]") + self.assertEqual( + dja_response["errors"][0]["detail"], "invalid query parameter: filter[]" + ) def test_filter_no_brackets(self): """ test for `filter=foobar` with missing filter[association] name """ - response = self.client.get(self.url, data={'filter': 'foobar'}) - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"filter": "foobar"}) + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "invalid query parameter: filter") + self.assertEqual( + dja_response["errors"][0]["detail"], "invalid query parameter: filter" + ) def test_filter_missing_right_bracket(self): """ test for filter missing right bracket """ - response = self.client.get(self.url, data={'filter[headline': 'foobar'}) - self.assertEqual(response.status_code, 400, msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"filter[headline": "foobar"}) + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "invalid query parameter: filter[headline") + self.assertEqual( + dja_response["errors"][0]["detail"], + "invalid query parameter: filter[headline", + ) def test_filter_no_brackets_rvalue(self): """ test for `filter=` with missing filter[association] and value """ - response = self.client.get(self.url + '?filter=') - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url + "?filter=") + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "invalid query parameter: filter") + self.assertEqual( + dja_response["errors"][0]["detail"], "invalid query parameter: filter" + ) def test_filter_no_brackets_equal(self): """ test for `filter` with missing filter[association] name and =value """ - response = self.client.get(self.url + '?filter') - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url + "?filter") + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "invalid query parameter: filter") + self.assertEqual( + dja_response["errors"][0]["detail"], "invalid query parameter: filter" + ) def test_filter_malformed_left_bracket(self): """ test for filter with invalid filter syntax """ - response = self.client.get(self.url, data={'filter[': 'foobar'}) - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"filter[": "foobar"}) + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], "invalid query parameter: filter[") + self.assertEqual( + dja_response["errors"][0]["detail"], "invalid query parameter: filter[" + ) def test_filter_missing_rvalue(self): """ @@ -318,24 +375,30 @@ def test_filter_missing_rvalue(self): this should probably be an error rather than ignoring the filter: https://django-filter.readthedocs.io/en/latest/guide/tips.html#filtering-by-an-empty-string """ - response = self.client.get(self.url, data={'filter[headline]': ''}) - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"filter[headline]": ""}) + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "missing value for query parameter filter[headline]") + self.assertEqual( + dja_response["errors"][0]["detail"], + "missing value for query parameter filter[headline]", + ) def test_filter_missing_rvalue_equal(self): """ test for filter with missing value to test against this should probably be an error rather than ignoring the filter: """ - response = self.client.get(self.url + '?filter[headline]') - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url + "?filter[headline]") + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "missing value for query parameter filter[headline]") + self.assertEqual( + dja_response["errors"][0]["detail"], + "missing value for query parameter filter[headline]", + ) def test_filter_single_relation(self): """ @@ -343,13 +406,14 @@ def test_filter_single_relation(self): e.g. filterset-entries?filter[authors.id]=1 looks for entries written by (at least) author.id=1 """ - response = self.client.get(self.fs_url, data={'filter[authors.id]': 1}) + response = self.client.get(self.fs_url, data={"filter[authors.id]": 1}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - ids = [k['id'] for k in dja_response['data']] + ids = [k["id"] for k in dja_response["data"]] expected_ids = [str(k.id) for k in self.entries.filter(authors__id=1)] @@ -361,15 +425,18 @@ def test_filter_repeated_relations(self): e.g. filterset-entries?filter[authors.id]=1&filter[authors.id]=2 looks for entries written by (at least) author.id=1 AND author.id=2 """ - response = self.client.get(self.fs_url, data={'filter[authors.id]': [1, 2]}) + response = self.client.get(self.fs_url, data={"filter[authors.id]": [1, 2]}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - ids = [k['id'] for k in dja_response['data']] + ids = [k["id"] for k in dja_response["data"]] - expected_ids = [str(k.id) for k in self.entries.filter(authors__id=1).filter(authors__id=2)] + expected_ids = [ + str(k.id) for k in self.entries.filter(authors__id=1).filter(authors__id=2) + ] self.assertEqual(set(ids), set(expected_ids)) @@ -379,13 +446,14 @@ def test_filter_in(self): e.g. filterset-entries?filter[authors.id.in]=1,2 looks for entries written by (at least) author.id=1 OR author.id=2 """ - response = self.client.get(self.fs_url, data={'filter[authors.id.in]': '1,2'}) + response = self.client.get(self.fs_url, data={"filter[authors.id.in]": "1,2"}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - ids = [k['id'] for k in dja_response['data']] + ids = [k["id"] for k in dja_response["data"]] expected_ids = [str(k.id) for k in self.entries.filter(authors__id__in=[1, 2])] @@ -396,82 +464,70 @@ def test_search_keywords(self): test for `filter[search]="keywords"` where some of the keywords are in the entry and others are in the related blog. """ - response = self.client.get(self.url, data={'filter[search]': 'barnard field research'}) + response = self.client.get( + self.url, data={"filter[search]": "barnard field research"} + ) expected_result = { - 'data': [ + "data": [ { - 'type': 'posts', - 'id': '7', - 'attributes': { - 'headline': 'ANTH3868X', - 'bodyText': 'ETHNOGRAPHIC FIELD RESEARCH IN NYC', - 'pubDate': None, - 'modDate': None}, - 'relationships': { - 'blog': { - 'data': { - 'type': 'blogs', - 'id': '1' + "type": "posts", + "id": "7", + "attributes": { + "headline": "ANTH3868X", + "bodyText": "ETHNOGRAPHIC FIELD RESEARCH IN NYC", + "pubDate": None, + "modDate": None, + }, + "relationships": { + "blog": {"data": {"type": "blogs", "id": "1"}}, + "blogHyperlinked": { + "links": { + "self": "http://testserver/entries/7/relationships/blog_hyperlinked", # noqa: E501 + "related": "http://testserver/entries/7/blog", } }, - 'blogHyperlinked': { - 'links': { - 'self': 'http://testserver/entries/7/relationships/blog_hyperlinked', # noqa: E501 - 'related': 'http://testserver/entries/7/blog'} - }, - 'authors': { - 'meta': { - 'count': 0 - }, - 'data': [] - }, - 'comments': { - 'meta': { - 'count': 0 - }, - 'data': [] - }, - 'commentsHyperlinked': { - 'links': { - 'self': 'http://testserver/entries/7/relationships/comments_hyperlinked', # noqa: E501 - 'related': 'http://testserver/entries/7/comments' + "authors": {"meta": {"count": 0}, "data": []}, + "comments": {"meta": {"count": 0}, "data": []}, + "commentsHyperlinked": { + "links": { + "self": "http://testserver/entries/7/relationships/comments_hyperlinked", # noqa: E501 + "related": "http://testserver/entries/7/comments", } }, - 'suggested': { - 'links': { - 'self': 'http://testserver/entries/7/relationships/suggested', - 'related': 'http://testserver/entries/7/suggested/' + "suggested": { + "links": { + "self": "http://testserver/entries/7/relationships/suggested", + "related": "http://testserver/entries/7/suggested/", }, - 'data': [ - {'type': 'entries', 'id': '1'}, - {'type': 'entries', 'id': '2'}, - {'type': 'entries', 'id': '3'}, - {'type': 'entries', 'id': '4'}, - {'type': 'entries', 'id': '5'}, - {'type': 'entries', 'id': '6'}, - {'type': 'entries', 'id': '8'}, - {'type': 'entries', 'id': '9'}, - {'type': 'entries', 'id': '10'}, - {'type': 'entries', 'id': '11'}, - {'type': 'entries', 'id': '12'} - ] + "data": [ + {"type": "entries", "id": "1"}, + {"type": "entries", "id": "2"}, + {"type": "entries", "id": "3"}, + {"type": "entries", "id": "4"}, + {"type": "entries", "id": "5"}, + {"type": "entries", "id": "6"}, + {"type": "entries", "id": "8"}, + {"type": "entries", "id": "9"}, + {"type": "entries", "id": "10"}, + {"type": "entries", "id": "11"}, + {"type": "entries", "id": "12"}, + ], }, - 'suggestedHyperlinked': { - 'links': { - 'self': 'http://testserver/entries/7/relationships/suggested_hyperlinked', # noqa: E501 - 'related': 'http://testserver/entries/7/suggested/'} + "suggestedHyperlinked": { + "links": { + "self": "http://testserver/entries/7/relationships/suggested_hyperlinked", # noqa: E501 + "related": "http://testserver/entries/7/suggested/", + } }, - 'tags': {'data': [], 'meta': {'count': 0}}, - 'featuredHyperlinked': { - 'links': { - 'self': 'http://testserver/entries/7/relationships/featured_hyperlinked', # noqa: E501 - 'related': 'http://testserver/entries/7/featured' + "tags": {"data": [], "meta": {"count": 0}}, + "featuredHyperlinked": { + "links": { + "self": "http://testserver/entries/7/relationships/featured_hyperlinked", # noqa: E501 + "related": "http://testserver/entries/7/featured", } - } + }, }, - 'meta': { - 'bodyFormat': 'text' - } + "meta": {"bodyFormat": "text"}, } ] } @@ -497,48 +553,68 @@ def test_search_multiple_keywords(self): See `example/fixtures/blogentry.json` for the test content that the searches are based on. The searches test for both direct entries and related blogs across multiple fields. """ - for searches in ("research", "chemistry", "nonesuch", - "research seminar", "research nonesuch", - "barnard classic", "barnard ethnographic field research"): - response = self.client.get(self.url, data={'filter[search]': searches}) - self.assertEqual(response.status_code, 200, msg=response.content.decode("utf-8")) + for searches in ( + "research", + "chemistry", + "nonesuch", + "research seminar", + "research nonesuch", + "barnard classic", + "barnard ethnographic field research", + ): + response = self.client.get(self.url, data={"filter[search]": searches}) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() keys = searches.split() # dicts keyed by the search keys for the 4 search_fields: - headline = {} # list of entry ids where key is in entry__headline - body_text = {} # list of entry ids where key is in entry__body_text - blog_name = {} # list of entry ids where key is in entry__blog__name + headline = {} # list of entry ids where key is in entry__headline + body_text = {} # list of entry ids where key is in entry__body_text + blog_name = {} # list of entry ids where key is in entry__blog__name blog_tagline = {} # list of entry ids where key is in entry__blog__tagline for key in keys: - headline[key] = [str(k.id) for k in - self.entries.filter(headline__icontains=key)] - body_text[key] = [str(k.id) for k in - self.entries.filter(body_text__icontains=key)] - blog_name[key] = [str(k.id) for k in - self.entries.filter(blog__name__icontains=key)] - blog_tagline[key] = [str(k.id) for k in - self.entries.filter(blog__tagline__icontains=key)] + headline[key] = [ + str(k.id) for k in self.entries.filter(headline__icontains=key) + ] + body_text[key] = [ + str(k.id) for k in self.entries.filter(body_text__icontains=key) + ] + blog_name[key] = [ + str(k.id) for k in self.entries.filter(blog__name__icontains=key) + ] + blog_tagline[key] = [ + str(k.id) for k in self.entries.filter(blog__tagline__icontains=key) + ] union = [] # each list item is a set of entry ids matching the given key for key in keys: - union.append(set(headline[key] + body_text[key] + - blog_name[key] + blog_tagline[key])) + union.append( + set( + headline[key] + + body_text[key] + + blog_name[key] + + blog_tagline[key] + ) + ) # all keywords must be present: intersect the keyword sets expected_ids = set.intersection(*union) expected_len = len(expected_ids) - self.assertEqual(len(dja_response['data']), expected_len) - returned_ids = set([k['id'] for k in dja_response['data']]) + self.assertEqual(len(dja_response["data"]), expected_len) + returned_ids = set([k["id"] for k in dja_response["data"]]) self.assertEqual(returned_ids, expected_ids) def test_param_invalid(self): """ Test a "wrong" query parameter """ - response = self.client.get(self.url, data={'garbage': 'foo'}) - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) + response = self.client.get(self.url, data={"garbage": "foo"}) + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "invalid query parameter: garbage") + self.assertEqual( + dja_response["errors"][0]["detail"], "invalid query parameter: garbage" + ) def test_param_duplicate_sort(self): """ @@ -546,38 +622,48 @@ def test_param_duplicate_sort(self): `?sort=headline&page[size]=3&sort=bodyText` is not allowed. This is not so obvious when using a data dict.... """ - response = self.client.get(self.url, - data={'sort': ['headline', 'bodyText'], - 'page[size]': 3} - ) - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) + response = self.client.get( + self.url, data={"sort": ["headline", "bodyText"], "page[size]": 3} + ) + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "repeated query parameter not allowed: sort") + self.assertEqual( + dja_response["errors"][0]["detail"], + "repeated query parameter not allowed: sort", + ) def test_param_duplicate_page(self): """ test a duplicated page[size] query parameter """ - response = self.client.get(self.fs_url, data={'page[size]': [1, 2]}) - self.assertEqual(response.status_code, 400, - msg=response.content.decode("utf-8")) + response = self.client.get(self.fs_url, data={"page[size]": [1, 2]}) + self.assertEqual( + response.status_code, 400, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(dja_response['errors'][0]['detail'], - "repeated query parameter not allowed: page[size]") + self.assertEqual( + dja_response["errors"][0]["detail"], + "repeated query parameter not allowed: page[size]", + ) def test_many_params(self): """ Test that filter params aren't ignored when many params are present """ - response = self.client.get(self.url, - data={'filter[headline.regex]': '^A', - 'filter[body_text.regex]': '^IN', - 'filter[blog.name]': 'ANTB', - 'page[size]': 3}) - self.assertEqual(response.status_code, 200, - msg=response.content.decode("utf-8")) + response = self.client.get( + self.url, + data={ + "filter[headline.regex]": "^A", + "filter[body_text.regex]": "^IN", + "filter[blog.name]": "ANTB", + "page[size]": 3, + }, + ) + self.assertEqual( + response.status_code, 200, msg=response.content.decode("utf-8") + ) dja_response = response.json() - self.assertEqual(len(dja_response['data']), 1) - self.assertEqual(dja_response['data'][0]['id'], '1') + self.assertEqual(len(dja_response["data"]), 1) + self.assertEqual(dja_response["data"][0]["id"], "1") diff --git a/example/tests/test_format_keys.py b/example/tests/test_format_keys.py index 0fd76c67..23a8a325 100644 --- a/example/tests/test_format_keys.py +++ b/example/tests/test_format_keys.py @@ -10,11 +10,12 @@ class FormatKeysSetTests(TestBase): """ Test that camelization and underscoring of key names works if they are activated. """ - list_url = reverse('user-list') + + list_url = reverse("user-list") def setUp(self): super(FormatKeysSetTests, self).setUp() - self.detail_url = reverse('user-detail', kwargs={'pk': self.miles.pk}) + self.detail_url = reverse("user-detail", kwargs={"pk": self.miles.pk}) def test_camelization(self): """ @@ -25,39 +26,42 @@ def test_camelization(self): user = get_user_model().objects.all()[0] expected = { - 'data': [ + "data": [ { - 'type': 'users', - 'id': encoding.force_str(user.pk), - 'attributes': { - 'firstName': user.first_name, - 'lastName': user.last_name, - 'email': user.email + "type": "users", + "id": encoding.force_str(user.pk), + "attributes": { + "firstName": user.first_name, + "lastName": user.last_name, + "email": user.email, }, } ], - 'links': { - 'first': 'http://testserver/identities?page%5Bnumber%5D=1', - 'last': 'http://testserver/identities?page%5Bnumber%5D=2', - 'next': 'http://testserver/identities?page%5Bnumber%5D=2', - 'prev': None + "links": { + "first": "http://testserver/identities?page%5Bnumber%5D=1", + "last": "http://testserver/identities?page%5Bnumber%5D=2", + "next": "http://testserver/identities?page%5Bnumber%5D=2", + "prev": None, }, - 'meta': { - 'pagination': { - 'page': 1, - 'pages': 2, - 'count': 2 - } - } + "meta": {"pagination": {"page": 1, "pages": 2, "count": 2}}, } assert expected == response.json() def test_options_format_field_names(db, client): - response = client.options(reverse('author-list')) + response = client.options(reverse("author-list")) assert response.status_code == status.HTTP_200_OK - data = response.json()['data'] - expected_keys = {'name', 'email', 'bio', 'entries', 'firstEntry', 'type', - 'comments', 'secrets', 'defaults'} - assert expected_keys == data['actions']['POST'].keys() + data = response.json()["data"] + expected_keys = { + "name", + "email", + "bio", + "entries", + "firstEntry", + "type", + "comments", + "secrets", + "defaults", + } + assert expected_keys == data["actions"]["POST"].keys() diff --git a/example/tests/test_generic_validation.py b/example/tests/test_generic_validation.py index ff2aff4b..99d2d09a 100644 --- a/example/tests/test_generic_validation.py +++ b/example/tests/test_generic_validation.py @@ -10,7 +10,7 @@ class GenericValidationTest(TestBase): def setUp(self): super(GenericValidationTest, self).setUp() - self.url = reverse('user-validation', kwargs={'pk': self.miles.pk}) + self.url = reverse("user-validation", kwargs={"pk": self.miles.pk}) def test_generic_validation_error(self): """ @@ -20,14 +20,14 @@ def test_generic_validation_error(self): self.assertEqual(response.status_code, 400) expected = { - 'errors': [{ - 'status': '400', - 'source': { - 'pointer': '/data' - }, - 'detail': 'Oh nohs!', - 'code': 'invalid', - }] + "errors": [ + { + "status": "400", + "source": {"pointer": "/data"}, + "detail": "Oh nohs!", + "code": "invalid", + } + ] } assert expected == response.json() diff --git a/example/tests/test_generic_viewset.py b/example/tests/test_generic_viewset.py index ba315740..a07eb610 100644 --- a/example/tests/test_generic_viewset.py +++ b/example/tests/test_generic_viewset.py @@ -13,17 +13,17 @@ def test_default_rest_framework_behavior(self): """ This is more of an example really, showing default behavior """ - url = reverse('user-default', kwargs={'pk': self.miles.pk}) + url = reverse("user-default", kwargs={"pk": self.miles.pk}) response = self.client.get(url) self.assertEqual(200, response.status_code) expected = { - 'id': 2, - 'first_name': 'Miles', - 'last_name': 'Davis', - 'email': 'miles@example.com' + "id": 2, + "first_name": "Miles", + "last_name": "Davis", + "email": "miles@example.com", } assert expected == response.json() @@ -33,21 +33,21 @@ def test_ember_expected_renderer(self): The :class:`UserEmber` ViewSet has the ``resource_name`` of 'data' so that should be the key in the JSON response. """ - url = reverse('user-manual-resource-name', kwargs={'pk': self.miles.pk}) + url = reverse("user-manual-resource-name", kwargs={"pk": self.miles.pk}) - with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'): + with override_settings(JSON_API_FORMAT_FIELD_NAMES="dasherize"): response = self.client.get(url) self.assertEqual(200, response.status_code) expected = { - 'data': { - 'type': 'data', - 'id': '2', - 'attributes': { - 'first-name': 'Miles', - 'last-name': 'Davis', - 'email': 'miles@example.com' - } + "data": { + "type": "data", + "id": "2", + "attributes": { + "first-name": "Miles", + "last-name": "Davis", + "email": "miles@example.com", + }, } } @@ -58,34 +58,38 @@ def test_default_validation_exceptions(self): Default validation exceptions should conform to json api spec """ expected = { - 'errors': [ + "errors": [ { - 'status': '400', - 'source': { - 'pointer': '/data/attributes/email', + "status": "400", + "source": { + "pointer": "/data/attributes/email", }, - 'detail': 'Enter a valid email address.', - 'code': 'invalid', + "detail": "Enter a valid email address.", + "code": "invalid", }, { - 'status': '400', - 'source': { - 'pointer': '/data/attributes/first-name', + "status": "400", + "source": { + "pointer": "/data/attributes/first-name", }, - 'detail': 'There\'s a problem with first name', - 'code': 'invalid', - } + "detail": "There's a problem with first name", + "code": "invalid", + }, ] } - with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'): - response = self.client.post('/identities', { - 'data': { - 'type': 'users', - 'attributes': { - 'email': 'bar', 'first_name': 'alajflajaljalajlfjafljalj' + with override_settings(JSON_API_FORMAT_FIELD_NAMES="dasherize"): + response = self.client.post( + "/identities", + { + "data": { + "type": "users", + "attributes": { + "email": "bar", + "first_name": "alajflajaljalajlfjafljalj", + }, } - } - }) + }, + ) assert expected == response.json() @@ -94,30 +98,34 @@ def test_custom_validation_exceptions(self): Exceptions should be able to be formatted manually """ expected = { - 'errors': [ + "errors": [ { - 'status': '400', - 'source': { - 'pointer': '/data/attributes/email', + "status": "400", + "source": { + "pointer": "/data/attributes/email", }, - 'detail': 'Enter a valid email address.', - 'code': 'invalid', + "detail": "Enter a valid email address.", + "code": "invalid", }, { - 'id': 'armageddon101', - 'detail': 'Hey! You need a last name!', - 'meta': 'something', - 'source': {'pointer': '/data/attributes/lastName'} + "id": "armageddon101", + "detail": "Hey! You need a last name!", + "meta": "something", + "source": {"pointer": "/data/attributes/lastName"}, }, ] } - response = self.client.post('/identities', { - 'data': { - 'type': 'users', - 'attributes': { - 'email': 'bar', 'last_name': 'alajflajaljalajlfjafljalj' + response = self.client.post( + "/identities", + { + "data": { + "type": "users", + "attributes": { + "email": "bar", + "last_name": "alajflajaljalajlfjafljalj", + }, } - } - }) + }, + ) assert expected == response.json() diff --git a/example/tests/test_model_viewsets.py b/example/tests/test_model_viewsets.py index 1ce8336d..9d4a610f 100644 --- a/example/tests/test_model_viewsets.py +++ b/example/tests/test_model_viewsets.py @@ -15,46 +15,41 @@ class ModelViewSetTests(TestBase): [, [^/]+)/$>] """ - list_url = reverse('user-list') + + list_url = reverse("user-list") def setUp(self): super(ModelViewSetTests, self).setUp() - self.detail_url = reverse('user-detail', kwargs={'pk': self.miles.pk}) + self.detail_url = reverse("user-detail", kwargs={"pk": self.miles.pk}) def test_key_in_list_result(self): """ Ensure the result has a 'user' key since that is the name of the model """ - with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'): + with override_settings(JSON_API_FORMAT_FIELD_NAMES="dasherize"): response = self.client.get(self.list_url) self.assertEqual(response.status_code, 200) user = get_user_model().objects.all()[0] expected = { - 'data': [ + "data": [ { - 'type': 'users', - 'id': encoding.force_str(user.pk), - 'attributes': { - 'first-name': user.first_name, - 'last-name': user.last_name, - 'email': user.email + "type": "users", + "id": encoding.force_str(user.pk), + "attributes": { + "first-name": user.first_name, + "last-name": user.last_name, + "email": user.email, }, } ], - 'links': { - 'first': 'http://testserver/identities?page%5Bnumber%5D=1', - 'last': 'http://testserver/identities?page%5Bnumber%5D=2', - 'next': 'http://testserver/identities?page%5Bnumber%5D=2', - 'prev': None + "links": { + "first": "http://testserver/identities?page%5Bnumber%5D=1", + "last": "http://testserver/identities?page%5Bnumber%5D=2", + "next": "http://testserver/identities?page%5Bnumber%5D=2", + "prev": None, }, - 'meta': { - 'pagination': { - 'page': 1, - 'pages': 2, - 'count': 2 - } - } + "meta": {"pagination": {"page": 1, "pages": 2, "count": 2}}, } assert expected == response.json() @@ -63,36 +58,30 @@ def test_page_two_in_list_result(self): """ Ensure that the second page is reachable and is the correct data. """ - with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'): - response = self.client.get(self.list_url, {'page[number]': 2}) + with override_settings(JSON_API_FORMAT_FIELD_NAMES="dasherize"): + response = self.client.get(self.list_url, {"page[number]": 2}) self.assertEqual(response.status_code, 200) user = get_user_model().objects.all()[1] expected = { - 'data': [ + "data": [ { - 'type': 'users', - 'id': encoding.force_str(user.pk), - 'attributes': { - 'first-name': user.first_name, - 'last-name': user.last_name, - 'email': user.email + "type": "users", + "id": encoding.force_str(user.pk), + "attributes": { + "first-name": user.first_name, + "last-name": user.last_name, + "email": user.email, }, } ], - 'links': { - 'first': 'http://testserver/identities?page%5Bnumber%5D=1', - 'last': 'http://testserver/identities?page%5Bnumber%5D=2', - 'next': None, - 'prev': 'http://testserver/identities?page%5Bnumber%5D=1' + "links": { + "first": "http://testserver/identities?page%5Bnumber%5D=1", + "last": "http://testserver/identities?page%5Bnumber%5D=2", + "next": None, + "prev": "http://testserver/identities?page%5Bnumber%5D=1", }, - 'meta': { - 'pagination': { - 'page': 2, - 'pages': 2, - 'count': 2 - } - } + "meta": {"pagination": {"page": 2, "pages": 2, "count": 2}}, } assert expected == response.json() @@ -103,45 +92,39 @@ def test_page_range_in_list_result(self): tests pluralization as two objects means it converts ``user`` to ``users``. """ - with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'): - response = self.client.get(self.list_url, {'page[size]': 2}) + with override_settings(JSON_API_FORMAT_FIELD_NAMES="dasherize"): + response = self.client.get(self.list_url, {"page[size]": 2}) self.assertEqual(response.status_code, 200) users = get_user_model().objects.all() expected = { - 'data': [ + "data": [ { - 'type': 'users', - 'id': encoding.force_str(users[0].pk), - 'attributes': { - 'first-name': users[0].first_name, - 'last-name': users[0].last_name, - 'email': users[0].email + "type": "users", + "id": encoding.force_str(users[0].pk), + "attributes": { + "first-name": users[0].first_name, + "last-name": users[0].last_name, + "email": users[0].email, }, }, { - 'type': 'users', - 'id': encoding.force_str(users[1].pk), - 'attributes': { - 'first-name': users[1].first_name, - 'last-name': users[1].last_name, - 'email': users[1].email + "type": "users", + "id": encoding.force_str(users[1].pk), + "attributes": { + "first-name": users[1].first_name, + "last-name": users[1].last_name, + "email": users[1].email, }, - } + }, ], - 'links': { - 'first': 'http://testserver/identities?page%5Bnumber%5D=1&page%5Bsize%5D=2', - 'last': 'http://testserver/identities?page%5Bnumber%5D=1&page%5Bsize%5D=2', - 'next': None, - 'prev': None + "links": { + "first": "http://testserver/identities?page%5Bnumber%5D=1&page%5Bsize%5D=2", + "last": "http://testserver/identities?page%5Bnumber%5D=1&page%5Bsize%5D=2", + "next": None, + "prev": None, }, - 'meta': { - 'pagination': { - 'page': 1, - 'pages': 1, - 'count': 2 - } - } + "meta": {"pagination": {"page": 1, "pages": 1, "count": 2}}, } assert expected == response.json() @@ -150,18 +133,18 @@ def test_key_in_detail_result(self): """ Ensure the result has a 'user' key. """ - with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'): + with override_settings(JSON_API_FORMAT_FIELD_NAMES="dasherize"): response = self.client.get(self.detail_url) self.assertEqual(response.status_code, 200) expected = { - 'data': { - 'type': 'users', - 'id': encoding.force_str(self.miles.pk), - 'attributes': { - 'first-name': self.miles.first_name, - 'last-name': self.miles.last_name, - 'email': self.miles.email + "data": { + "type": "users", + "id": encoding.force_str(self.miles.pk), + "attributes": { + "first-name": self.miles.first_name, + "last-name": self.miles.last_name, + "email": self.miles.email, }, } } @@ -173,12 +156,7 @@ def test_patch_requires_id(self): Verify that 'id' is required to be passed in an update request. """ data = { - 'data': { - 'type': 'users', - 'attributes': { - 'first-name': 'DifferentName' - } - } + "data": {"type": "users", "attributes": {"first-name": "DifferentName"}} } response = self.client.patch(self.detail_url, data=data) @@ -190,12 +168,10 @@ def test_patch_requires_correct_id(self): Verify that 'id' is the same then in url """ data = { - 'data': { - 'type': 'users', - 'id': self.miles.pk + 1, - 'attributes': { - 'first-name': 'DifferentName' - } + "data": { + "type": "users", + "id": self.miles.pk + 1, + "attributes": {"first-name": "DifferentName"}, } } @@ -207,36 +183,34 @@ def test_key_in_post(self): """ Ensure a key is in the post. """ - self.client.login(username='miles', password='pw') + self.client.login(username="miles", password="pw") data = { - 'data': { - 'type': 'users', - 'id': encoding.force_str(self.miles.pk), - 'attributes': { - 'first-name': self.miles.first_name, - 'last-name': self.miles.last_name, - 'email': 'miles@trumpet.org' + "data": { + "type": "users", + "id": encoding.force_str(self.miles.pk), + "attributes": { + "first-name": self.miles.first_name, + "last-name": self.miles.last_name, + "email": "miles@trumpet.org", }, } } - with override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize'): + with override_settings(JSON_API_FORMAT_FIELD_NAMES="dasherize"): response = self.client.put(self.detail_url, data=data) assert data == response.json() # is it updated? self.assertEqual( - get_user_model().objects.get(pk=self.miles.pk).email, - 'miles@trumpet.org') + get_user_model().objects.get(pk=self.miles.pk).email, "miles@trumpet.org" + ) def test_404_error_pointer(self): - self.client.login(username='miles', password='pw') - not_found_url = reverse('user-detail', kwargs={'pk': 12345}) + self.client.login(username="miles", password="pw") + not_found_url = reverse("user-detail", kwargs={"pk": 12345}) errors = { - 'errors': [ - {'detail': 'Not found.', 'status': '404', 'code': 'not_found'} - ] + "errors": [{"detail": "Not found.", "status": "404", "code": "not_found"}] } response = self.client.get(not_found_url) @@ -250,18 +224,13 @@ def test_patch_allow_field_type(author, author_type_factory, client): Verify that type field may be updated. """ author_type = author_type_factory() - url = reverse('author-detail', args=[author.id]) + url = reverse("author-detail", args=[author.id]) data = { - 'data': { - 'id': author.id, - 'type': 'authors', - 'relationships': { - 'data': { - 'id': author_type.id, - 'type': 'author-type' - } - } + "data": { + "id": author.id, + "type": "authors", + "relationships": {"data": {"id": author_type.id, "type": "author-type"}}, } } diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index e7a2b6ca..d32a9367 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -23,14 +23,11 @@ def create_view_with_kw(view_cls, method, request, initkwargs): def test_path_without_parameters(snapshot): - path = '/authors/' - method = 'GET' + path = "/authors/" + method = "GET" view = create_view_with_kw( - views.AuthorViewSet, - method, - create_request(path), - {'get': 'list'} + views.AuthorViewSet, method, create_request(path), {"get": "list"} ) inspector = AutoSchema() inspector.view = view @@ -40,14 +37,11 @@ def test_path_without_parameters(snapshot): def test_path_with_id_parameter(snapshot): - path = '/authors/{id}/' - method = 'GET' + path = "/authors/{id}/" + method = "GET" view = create_view_with_kw( - views.AuthorViewSet, - method, - create_request(path), - {'get': 'retrieve'} + views.AuthorViewSet, method, create_request(path), {"get": "retrieve"} ) inspector = AutoSchema() inspector.view = view @@ -57,14 +51,11 @@ def test_path_with_id_parameter(snapshot): def test_post_request(snapshot): - method = 'POST' - path = '/authors/' + method = "POST" + path = "/authors/" view = create_view_with_kw( - views.AuthorViewSet, - method, - create_request(path), - {'post': 'create'} + views.AuthorViewSet, method, create_request(path), {"post": "create"} ) inspector = AutoSchema() inspector.view = view @@ -74,14 +65,11 @@ def test_post_request(snapshot): def test_patch_request(snapshot): - method = 'PATCH' - path = '/authors/{id}' + method = "PATCH" + path = "/authors/{id}" view = create_view_with_kw( - views.AuthorViewSet, - method, - create_request(path), - {'patch': 'update'} + views.AuthorViewSet, method, create_request(path), {"patch": "update"} ) inspector = AutoSchema() inspector.view = view @@ -91,14 +79,11 @@ def test_patch_request(snapshot): def test_delete_request(snapshot): - method = 'DELETE' - path = '/authors/{id}' + method = "DELETE" + path = "/authors/{id}" view = create_view_with_kw( - views.AuthorViewSet, - method, - create_request(path), - {'delete': 'delete'} + views.AuthorViewSet, method, create_request(path), {"delete": "delete"} ) inspector = AutoSchema() inspector.view = view @@ -107,22 +92,25 @@ def test_delete_request(snapshot): snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) -@override_settings(REST_FRAMEWORK={ - 'DEFAULT_SCHEMA_CLASS': 'rest_framework_json_api.schemas.openapi.AutoSchema'}) +@override_settings( + REST_FRAMEWORK={ + "DEFAULT_SCHEMA_CLASS": "rest_framework_json_api.schemas.openapi.AutoSchema" + } +) def test_schema_construction(): """Construction of the top level dictionary.""" patterns = [ - re_path('^authors/?$', views.AuthorViewSet.as_view({'get': 'list'})), + re_path("^authors/?$", views.AuthorViewSet.as_view({"get": "list"})), ] generator = SchemaGenerator(patterns=patterns) - request = create_request('/') + request = create_request("/") schema = generator.get_schema(request=request) - assert 'openapi' in schema - assert 'info' in schema - assert 'paths' in schema - assert 'components' in schema + assert "openapi" in schema + assert "info" in schema + assert "paths" in schema + assert "components" in schema def test_schema_related_serializers(): @@ -135,15 +123,15 @@ def test_schema_related_serializers(): and confirm that the schema for the related field is properly rendered """ generator = SchemaGenerator() - request = create_request('/') + request = create_request("/") schema = generator.get_schema(request=request) # make sure the path's relationship and related {related_field}'s got expanded - assert '/authors/{id}/relationships/{related_field}' in schema['paths'] - assert '/authors/{id}/comments/' in schema['paths'] - assert '/authors/{id}/entries/' in schema['paths'] - assert '/authors/{id}/first_entry/' in schema['paths'] - first_get = schema['paths']['/authors/{id}/first_entry/']['get']['responses']['200'] - first_schema = first_get['content']['application/vnd.api+json']['schema'] - first_props = first_schema['properties']['data'] - assert '$ref' in first_props - assert first_props['$ref'] == '#/components/schemas/Entry' + assert "/authors/{id}/relationships/{related_field}" in schema["paths"] + assert "/authors/{id}/comments/" in schema["paths"] + assert "/authors/{id}/entries/" in schema["paths"] + assert "/authors/{id}/first_entry/" in schema["paths"] + first_get = schema["paths"]["/authors/{id}/first_entry/"]["get"]["responses"]["200"] + first_schema = first_get["content"]["application/vnd.api+json"]["schema"] + first_props = first_schema["properties"]["data"] + assert "$ref" in first_props + assert first_props["$ref"] == "#/components/schemas/Entry" diff --git a/example/tests/test_parsers.py b/example/tests/test_parsers.py index 38d7c6d7..b83f70a7 100644 --- a/example/tests/test_parsers.py +++ b/example/tests/test_parsers.py @@ -14,47 +14,41 @@ class TestJSONParser(TestCase): - def setUp(self): class MockRequest(object): - def __init__(self): - self.method = 'GET' + self.method = "GET" request = MockRequest() - self.parser_context = {'request': request, 'kwargs': {}, 'view': 'BlogViewSet'} + self.parser_context = {"request": request, "kwargs": {}, "view": "BlogViewSet"} data = { - 'data': { - 'id': 123, - 'type': 'Blog', - 'attributes': { - 'json-value': {'JsonKey': 'JsonValue'} - }, + "data": { + "id": 123, + "type": "Blog", + "attributes": {"json-value": {"JsonKey": "JsonValue"}}, }, - 'meta': { - 'random_key': 'random_value' - } + "meta": {"random_key": "random_value"}, } self.string = json.dumps(data) - @override_settings(JSON_API_FORMAT_FIELD_NAMES='dasherize') + @override_settings(JSON_API_FORMAT_FIELD_NAMES="dasherize") def test_parse_include_metadata_format_field_names(self): parser = JSONParser() - stream = BytesIO(self.string.encode('utf-8')) + stream = BytesIO(self.string.encode("utf-8")) data = parser.parse(stream, None, self.parser_context) - self.assertEqual(data['_meta'], {'random_key': 'random_value'}) - self.assertEqual(data['json_value'], {'JsonKey': 'JsonValue'}) + self.assertEqual(data["_meta"], {"random_key": "random_value"}) + self.assertEqual(data["json_value"], {"JsonKey": "JsonValue"}) def test_parse_invalid_data(self): parser = JSONParser() string = json.dumps([]) - stream = BytesIO(string.encode('utf-8')) + stream = BytesIO(string.encode("utf-8")) with self.assertRaises(ParseError): parser.parse(stream, None, self.parser_context) @@ -62,16 +56,18 @@ def test_parse_invalid_data(self): def test_parse_invalid_data_key(self): parser = JSONParser() - string = json.dumps({ - 'data': [{ - 'id': 123, - 'type': 'Blog', - 'attributes': { - 'json-value': {'JsonKey': 'JsonValue'} - }, - }] - }) - stream = BytesIO(string.encode('utf-8')) + string = json.dumps( + { + "data": [ + { + "id": 123, + "type": "Blog", + "attributes": {"json-value": {"JsonKey": "JsonValue"}}, + } + ] + } + ) + stream = BytesIO(string.encode("utf-8")) with self.assertRaises(ParseError): parser.parse(stream, None, self.parser_context) @@ -84,7 +80,7 @@ def __init__(self, response_dict): @property def pk(self): - return self.id if hasattr(self, 'id') else None + return self.id if hasattr(self, "id") else None class DummySerializer(serializers.Serializer): @@ -95,7 +91,7 @@ class DummySerializer(serializers.Serializer): class DummyAPIView(views.APIView): parser_classes = [JSONParser] renderer_classes = [JSONRenderer] - resource_name = 'dummy' + resource_name = "dummy" def patch(self, request, *args, **kwargs): serializer = DummySerializer(DummyDTO(request.data)) @@ -103,28 +99,25 @@ def patch(self, request, *args, **kwargs): urlpatterns = [ - path('repeater', DummyAPIView.as_view(), name='repeater'), + path("repeater", DummyAPIView.as_view(), name="repeater"), ] class TestParserOnAPIView(APITestCase): - def setUp(self): class MockRequest(object): def __init__(self): - self.method = 'PATCH' + self.method = "PATCH" request = MockRequest() # To be honest view string isn't resolved into actual view - self.parser_context = {'request': request, 'kwargs': {}, 'view': 'DummyAPIView'} + self.parser_context = {"request": request, "kwargs": {}, "view": "DummyAPIView"} self.data = { - 'data': { - 'id': 123, - 'type': 'strs', - 'attributes': { - 'body': 'hello' - }, + "data": { + "id": 123, + "type": "strs", + "attributes": {"body": "hello"}, } } @@ -133,20 +126,20 @@ def __init__(self): def test_patch_doesnt_raise_attribute_error(self): parser = JSONParser() - stream = BytesIO(self.string.encode('utf-8')) + stream = BytesIO(self.string.encode("utf-8")) data = parser.parse(stream, None, self.parser_context) - assert data['id'] == 123 - assert data['body'] == 'hello' + assert data["id"] == 123 + assert data["body"] == "hello" @override_settings(ROOT_URLCONF=__name__) def test_patch_request(self): - url = reverse('repeater') + url = reverse("repeater") data = self.data - data['data']['type'] = 'dummy' + data["data"]["type"] = "dummy" response = self.client.patch(url, data=data) data = response.json() - assert data['data']['id'] == str(123) - assert data['data']['attributes']['body'] == 'hello' + assert data["data"]["id"] == str(123) + assert data["data"]["attributes"]["body"] == "hello" diff --git a/example/tests/test_performance.py b/example/tests/test_performance.py index ac8b5956..e42afada 100644 --- a/example/tests/test_performance.py +++ b/example/tests/test_performance.py @@ -7,45 +7,49 @@ class PerformanceTestCase(APITestCase): def setUp(self): - self.author = Author.objects.create(name='Super powerful superhero', email='i.am@lost.com') - self.blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") - self.other_blog = Blog.objects.create(name='Other blog', tagline="It's another blog") + self.author = Author.objects.create( + name="Super powerful superhero", email="i.am@lost.com" + ) + self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") + self.other_blog = Blog.objects.create( + name="Other blog", tagline="It's another blog" + ) self.first_entry = Entry.objects.create( blog=self.blog, - headline='headline one', - body_text='body_text two', + headline="headline one", + body_text="body_text two", pub_date=timezone.now(), mod_date=timezone.now(), n_comments=0, n_pingbacks=0, - rating=3 + rating=3, ) self.second_entry = Entry.objects.create( blog=self.blog, - headline='headline two', - body_text='body_text one', + headline="headline two", + body_text="body_text one", pub_date=timezone.now(), mod_date=timezone.now(), n_comments=0, n_pingbacks=0, - rating=1 + rating=1, ) self.comment = Comment.objects.create(entry=self.first_entry) CommentFactory.create_batch(50) def test_query_count_no_includes(self): - """ We expect a simple list view to issue only two queries. + """We expect a simple list view to issue only two queries. 1. The number of results in the set (e.g. a COUNT query), only necessary because we're using PageNumberPagination 2. The SELECT query for the set """ with self.assertNumQueries(2): - response = self.client.get('/comments?page[size]=25') - self.assertEqual(len(response.data['results']), 25) + response = self.client.get("/comments?page[size]=25") + self.assertEqual(len(response.data["results"]), 25) def test_query_count_include_author(self): - """ We expect a list view with an include have three queries: + """We expect a list view with an include have three queries: 1. Primary resource COUNT query 2. Primary resource SELECT @@ -54,15 +58,15 @@ def test_query_count_include_author(self): 5. Entries prefetched """ with self.assertNumQueries(5): - response = self.client.get('/comments?include=author&page[size]=25') - self.assertEqual(len(response.data['results']), 25) + response = self.client.get("/comments?include=author&page[size]=25") + self.assertEqual(len(response.data["results"]), 25) def test_query_select_related_entry(self): - """ We expect a list view with an include have two queries: + """We expect a list view with an include have two queries: 1. Primary resource COUNT query 2. Primary resource SELECT + SELECT RELATED writer(author) and bio """ with self.assertNumQueries(2): - response = self.client.get('/comments?include=writer&page[size]=25') - self.assertEqual(len(response.data['results']), 25) + response = self.client.get("/comments?include=writer&page[size]=25") + self.assertEqual(len(response.data["results"]), 25) diff --git a/example/tests/test_relations.py b/example/tests/test_relations.py index ef1dfb02..b83bbef4 100644 --- a/example/tests/test_relations.py +++ b/example/tests/test_relations.py @@ -10,7 +10,7 @@ from rest_framework_json_api.relations import ( HyperlinkedRelatedField, ResourceRelatedField, - SerializerMethodHyperlinkedRelatedField + SerializerMethodHyperlinkedRelatedField, ) from rest_framework_json_api.utils import format_resource_type @@ -21,76 +21,66 @@ class TestResourceRelatedField(TestBase): - def setUp(self): super(TestResourceRelatedField, self).setUp() - self.blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") + self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") self.entry = Entry.objects.create( blog=self.blog, - headline='headline', - body_text='body_text', + headline="headline", + body_text="body_text", pub_date=timezone.now(), mod_date=timezone.now(), n_comments=0, n_pingbacks=0, - rating=3 + rating=3, ) for i in range(1, 6): - name = 'some_author{}'.format(i) + name = "some_author{}".format(i) self.entry.authors.add( - Author.objects.create(name=name, email='{}@example.org'.format(name)) + Author.objects.create(name=name, email="{}@example.org".format(name)) ) self.comment = Comment.objects.create( entry=self.entry, - body='testing one two three', - author=Author.objects.first() + body="testing one two three", + author=Author.objects.first(), ) def test_data_in_correct_format_when_instantiated_with_blog_object(self): - serializer = BlogFKSerializer(instance={'blog': self.blog}) + serializer = BlogFKSerializer(instance={"blog": self.blog}) - expected_data = { - 'type': format_resource_type('Blog'), - 'id': str(self.blog.id) - } + expected_data = {"type": format_resource_type("Blog"), "id": str(self.blog.id)} - actual_data = serializer.data['blog'] + actual_data = serializer.data["blog"] self.assertEqual(actual_data, expected_data) def test_data_in_correct_format_when_instantiated_with_entry_object(self): - serializer = EntryFKSerializer(instance={'entry': self.entry}) + serializer = EntryFKSerializer(instance={"entry": self.entry}) expected_data = { - 'type': format_resource_type('Entry'), - 'id': str(self.entry.id) + "type": format_resource_type("Entry"), + "id": str(self.entry.id), } - actual_data = serializer.data['entry'] + actual_data = serializer.data["entry"] self.assertEqual(actual_data, expected_data) def test_deserialize_primitive_data_blog(self): - serializer = BlogFKSerializer(data={ - 'blog': { - 'type': format_resource_type('Blog'), - 'id': str(self.blog.id) + serializer = BlogFKSerializer( + data={ + "blog": {"type": format_resource_type("Blog"), "id": str(self.blog.id)} } - } ) self.assertTrue(serializer.is_valid()) - self.assertEqual(serializer.validated_data['blog'], self.blog) + self.assertEqual(serializer.validated_data["blog"], self.blog) def test_validation_fails_for_wrong_type(self): with self.assertRaises(Conflict) as cm: - serializer = BlogFKSerializer(data={ - 'blog': { - 'type': 'Entries', - 'id': str(self.blog.id) - } - } + serializer = BlogFKSerializer( + data={"blog": {"type": "Entries", "id": str(self.blog.id)}} ) serializer.is_valid() the_exception = cm.exception @@ -99,150 +89,156 @@ def test_validation_fails_for_wrong_type(self): def test_serialize_many_to_many_relation(self): serializer = EntryModelSerializer(instance=self.entry) - type_string = format_resource_type('Author') - author_pks = Author.objects.values_list('pk', flat=True) - expected_data = [{'type': type_string, 'id': str(pk)} for pk in author_pks] + type_string = format_resource_type("Author") + author_pks = Author.objects.values_list("pk", flat=True) + expected_data = [{"type": type_string, "id": str(pk)} for pk in author_pks] - self.assertEqual( - serializer.data['authors'], - expected_data - ) + self.assertEqual(serializer.data["authors"], expected_data) def test_deserialize_many_to_many_relation(self): - type_string = format_resource_type('Author') - author_pks = Author.objects.values_list('pk', flat=True) - authors = [{'type': type_string, 'id': pk} for pk in author_pks] + type_string = format_resource_type("Author") + author_pks = Author.objects.values_list("pk", flat=True) + authors = [{"type": type_string, "id": pk} for pk in author_pks] - serializer = EntryModelSerializer(data={'authors': authors, 'comments': []}) + serializer = EntryModelSerializer(data={"authors": authors, "comments": []}) self.assertTrue(serializer.is_valid()) - self.assertEqual(len(serializer.validated_data['authors']), Author.objects.count()) - for author in serializer.validated_data['authors']: + self.assertEqual( + len(serializer.validated_data["authors"]), Author.objects.count() + ) + for author in serializer.validated_data["authors"]: self.assertIsInstance(author, Author) def test_read_only(self): serializer = EntryModelSerializer( - data={'authors': [], 'comments': [{'type': 'Comments', 'id': 2}]} + data={"authors": [], "comments": [{"type": "Comments", "id": 2}]} ) serializer.is_valid(raise_exception=True) - self.assertNotIn('comments', serializer.validated_data) + self.assertNotIn("comments", serializer.validated_data) def test_invalid_resource_id_object(self): - comment = {'body': 'testing 123', 'entry': {'type': 'entry'}, 'author': {'id': '5'}} + comment = { + "body": "testing 123", + "entry": {"type": "entry"}, + "author": {"id": "5"}, + } serializer = CommentSerializer(data=comment) assert not serializer.is_valid() assert serializer.errors == { - 'author': ["Invalid resource identifier object: missing 'type' attribute"], - 'entry': ["Invalid resource identifier object: missing 'id' attribute"] + "author": ["Invalid resource identifier object: missing 'type' attribute"], + "entry": ["Invalid resource identifier object: missing 'id' attribute"], } class TestHyperlinkedFieldBase(TestBase): - def setUp(self): super(TestHyperlinkedFieldBase, self).setUp() - self.blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") + self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") self.entry = Entry.objects.create( blog=self.blog, - headline='headline', - body_text='body_text', + headline="headline", + body_text="body_text", pub_date=timezone.now(), mod_date=timezone.now(), n_comments=0, n_pingbacks=0, - rating=3 + rating=3, ) self.comment = Comment.objects.create( entry=self.entry, - body='testing one two three', + body="testing one two three", ) - self.request = RequestFactory().get(reverse('entry-detail', kwargs={'pk': self.entry.pk})) - self.view = EntryViewSet(request=self.request, kwargs={'entry_pk': self.entry.id}) + self.request = RequestFactory().get( + reverse("entry-detail", kwargs={"pk": self.entry.pk}) + ) + self.view = EntryViewSet( + request=self.request, kwargs={"entry_pk": self.entry.id} + ) class TestHyperlinkedRelatedField(TestHyperlinkedFieldBase): - def test_single_hyperlinked_related_field(self): field = HyperlinkedRelatedField( - related_link_view_name='entry-blog', - related_link_url_kwarg='entry_pk', - self_link_view_name='entry-relationships', + related_link_view_name="entry-blog", + related_link_url_kwarg="entry_pk", + self_link_view_name="entry-relationships", read_only=True, ) - field._context = {'request': self.request, 'view': self.view} - field.field_name = 'blog' + field._context = {"request": self.request, "view": self.view} + field.field_name = "blog" self.assertRaises(NotImplementedError, field.to_representation, self.entry) self.assertRaises(SkipField, field.get_attribute, self.entry) links_expected = { - 'self': 'http://testserver/entries/{}/relationships/blog'.format(self.entry.pk), - 'related': 'http://testserver/entries/{}/blog'.format(self.entry.pk) + "self": "http://testserver/entries/{}/relationships/blog".format( + self.entry.pk + ), + "related": "http://testserver/entries/{}/blog".format(self.entry.pk), } got = field.get_links(self.entry) self.assertEqual(got, links_expected) def test_many_hyperlinked_related_field(self): field = HyperlinkedRelatedField( - related_link_view_name='entry-comments', - related_link_url_kwarg='entry_pk', - self_link_view_name='entry-relationships', + related_link_view_name="entry-comments", + related_link_url_kwarg="entry_pk", + self_link_view_name="entry-relationships", read_only=True, - many=True + many=True, ) - field._context = {'request': self.request, 'view': self.view} - field.field_name = 'comments' + field._context = {"request": self.request, "view": self.view} + field.field_name = "comments" - self.assertRaises(NotImplementedError, field.to_representation, self.entry.comments.all()) + self.assertRaises( + NotImplementedError, field.to_representation, self.entry.comments.all() + ) self.assertRaises(SkipField, field.get_attribute, self.entry) links_expected = { - 'self': 'http://testserver/entries/{}/relationships/comments'.format(self.entry.pk), - 'related': 'http://testserver/entries/{}/comments'.format(self.entry.pk) + "self": "http://testserver/entries/{}/relationships/comments".format( + self.entry.pk + ), + "related": "http://testserver/entries/{}/comments".format(self.entry.pk), } got = field.child_relation.get_links(self.entry) self.assertEqual(got, links_expected) class TestSerializerMethodHyperlinkedRelatedField(TestHyperlinkedFieldBase): - def test_single_serializer_method_hyperlinked_related_field(self): serializer = EntryModelSerializerWithHyperLinks( - instance=self.entry, - context={ - 'request': self.request, - 'view': self.view - } + instance=self.entry, context={"request": self.request, "view": self.view} ) - field = serializer.fields['blog'] + field = serializer.fields["blog"] self.assertRaises(NotImplementedError, field.to_representation, self.entry) self.assertRaises(SkipField, field.get_attribute, self.entry) expected = { - 'self': 'http://testserver/entries/{}/relationships/blog'.format(self.entry.pk), - 'related': 'http://testserver/entries/{}/blog'.format(self.entry.pk) + "self": "http://testserver/entries/{}/relationships/blog".format( + self.entry.pk + ), + "related": "http://testserver/entries/{}/blog".format(self.entry.pk), } got = field.get_links(self.entry) self.assertEqual(got, expected) def test_many_serializer_method_hyperlinked_related_field(self): serializer = EntryModelSerializerWithHyperLinks( - instance=self.entry, - context={ - 'request': self.request, - 'view': self.view - } + instance=self.entry, context={"request": self.request, "view": self.view} ) - field = serializer.fields['comments'] + field = serializer.fields["comments"] self.assertRaises(NotImplementedError, field.to_representation, self.entry) self.assertRaises(SkipField, field.get_attribute, self.entry) expected = { - 'self': 'http://testserver/entries/{}/relationships/comments'.format(self.entry.pk), - 'related': 'http://testserver/entries/{}/comments'.format(self.entry.pk) + "self": "http://testserver/entries/{}/relationships/comments".format( + self.entry.pk + ), + "related": "http://testserver/entries/{}/comments".format(self.entry.pk), } got = field.get_links(self.entry) self.assertEqual(got, expected) @@ -281,26 +277,29 @@ class EntryModelSerializer(serializers.ModelSerializer): class Meta: model = Entry - fields = ('authors', 'comments') + fields = ("authors", "comments") class EntryModelSerializerWithHyperLinks(serializers.ModelSerializer): blog = SerializerMethodHyperlinkedRelatedField( - related_link_view_name='entry-blog', - related_link_url_kwarg='entry_pk', - self_link_view_name='entry-relationships', + related_link_view_name="entry-blog", + related_link_url_kwarg="entry_pk", + self_link_view_name="entry-relationships", many=True, ) comments = SerializerMethodHyperlinkedRelatedField( - related_link_view_name='entry-comments', - related_link_url_kwarg='entry_pk', - self_link_view_name='entry-relationships', + related_link_view_name="entry-comments", + related_link_url_kwarg="entry_pk", + self_link_view_name="entry-relationships", many=True, ) class Meta: model = Entry - fields = ('blog', 'comments',) + fields = ( + "blog", + "comments", + ) def get_blog(self, obj): return obj.blog diff --git a/example/tests/test_serializers.py b/example/tests/test_serializers.py index de97dcf8..b3b1dc2d 100644 --- a/example/tests/test_serializers.py +++ b/example/tests/test_serializers.py @@ -11,7 +11,7 @@ DateField, ModelSerializer, ResourceIdentifierObjectSerializer, - empty + empty, ) from rest_framework_json_api.utils import format_resource_type @@ -25,37 +25,40 @@ class TestResourceIdentifierObjectSerializer(TestCase): def setUp(self): - self.blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") + self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") now = timezone.now() self.entry = Entry.objects.create( blog=self.blog, - headline='headline', - body_text='body_text', + headline="headline", + body_text="body_text", pub_date=now.date(), mod_date=now.date(), n_comments=0, n_pingbacks=0, - rating=3 + rating=3, ) for i in range(1, 6): - name = 'some_author{}'.format(i) + name = "some_author{}".format(i) self.entry.authors.add( - Author.objects.create(name=name, email='{}@example.org'.format(name)) + Author.objects.create(name=name, email="{}@example.org".format(name)) ) def test_forward_relationship_not_loaded_when_not_included(self): - to_representation_method = 'example.serializers.TaggedItemSerializer.to_representation' + to_representation_method = ( + "example.serializers.TaggedItemSerializer.to_representation" + ) with mock.patch(to_representation_method) as mocked_serializer: + class EntrySerializer(ModelSerializer): blog = BlogSerializer() class Meta: model = Entry - fields = '__all__' + fields = "__all__" - request_without_includes = Request(request_factory.get('/')) - serializer = EntrySerializer(context={'request': request_without_includes}) + request_without_includes = Request(request_factory.get("/")) + serializer = EntrySerializer(context={"request": request_without_includes}) serializer.to_representation(self.entry) mocked_serializer.assert_not_called() @@ -66,62 +69,74 @@ class EntrySerializer(ModelSerializer): class Meta: model = Entry - fields = '__all__' + fields = "__all__" - request_without_includes = Request(request_factory.get('/')) - serializer = EntrySerializer(context={'request': request_without_includes}) + request_without_includes = Request(request_factory.get("/")) + serializer = EntrySerializer(context={"request": request_without_includes}) result = serializer.to_representation(self.entry) # Remove non deterministic fields - result.pop('created_at') - result.pop('modified_at') + result.pop("created_at") + result.pop("modified_at") expected = dict( [ - ('id', 1), - ('blog', dict([ - ('name', 'Some Blog'), - ('tags', []), - ('copyright', 2020), - ('url', 'http://testserver/blogs/1') - ])), - ('headline', 'headline'), - ('body_text', 'body_text'), - ('pub_date', DateField().to_representation(self.entry.pub_date)), - ('mod_date', DateField().to_representation(self.entry.mod_date)), - ('n_comments', 0), - ('n_pingbacks', 0), - ('rating', 3), - ('authors', + ("id", 1), + ( + "blog", + dict( + [ + ("name", "Some Blog"), + ("tags", []), + ("copyright", 2020), + ("url", "http://testserver/blogs/1"), + ] + ), + ), + ("headline", "headline"), + ("body_text", "body_text"), + ("pub_date", DateField().to_representation(self.entry.pub_date)), + ("mod_date", DateField().to_representation(self.entry.mod_date)), + ("n_comments", 0), + ("n_pingbacks", 0), + ("rating", 3), + ( + "authors", [ - dict([('type', 'authors'), ('id', '1')]), - dict([('type', 'authors'), ('id', '2')]), - dict([('type', 'authors'), ('id', '3')]), - dict([('type', 'authors'), ('id', '4')]), - dict([('type', 'authors'), ('id', '5')])])]) + dict([("type", "authors"), ("id", "1")]), + dict([("type", "authors"), ("id", "2")]), + dict([("type", "authors"), ("id", "3")]), + dict([("type", "authors"), ("id", "4")]), + dict([("type", "authors"), ("id", "5")]), + ], + ), + ] + ) self.assertDictEqual(expected, result) def test_data_in_correct_format_when_instantiated_with_blog_object(self): serializer = ResourceIdentifierObjectSerializer(instance=self.blog) - expected_data = {'type': format_resource_type('Blog'), 'id': str(self.blog.id)} + expected_data = {"type": format_resource_type("Blog"), "id": str(self.blog.id)} assert serializer.data == expected_data def test_data_in_correct_format_when_instantiated_with_entry_object(self): serializer = ResourceIdentifierObjectSerializer(instance=self.entry) - expected_data = {'type': format_resource_type('Entry'), 'id': str(self.entry.id)} + expected_data = { + "type": format_resource_type("Entry"), + "id": str(self.entry.id), + } assert serializer.data == expected_data def test_deserialize_primitive_data_blog(self): - initial_data = { - 'type': format_resource_type('Blog'), - 'id': str(self.blog.id) - } - serializer = ResourceIdentifierObjectSerializer(data=initial_data, model_class=Blog) + initial_data = {"type": format_resource_type("Blog"), "id": str(self.blog.id)} + serializer = ResourceIdentifierObjectSerializer( + data=initial_data, model_class=Blog + ) self.assertTrue(serializer.is_valid(), msg=serializer.errors) assert serializer.validated_data == self.blog @@ -131,29 +146,28 @@ def test_deserialize_primitive_data_blog_with_unexisting_pk(self): self.blog.delete() assert not Blog.objects.filter(id=unexisting_pk).exists() - initial_data = { - 'type': format_resource_type('Blog'), - 'id': str(unexisting_pk) - } - serializer = ResourceIdentifierObjectSerializer(data=initial_data, model_class=Blog) + initial_data = {"type": format_resource_type("Blog"), "id": str(unexisting_pk)} + serializer = ResourceIdentifierObjectSerializer( + data=initial_data, model_class=Blog + ) self.assertFalse(serializer.is_valid()) - self.assertEqual(serializer.errors[0].code, 'does_not_exist') + self.assertEqual(serializer.errors[0].code, "does_not_exist") def test_data_in_correct_format_when_instantiated_with_queryset(self): qs = Author.objects.all() serializer = ResourceIdentifierObjectSerializer(instance=qs, many=True) - type_string = format_resource_type('Author') - author_pks = Author.objects.values_list('pk', flat=True) - expected_data = [{'type': type_string, 'id': str(pk)} for pk in author_pks] + type_string = format_resource_type("Author") + author_pks = Author.objects.values_list("pk", flat=True) + expected_data = [{"type": type_string, "id": str(pk)} for pk in author_pks] assert serializer.data == expected_data def test_deserialize_many(self): - type_string = format_resource_type('Author') - author_pks = Author.objects.values_list('pk', flat=True) - initial_data = [{'type': type_string, 'id': str(pk)} for pk in author_pks] + type_string = format_resource_type("Author") + author_pks = Author.objects.values_list("pk", flat=True) + initial_data = [{"type": type_string, "id": str(pk)} for pk in author_pks] serializer = ResourceIdentifierObjectSerializer( data=initial_data, model_class=Author, many=True @@ -170,33 +184,20 @@ def test_model_serializer_with_implicit_fields(self, comment, client): "data": { "type": "comments", "id": str(comment.pk), - "attributes": { - "body": comment.body - }, + "attributes": {"body": comment.body}, "relationships": { - "entry": { - "data": { - "type": "entries", - "id": str(comment.entry.pk) - } - }, + "entry": {"data": {"type": "entries", "id": str(comment.entry.pk)}}, "author": { - "data": { - "type": "authors", - "id": str(comment.author.pk) - } + "data": {"type": "authors", "id": str(comment.author.pk)} }, "writer": { - "data": { - "type": "writers", - "id": str(comment.author.pk) - } + "data": {"type": "writers", "id": str(comment.author.pk)} }, - } + }, } } - response = client.get(reverse("comment-detail", kwargs={'pk': comment.pk})) + response = client.get(reverse("comment-detail", kwargs={"pk": comment.pk})) assert response.status_code == 200 assert expected == response.json() diff --git a/example/tests/test_sideload_resources.py b/example/tests/test_sideload_resources.py index 69641af7..5ca96afe 100644 --- a/example/tests/test_sideload_resources.py +++ b/example/tests/test_sideload_resources.py @@ -13,7 +13,8 @@ class SideloadResourceTest(TestBase): """ Test that sideloading resources returns expected output. """ - url = reverse('user-posts') + + url = reverse("user-posts") def test_get_sideloaded_data(self): """ @@ -21,9 +22,9 @@ def test_get_sideloaded_data(self): do not return a single root key. """ response = self.client.get(self.url) - content = json.loads(response.content.decode('utf8')) + content = json.loads(response.content.decode("utf8")) self.assertEqual( sorted(content.keys()), - [encoding.force_str('identities'), - encoding.force_str('posts')]) + [encoding.force_str("identities"), encoding.force_str("posts")], + ) diff --git a/example/tests/test_views.py b/example/tests/test_views.py index 25eeca89..4b494b52 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -16,142 +16,164 @@ from example.factories import AuthorFactory, CommentFactory, EntryFactory from example.models import Author, Blog, Comment, Entry -from example.serializers import AuthorBioSerializer, AuthorTypeSerializer, EntrySerializer +from example.serializers import ( + AuthorBioSerializer, + AuthorTypeSerializer, + EntrySerializer, +) from example.tests import TestBase from example.views import AuthorViewSet, BlogViewSet class TestRelationshipView(APITestCase): def setUp(self): - self.author = Author.objects.create(name='Super powerful superhero', email='i.am@lost.com') - self.blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") - self.other_blog = Blog.objects.create(name='Other blog', tagline="It's another blog") + self.author = Author.objects.create( + name="Super powerful superhero", email="i.am@lost.com" + ) + self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") + self.other_blog = Blog.objects.create( + name="Other blog", tagline="It's another blog" + ) self.first_entry = Entry.objects.create( blog=self.blog, - headline='headline one', - body_text='body_text two', + headline="headline one", + body_text="body_text two", pub_date=timezone.now(), mod_date=timezone.now(), n_comments=0, n_pingbacks=0, - rating=3 + rating=3, ) self.second_entry = Entry.objects.create( blog=self.blog, - headline='headline two', - body_text='body_text one', + headline="headline two", + body_text="body_text one", pub_date=timezone.now(), mod_date=timezone.now(), n_comments=0, n_pingbacks=0, - rating=1 + rating=1, ) self.first_comment = Comment.objects.create( entry=self.first_entry, body="This entry is cool", author=None ) self.second_comment = Comment.objects.create( - entry=self.second_entry, - body="This entry is not cool", - author=self.author + entry=self.second_entry, body="This entry is not cool", author=self.author ) def test_get_entry_relationship_blog(self): url = reverse( - 'entry-relationships', kwargs={'pk': self.first_entry.id, 'related_field': 'blog'} + "entry-relationships", + kwargs={"pk": self.first_entry.id, "related_field": "blog"}, ) response = self.client.get(url) - expected_data = {'type': format_resource_type('Blog'), 'id': str(self.first_entry.blog.id)} + expected_data = { + "type": format_resource_type("Blog"), + "id": str(self.first_entry.blog.id), + } assert response.data == expected_data def test_get_entry_relationship_invalid_field(self): response = self.client.get( - '/entries/{}/relationships/invalid_field'.format(self.first_entry.id) + "/entries/{}/relationships/invalid_field".format(self.first_entry.id) ) assert response.status_code == 404 def test_get_blog_relationship_entry_set(self): - response = self.client.get('/blogs/{}/relationships/entry_set'.format(self.blog.id)) - expected_data = [{'type': format_resource_type('Entry'), 'id': str(self.first_entry.id)}, - {'type': format_resource_type('Entry'), 'id': str(self.second_entry.id)}] + response = self.client.get( + "/blogs/{}/relationships/entry_set".format(self.blog.id) + ) + expected_data = [ + {"type": format_resource_type("Entry"), "id": str(self.first_entry.id)}, + {"type": format_resource_type("Entry"), "id": str(self.second_entry.id)}, + ] assert response.data == expected_data def test_put_entry_relationship_blog_returns_405(self): - url = '/entries/{}/relationships/blog'.format(self.first_entry.id) + url = "/entries/{}/relationships/blog".format(self.first_entry.id) response = self.client.put(url, data={}) assert response.status_code == 405 def test_patch_invalid_entry_relationship_blog_returns_400(self): - url = '/entries/{}/relationships/blog'.format(self.first_entry.id) - response = self.client.patch(url, data={'data': {'invalid': ''}}) + url = "/entries/{}/relationships/blog".format(self.first_entry.id) + response = self.client.patch(url, data={"data": {"invalid": ""}}) assert response.status_code == 400 def test_relationship_view_errors_format(self): - url = '/entries/{}/relationships/blog'.format(self.first_entry.id) - response = self.client.patch(url, data={'data': {'invalid': ''}}) + url = "/entries/{}/relationships/blog".format(self.first_entry.id) + response = self.client.patch(url, data={"data": {"invalid": ""}}) assert response.status_code == 400 - result = json.loads(response.content.decode('utf-8')) + result = json.loads(response.content.decode("utf-8")) - assert 'data' not in result - assert 'errors' in result + assert "data" not in result + assert "errors" in result def test_get_empty_to_one_relationship(self): - url = '/comments/{}/relationships/author'.format(self.first_entry.id) + url = "/comments/{}/relationships/author".format(self.first_entry.id) response = self.client.get(url) expected_data = None assert response.data == expected_data def test_get_to_many_relationship_self_link(self): - url = '/authors/{}/relationships/comments'.format(self.author.id) + url = "/authors/{}/relationships/comments".format(self.author.id) response = self.client.get(url) expected_data = { - 'links': {'self': 'http://testserver/authors/1/relationships/comments'}, - 'data': [{'id': str(self.second_comment.id), 'type': format_resource_type('Comment')}] + "links": {"self": "http://testserver/authors/1/relationships/comments"}, + "data": [ + { + "id": str(self.second_comment.id), + "type": format_resource_type("Comment"), + } + ], } - assert json.loads(response.content.decode('utf-8')) == expected_data + assert json.loads(response.content.decode("utf-8")) == expected_data def test_patch_to_one_relationship(self): - url = '/entries/{}/relationships/blog'.format(self.first_entry.id) + url = "/entries/{}/relationships/blog".format(self.first_entry.id) request_data = { - 'data': {'type': format_resource_type('Blog'), 'id': str(self.other_blog.id)} + "data": { + "type": format_resource_type("Blog"), + "id": str(self.other_blog.id), + } } response = self.client.patch(url, data=request_data) assert response.status_code == 200, response.content.decode() - assert response.data == request_data['data'] + assert response.data == request_data["data"] response = self.client.get(url) - assert response.data == request_data['data'] + assert response.data == request_data["data"] def test_patch_one_to_many_relationship(self): - url = '/blogs/{}/relationships/entry_set'.format(self.first_entry.id) + url = "/blogs/{}/relationships/entry_set".format(self.first_entry.id) request_data = { - 'data': [{'type': format_resource_type('Entry'), 'id': str(self.first_entry.id)}, ] + "data": [ + {"type": format_resource_type("Entry"), "id": str(self.first_entry.id)}, + ] } response = self.client.patch(url, data=request_data) assert response.status_code == 200, response.content.decode() - assert response.data == request_data['data'] + assert response.data == request_data["data"] response = self.client.get(url) - assert response.data == request_data['data'] + assert response.data == request_data["data"] # retry a second time should end up with same result response = self.client.patch(url, data=request_data) assert response.status_code == 200, response.content.decode() - assert response.data == request_data['data'] + assert response.data == request_data["data"] response = self.client.get(url) - assert response.data == request_data['data'] + assert response.data == request_data["data"] def test_patch_one_to_many_relaitonship_with_none(self): - url = '/blogs/{}/relationships/entry_set'.format(self.first_entry.id) - request_data = { - 'data': None - } + url = "/blogs/{}/relationships/entry_set".format(self.first_entry.id) + request_data = {"data": None} response = self.client.patch(url, data=request_data) assert response.status_code == 200, response.content.decode() assert response.data == [] @@ -160,103 +182,127 @@ def test_patch_one_to_many_relaitonship_with_none(self): assert response.data == [] def test_patch_many_to_many_relationship(self): - url = '/entries/{}/relationships/authors'.format(self.first_entry.id) + url = "/entries/{}/relationships/authors".format(self.first_entry.id) request_data = { - 'data': [ - { - 'type': format_resource_type('Author'), - 'id': str(self.author.id) - }, + "data": [ + {"type": format_resource_type("Author"), "id": str(self.author.id)}, ] } response = self.client.patch(url, data=request_data) assert response.status_code == 200, response.content.decode() - assert response.data == request_data['data'] + assert response.data == request_data["data"] response = self.client.get(url) - assert response.data == request_data['data'] + assert response.data == request_data["data"] # retry a second time should end up with same result response = self.client.patch(url, data=request_data) assert response.status_code == 200, response.content.decode() - assert response.data == request_data['data'] + assert response.data == request_data["data"] response = self.client.get(url) - assert response.data == request_data['data'] + assert response.data == request_data["data"] def test_post_to_one_relationship_should_fail(self): - url = '/entries/{}/relationships/blog'.format(self.first_entry.id) + url = "/entries/{}/relationships/blog".format(self.first_entry.id) request_data = { - 'data': {'type': format_resource_type('Blog'), 'id': str(self.other_blog.id)} + "data": { + "type": format_resource_type("Blog"), + "id": str(self.other_blog.id), + } } response = self.client.post(url, data=request_data) assert response.status_code == 405, response.content.decode() def test_post_to_many_relationship_with_no_change(self): - url = '/entries/{}/relationships/comments'.format(self.first_entry.id) + url = "/entries/{}/relationships/comments".format(self.first_entry.id) request_data = { - 'data': [{'type': format_resource_type('Comment'), 'id': str(self.first_comment.id)}, ] + "data": [ + { + "type": format_resource_type("Comment"), + "id": str(self.first_comment.id), + }, + ] } response = self.client.post(url, data=request_data) assert response.status_code == 204, response.content.decode() assert len(response.rendered_content) == 0, response.rendered_content.decode() def test_post_to_many_relationship_with_change(self): - url = '/entries/{}/relationships/comments'.format(self.first_entry.id) + url = "/entries/{}/relationships/comments".format(self.first_entry.id) request_data = { - 'data': [{'type': format_resource_type('Comment'), 'id': str(self.second_comment.id)}, ] + "data": [ + { + "type": format_resource_type("Comment"), + "id": str(self.second_comment.id), + }, + ] } response = self.client.post(url, data=request_data) assert response.status_code == 200, response.content.decode() - assert request_data['data'][0] in response.data + assert request_data["data"][0] in response.data def test_delete_to_one_relationship_should_fail(self): - url = '/entries/{}/relationships/blog'.format(self.first_entry.id) + url = "/entries/{}/relationships/blog".format(self.first_entry.id) request_data = { - 'data': {'type': format_resource_type('Blog'), 'id': str(self.other_blog.id)} + "data": { + "type": format_resource_type("Blog"), + "id": str(self.other_blog.id), + } } response = self.client.delete(url, data=request_data) assert response.status_code == 405, response.content.decode() def test_delete_relationship_overriding_with_none(self): - url = '/comments/{}'.format(self.second_comment.id) + url = "/comments/{}".format(self.second_comment.id) request_data = { - 'data': { - 'type': 'comments', - 'id': self.second_comment.id, - 'relationships': { - 'author': { - 'data': None - } - } + "data": { + "type": "comments", + "id": self.second_comment.id, + "relationships": {"author": {"data": None}}, } } response = self.client.patch(url, data=request_data) assert response.status_code == 200, response.content.decode() - assert response.data['author'] is None + assert response.data["author"] is None def test_delete_to_many_relationship_with_no_change(self): - url = '/entries/{}/relationships/comments'.format(self.first_entry.id) + url = "/entries/{}/relationships/comments".format(self.first_entry.id) request_data = { - 'data': [{'type': format_resource_type('Comment'), 'id': str(self.second_comment.id)}, ] + "data": [ + { + "type": format_resource_type("Comment"), + "id": str(self.second_comment.id), + }, + ] } response = self.client.delete(url, data=request_data) assert response.status_code == 204, response.content.decode() assert len(response.rendered_content) == 0, response.rendered_content.decode() def test_delete_one_to_many_relationship_with_not_null_constraint(self): - url = '/entries/{}/relationships/comments'.format(self.first_entry.id) + url = "/entries/{}/relationships/comments".format(self.first_entry.id) request_data = { - 'data': [{'type': format_resource_type('Comment'), 'id': str(self.first_comment.id)}, ] + "data": [ + { + "type": format_resource_type("Comment"), + "id": str(self.first_comment.id), + }, + ] } response = self.client.delete(url, data=request_data) assert response.status_code == 409, response.content.decode() def test_delete_to_many_relationship_with_change(self): - url = '/authors/{}/relationships/comments'.format(self.author.id) + url = "/authors/{}/relationships/comments".format(self.author.id) request_data = { - 'data': [{'type': format_resource_type('Comment'), 'id': str(self.second_comment.id)}, ] + "data": [ + { + "type": format_resource_type("Comment"), + "id": str(self.second_comment.id), + }, + ] } response = self.client.delete(url, data=request_data) assert response.status_code == 200, response.content.decode() @@ -265,21 +311,19 @@ def test_new_comment_data_patch_to_many_relationship(self): entry = EntryFactory(blog=self.blog, authors=(self.author,)) comment = CommentFactory(entry=entry) - url = '/authors/{}/relationships/comments'.format(self.author.id) + url = "/authors/{}/relationships/comments".format(self.author.id) request_data = { - 'data': [{'type': format_resource_type('Comment'), 'id': str(comment.id)}, ] + "data": [ + {"type": format_resource_type("Comment"), "id": str(comment.id)}, + ] } previous_response = { - 'data': [ - {'type': 'comments', - 'id': str(self.second_comment.id) - } - ], - 'links': { - 'self': 'http://testserver/authors/{}/relationships/comments'.format( + "data": [{"type": "comments", "id": str(self.second_comment.id)}], + "links": { + "self": "http://testserver/authors/{}/relationships/comments".format( self.author.id ) - } + }, } response = self.client.get(url) @@ -287,16 +331,12 @@ def test_new_comment_data_patch_to_many_relationship(self): assert response.json() == previous_response new_patched_response = { - 'data': [ - {'type': 'comments', - 'id': str(comment.id) - } - ], - 'links': { - 'self': 'http://testserver/authors/{}/relationships/comments'.format( + "data": [{"type": "comments", "id": str(comment.id)}], + "links": { + "self": "http://testserver/authors/{}/relationships/comments".format( self.author.id ) - } + }, } response = self.client.patch(url, data=request_data) @@ -307,21 +347,19 @@ def test_new_comment_data_patch_to_many_relationship(self): def test_options_entry_relationship_blog(self): url = reverse( - 'entry-relationships', kwargs={'pk': self.first_entry.id, 'related_field': 'blog'} + "entry-relationships", + kwargs={"pk": self.first_entry.id, "related_field": "blog"}, ) response = self.client.options(url) expected_data = { "data": { "name": "Entry Relationship", "description": "", - "renders": [ - "application/vnd.api+json", - "text/html" - ], + "renders": ["application/vnd.api+json", "text/html"], "parses": [ "application/vnd.api+json", "application/x-www-form-urlencoded", - "multipart/form-data" + "multipart/form-data", ], "allowed_methods": [ "GET", @@ -329,99 +367,103 @@ def test_options_entry_relationship_blog(self): "PATCH", "DELETE", "HEAD", - "OPTIONS" + "OPTIONS", ], - "actions": { - "POST": {} - } + "actions": {"POST": {}}, } } assert response.json() == expected_data class TestRelatedMixin(APITestCase): - def setUp(self): self.author = AuthorFactory() def _get_view(self, kwargs): factory = APIRequestFactory() - request = Request(factory.get('', content_type='application/vnd.api+json')) + request = Request(factory.get("", content_type="application/vnd.api+json")) return AuthorViewSet(request=request, kwargs=kwargs) def test_get_related_field_name(self): - kwargs = {'pk': self.author.id, 'related_field': 'bio'} + kwargs = {"pk": self.author.id, "related_field": "bio"} view = self._get_view(kwargs) got = view.get_related_field_name() - self.assertEqual(got, kwargs['related_field']) + self.assertEqual(got, kwargs["related_field"]) def test_get_related_instance_serializer_field(self): - kwargs = {'pk': self.author.id, 'related_field': 'bio'} + kwargs = {"pk": self.author.id, "related_field": "bio"} view = self._get_view(kwargs) got = view.get_related_instance() self.assertEqual(got, self.author.bio) def test_get_related_instance_model_field(self): - kwargs = {'pk': self.author.id, 'related_field': 'id'} + kwargs = {"pk": self.author.id, "related_field": "id"} view = self._get_view(kwargs) got = view.get_related_instance() self.assertEqual(got, self.author.id) def test_get_related_serializer_class(self): - kwargs = {'pk': self.author.id, 'related_field': 'bio'} + kwargs = {"pk": self.author.id, "related_field": "bio"} view = self._get_view(kwargs) got = view.get_related_serializer_class() self.assertEqual(got, AuthorBioSerializer) def test_get_related_serializer_class_many(self): - kwargs = {'pk': self.author.id, 'related_field': 'entries'} + kwargs = {"pk": self.author.id, "related_field": "entries"} view = self._get_view(kwargs) got = view.get_related_serializer_class() self.assertEqual(got, EntrySerializer) def test_get_serializer_comes_from_included_serializers(self): - kwargs = {'pk': self.author.id, 'related_field': 'type'} + kwargs = {"pk": self.author.id, "related_field": "type"} view = self._get_view(kwargs) related_serializers = view.serializer_class.related_serializers - delattr(view.serializer_class, 'related_serializers') + delattr(view.serializer_class, "related_serializers") got = view.get_related_serializer_class() self.assertEqual(got, AuthorTypeSerializer) view.serializer_class.related_serializers = related_serializers def test_get_related_serializer_class_raises_error(self): - kwargs = {'pk': self.author.id, 'related_field': 'unknown'} + kwargs = {"pk": self.author.id, "related_field": "unknown"} view = self._get_view(kwargs) self.assertRaises(NotFound, view.get_related_serializer_class) def test_retrieve_related_single_reverse_lookup(self): - url = reverse('author-related', kwargs={'pk': self.author.pk, 'related_field': 'bio'}) + url = reverse( + "author-related", kwargs={"pk": self.author.pk, "related_field": "bio"} + ) resp = self.client.get(url) expected = { - 'data': { - 'type': 'authorBios', 'id': str(self.author.bio.id), - 'relationships': { - 'author': {'data': {'type': 'authors', 'id': str(self.author.id)}}, - 'metadata': {'data': {'id': str(self.author.bio.metadata.id), - 'type': 'authorBioMetadata'}} - }, - 'attributes': { - 'body': str(self.author.bio.body) + "data": { + "type": "authorBios", + "id": str(self.author.bio.id), + "relationships": { + "author": {"data": {"type": "authors", "id": str(self.author.id)}}, + "metadata": { + "data": { + "id": str(self.author.bio.metadata.id), + "type": "authorBioMetadata", + } + }, }, + "attributes": {"body": str(self.author.bio.body)}, } } self.assertEqual(resp.status_code, 200) self.assertEqual(resp.json(), expected) def test_retrieve_related_single(self): - url = reverse('author-related', kwargs={'pk': self.author.type.pk, 'related_field': 'type'}) + url = reverse( + "author-related", + kwargs={"pk": self.author.type.pk, "related_field": "type"}, + ) resp = self.client.get(url) expected = { - 'data': { - 'type': 'authorTypes', 'id': str(self.author.type.id), - 'attributes': { - 'name': str(self.author.type.name) - }, + "data": { + "type": "authorTypes", + "id": str(self.author.type.id), + "attributes": {"name": str(self.author.type.name)}, } } self.assertEqual(resp.status_code, 200) @@ -429,211 +471,219 @@ def test_retrieve_related_single(self): def test_retrieve_related_many(self): entry = EntryFactory(authors=self.author) - url = reverse('author-related', kwargs={'pk': self.author.pk, 'related_field': 'entries'}) + url = reverse( + "author-related", kwargs={"pk": self.author.pk, "related_field": "entries"} + ) resp = self.client.get(url) self.assertEqual(resp.status_code, 200) - self.assertTrue(isinstance(resp.json()['data'], list)) - self.assertEqual(len(resp.json()['data']), 1) - self.assertEqual(resp.json()['data'][0]['id'], str(entry.id)) + self.assertTrue(isinstance(resp.json()["data"], list)) + self.assertEqual(len(resp.json()["data"]), 1) + self.assertEqual(resp.json()["data"][0]["id"], str(entry.id)) def test_retrieve_related_many_hyperlinked(self): comment = CommentFactory(author=self.author) - url = reverse('author-related', kwargs={'pk': self.author.pk, 'related_field': 'comments'}) + url = reverse( + "author-related", kwargs={"pk": self.author.pk, "related_field": "comments"} + ) resp = self.client.get(url) self.assertEqual(resp.status_code, 200) - self.assertTrue(isinstance(resp.json()['data'], list)) - self.assertEqual(len(resp.json()['data']), 1) - self.assertEqual(resp.json()['data'][0]['id'], str(comment.id)) + self.assertTrue(isinstance(resp.json()["data"], list)) + self.assertEqual(len(resp.json()["data"]), 1) + self.assertEqual(resp.json()["data"][0]["id"], str(comment.id)) def test_retrieve_related_None(self): - kwargs = {'pk': self.author.pk, 'related_field': 'first_entry'} - url = reverse('author-related', kwargs=kwargs) + kwargs = {"pk": self.author.pk, "related_field": "first_entry"} + url = reverse("author-related", kwargs=kwargs) resp = self.client.get(url) self.assertEqual(resp.status_code, 200) - self.assertEqual(resp.json(), {'data': None}) + self.assertEqual(resp.json(), {"data": None}) class TestValidationErrorResponses(TestBase): def test_if_returns_error_on_empty_post(self): - view = BlogViewSet.as_view({'post': 'create'}) + view = BlogViewSet.as_view({"post": "create"}) response = self._get_create_response("{}", view) self.assertEqual(400, response.status_code) - expected = [{ - 'detail': 'Received document does not contain primary data', - 'status': '400', - 'source': {'pointer': '/data'}, - 'code': 'parse_error', - }] + expected = [ + { + "detail": "Received document does not contain primary data", + "status": "400", + "source": {"pointer": "/data"}, + "code": "parse_error", + } + ] self.assertEqual(expected, response.data) def test_if_returns_error_on_missing_form_data_post(self): - view = BlogViewSet.as_view({'post': 'create'}) - response = self._get_create_response('{"data":{"attributes":{},"type":"blogs"}}', view) + view = BlogViewSet.as_view({"post": "create"}) + response = self._get_create_response( + '{"data":{"attributes":{},"type":"blogs"}}', view + ) self.assertEqual(400, response.status_code) - expected = [{ - 'status': '400', - 'detail': 'This field is required.', - 'source': {'pointer': '/data/attributes/name'}, - 'code': 'required', - }] + expected = [ + { + "status": "400", + "detail": "This field is required.", + "source": {"pointer": "/data/attributes/name"}, + "code": "required", + } + ] self.assertEqual(expected, response.data) def test_if_returns_error_on_bad_endpoint_name(self): - view = BlogViewSet.as_view({'post': 'create'}) - response = self._get_create_response('{"data":{"attributes":{},"type":"bad"}}', view) + view = BlogViewSet.as_view({"post": "create"}) + response = self._get_create_response( + '{"data":{"attributes":{},"type":"bad"}}', view + ) self.assertEqual(409, response.status_code) - expected = [{ - 'detail': ( - "The resource object's type (bad) is not the type that constitute the collection " - "represented by the endpoint (blogs)." - ), - 'source': {'pointer': '/data'}, - 'status': '409', - 'code': 'error', - }] + expected = [ + { + "detail": ( + "The resource object's type (bad) is not the type that constitute the collection " + "represented by the endpoint (blogs)." + ), + "source": {"pointer": "/data"}, + "status": "409", + "code": "error", + } + ] self.assertEqual(expected, response.data) def _get_create_response(self, data, view): factory = RequestFactory() - request = factory.post('/', data, content_type='application/vnd.api+json') - user = self.create_user('user', 'pass') + request = factory.post("/", data, content_type="application/vnd.api+json") + user = self.create_user("user", "pass") force_authenticate(request, user) return view(request) class TestModelViewSet(TestBase): def setUp(self): - self.author = Author.objects.create(name='Super powerful superhero', email='i.am@lost.com') - self.blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") + self.author = Author.objects.create( + name="Super powerful superhero", email="i.am@lost.com" + ) + self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") def test_no_content_response(self): - url = '/blogs/{}'.format(self.blog.pk) + url = "/blogs/{}".format(self.blog.pk) response = self.client.delete(url) assert response.status_code == 204, response.rendered_content.decode() assert len(response.rendered_content) == 0, response.rendered_content.decode() class TestBlogViewSet(APITestCase): - def setUp(self): - self.blog = Blog.objects.create( - name='Some Blog', - tagline="It's a blog" - ) + self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") self.entry = Entry.objects.create( blog=self.blog, - headline='headline one', - body_text='body_text two', + headline="headline one", + body_text="body_text two", ) def test_get_object_gives_correct_blog(self): - url = reverse('entry-blog', kwargs={'entry_pk': self.entry.id}) + url = reverse("entry-blog", kwargs={"entry_pk": self.entry.id}) resp = self.client.get(url) expected = { - 'data': { - 'attributes': {'name': self.blog.name}, - 'id': '{}'.format(self.blog.id), - 'links': {'self': 'http://testserver/blogs/{}'.format(self.blog.id)}, - 'meta': {'copyright': datetime.now().year}, - 'relationships': {'tags': {'data': [], 'meta': {'count': 0}}}, - 'type': 'blogs' + "data": { + "attributes": {"name": self.blog.name}, + "id": "{}".format(self.blog.id), + "links": {"self": "http://testserver/blogs/{}".format(self.blog.id)}, + "meta": {"copyright": datetime.now().year}, + "relationships": {"tags": {"data": [], "meta": {"count": 0}}}, + "type": "blogs", }, - 'meta': {'apiDocs': '/docs/api/blogs'} + "meta": {"apiDocs": "/docs/api/blogs"}, } got = resp.json() self.assertEqual(got, expected) class TestEntryViewSet(APITestCase): - def setUp(self): - self.blog = Blog.objects.create( - name='Some Blog', - tagline="It's a blog" - ) + self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") self.first_entry = Entry.objects.create( blog=self.blog, - headline='headline two', - body_text='body_text two', + headline="headline two", + body_text="body_text two", ) self.second_entry = Entry.objects.create( blog=self.blog, - headline='headline two', - body_text='body_text two', + headline="headline two", + body_text="body_text two", ) self.maxDiff = None def test_get_object_gives_correct_entry(self): - url = reverse('entry-featured', kwargs={'entry_pk': self.first_entry.id}) + url = reverse("entry-featured", kwargs={"entry_pk": self.first_entry.id}) resp = self.client.get(url) expected = { - 'data': { - 'attributes': { - 'bodyText': self.second_entry.body_text, - 'headline': self.second_entry.headline, - 'modDate': self.second_entry.mod_date, - 'pubDate': self.second_entry.pub_date + "data": { + "attributes": { + "bodyText": self.second_entry.body_text, + "headline": self.second_entry.headline, + "modDate": self.second_entry.mod_date, + "pubDate": self.second_entry.pub_date, }, - 'id': '{}'.format(self.second_entry.id), - 'meta': {'bodyFormat': 'text'}, - 'relationships': { - 'authors': {'data': [], 'meta': {'count': 0}}, - 'blog': { - 'data': { - 'id': '{}'.format(self.second_entry.blog_id), - 'type': 'blogs' + "id": "{}".format(self.second_entry.id), + "meta": {"bodyFormat": "text"}, + "relationships": { + "authors": {"data": [], "meta": {"count": 0}}, + "blog": { + "data": { + "id": "{}".format(self.second_entry.blog_id), + "type": "blogs", } }, - 'blogHyperlinked': { - 'links': { - 'related': 'http://testserver/entries/{}' - '/blog'.format(self.second_entry.id), - 'self': 'http://testserver/entries/{}' - '/relationships/blog_hyperlinked'.format(self.second_entry.id) + "blogHyperlinked": { + "links": { + "related": "http://testserver/entries/{}" + "/blog".format(self.second_entry.id), + "self": "http://testserver/entries/{}" + "/relationships/blog_hyperlinked".format( + self.second_entry.id + ), } }, - 'comments': { - 'data': [], - 'meta': {'count': 0} - }, - 'commentsHyperlinked': { - 'links': { - 'related': 'http://testserver/entries/{}' - '/comments'.format(self.second_entry.id), - 'self': 'http://testserver/entries/{}/relationships' - '/comments_hyperlinked'.format(self.second_entry.id) + "comments": {"data": [], "meta": {"count": 0}}, + "commentsHyperlinked": { + "links": { + "related": "http://testserver/entries/{}" + "/comments".format(self.second_entry.id), + "self": "http://testserver/entries/{}/relationships" + "/comments_hyperlinked".format(self.second_entry.id), } }, - 'featuredHyperlinked': { - 'links': { - 'related': 'http://testserver/entries/{}' - '/featured'.format(self.second_entry.id), - 'self': 'http://testserver/entries/{}/relationships' - '/featured_hyperlinked'.format(self.second_entry.id) + "featuredHyperlinked": { + "links": { + "related": "http://testserver/entries/{}" + "/featured".format(self.second_entry.id), + "self": "http://testserver/entries/{}/relationships" + "/featured_hyperlinked".format(self.second_entry.id), } }, - 'suggested': { - 'data': [{'id': '1', 'type': 'entries'}], - 'links': { - 'related': 'http://testserver/entries/{}' - '/suggested/'.format(self.second_entry.id), - 'self': 'http://testserver/entries/{}' - '/relationships/suggested'.format(self.second_entry.id) - } + "suggested": { + "data": [{"id": "1", "type": "entries"}], + "links": { + "related": "http://testserver/entries/{}" + "/suggested/".format(self.second_entry.id), + "self": "http://testserver/entries/{}" + "/relationships/suggested".format(self.second_entry.id), + }, }, - 'suggestedHyperlinked': { - 'links': { - 'related': 'http://testserver/entries/{}' - '/suggested/'.format(self.second_entry.id), - 'self': 'http://testserver/entries/{}/relationships' - '/suggested_hyperlinked'.format(self.second_entry.id) + "suggestedHyperlinked": { + "links": { + "related": "http://testserver/entries/{}" + "/suggested/".format(self.second_entry.id), + "self": "http://testserver/entries/{}/relationships" + "/suggested_hyperlinked".format(self.second_entry.id), } }, - 'tags': {'data': [], 'meta': {'count': 0}}}, - 'type': 'posts' + "tags": {"data": [], "meta": {"count": 0}}, + }, + "type": "posts", } } got = resp.json() @@ -643,74 +693,75 @@ def test_get_object_gives_correct_entry(self): class BasicAuthorSerializer(serializers.ModelSerializer): class Meta: model = Author - fields = ('name',) + fields = ("name",) class ReadOnlyViewSetWithCustomActions(views.ReadOnlyModelViewSet): queryset = Author.objects.all() serializer_class = BasicAuthorSerializer - @action(detail=False, methods=['get', 'post', 'patch', 'delete']) + @action(detail=False, methods=["get", "post", "patch", "delete"]) def group_action(self, request): return Response(status=status.HTTP_204_NO_CONTENT) - @action(detail=True, methods=['get', 'post', 'patch', 'delete']) + @action(detail=True, methods=["get", "post", "patch", "delete"]) def item_action(self, request, pk): return Response(status=status.HTTP_204_NO_CONTENT) class TestReadonlyModelViewSet(TestBase): """ - Test if ReadOnlyModelViewSet allows to have custom actions with POST, PATCH, DELETE methods + Test if ReadOnlyModelViewSet allows to have custom actions with POST, PATCH, DELETE methods """ + factory = RequestFactory() viewset_class = ReadOnlyViewSetWithCustomActions - media_type = 'application/vnd.api+json' + media_type = "application/vnd.api+json" def test_group_action_allows_get(self): - view = self.viewset_class.as_view({'get': 'group_action'}) - request = self.factory.get('/') + view = self.viewset_class.as_view({"get": "group_action"}) + request = self.factory.get("/") response = view(request) self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) def test_group_action_allows_post(self): - view = self.viewset_class.as_view({'post': 'group_action'}) - request = self.factory.post('/', '{}', content_type=self.media_type) + view = self.viewset_class.as_view({"post": "group_action"}) + request = self.factory.post("/", "{}", content_type=self.media_type) response = view(request) self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) def test_group_action_allows_patch(self): - view = self.viewset_class.as_view({'patch': 'group_action'}) - request = self.factory.patch('/', '{}', content_type=self.media_type) + view = self.viewset_class.as_view({"patch": "group_action"}) + request = self.factory.patch("/", "{}", content_type=self.media_type) response = view(request) self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) def test_group_action_allows_delete(self): - view = self.viewset_class.as_view({'delete': 'group_action'}) - request = self.factory.delete('/', '{}', content_type=self.media_type) + view = self.viewset_class.as_view({"delete": "group_action"}) + request = self.factory.delete("/", "{}", content_type=self.media_type) response = view(request) self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) def test_item_action_allows_get(self): - view = self.viewset_class.as_view({'get': 'item_action'}) - request = self.factory.get('/') - response = view(request, pk='1') + view = self.viewset_class.as_view({"get": "item_action"}) + request = self.factory.get("/") + response = view(request, pk="1") self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) def test_item_action_allows_post(self): - view = self.viewset_class.as_view({'post': 'item_action'}) - request = self.factory.post('/', '{}', content_type=self.media_type) - response = view(request, pk='1') + view = self.viewset_class.as_view({"post": "item_action"}) + request = self.factory.post("/", "{}", content_type=self.media_type) + response = view(request, pk="1") self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) def test_item_action_allows_patch(self): - view = self.viewset_class.as_view({'patch': 'item_action'}) - request = self.factory.patch('/', '{}', content_type=self.media_type) - response = view(request, pk='1') + view = self.viewset_class.as_view({"patch": "item_action"}) + request = self.factory.patch("/", "{}", content_type=self.media_type) + response = view(request, pk="1") self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) def test_item_action_allows_delete(self): - view = self.viewset_class.as_view({'delete': 'item_action'}) - request = self.factory.delete('/', '{}', content_type=self.media_type) - response = view(request, pk='1') + view = self.viewset_class.as_view({"delete": "item_action"}) + request = self.factory.delete("/", "{}", content_type=self.media_type) + response = view(request, pk="1") self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) diff --git a/example/tests/unit/test_default_drf_serializers.py b/example/tests/unit/test_default_drf_serializers.py index 3233ce84..e8c78df8 100644 --- a/example/tests/unit/test_default_drf_serializers.py +++ b/example/tests/unit/test_default_drf_serializers.py @@ -15,7 +15,7 @@ class RelatedModelSerializer(ModelSerializer): class Meta: model = Comment - fields = ('id',) + fields = ("id",) class DummyTestSerializer(ModelSerializer): @@ -23,16 +23,19 @@ class DummyTestSerializer(ModelSerializer): This serializer is a simple compound document serializer which includes only a single embedded relation """ - related_models = RelatedModelSerializer(source='comments', many=True, read_only=True) + + related_models = RelatedModelSerializer( + source="comments", many=True, read_only=True + ) json_field = SerializerMethodField() def get_json_field(self, entry): - return {'JsonKey': 'JsonValue'} + return {"JsonKey": "JsonValue"} class Meta: model = Entry - fields = ('related_models', 'json_field') + fields = ("related_models", "json_field") # views @@ -44,9 +47,7 @@ class DummyTestViewSet(viewsets.ModelViewSet): def render_dummy_test_serialized_view(view_class): serializer = DummyTestSerializer(instance=Entry()) renderer = JSONRenderer() - return renderer.render( - serializer.data, - renderer_context={'view': view_class()}) + return renderer.render(serializer.data, renderer_context={"view": view_class()}) # tests @@ -54,32 +55,28 @@ def test_simple_reverse_relation_included_renderer(): """ Test renderer when a single reverse fk relation is passed. """ - rendered = render_dummy_test_serialized_view( - DummyTestViewSet) + rendered = render_dummy_test_serialized_view(DummyTestViewSet) assert rendered def test_render_format_field_names(settings): """Test that json field is kept untouched.""" - settings.JSON_API_FORMAT_FIELD_NAMES = 'dasherize' + settings.JSON_API_FORMAT_FIELD_NAMES = "dasherize" rendered = render_dummy_test_serialized_view(DummyTestViewSet) result = json.loads(rendered.decode()) - assert result['data']['attributes']['json-field'] == {'JsonKey': 'JsonValue'} + assert result["data"]["attributes"]["json-field"] == {"JsonKey": "JsonValue"} @pytest.mark.django_db def test_blog_create(client): - url = reverse('drf-entry-blog-list') + url = reverse("drf-entry-blog-list") name = "Dummy Name" request_data = { - 'data': { - 'attributes': {'name': name}, - 'type': 'blogs' - }, + "data": {"attributes": {"name": name}, "type": "blogs"}, } resp = client.post(url, request_data) @@ -94,14 +91,14 @@ def test_blog_create(client): blog = blog.first() expected = { - 'data': { - 'attributes': {'name': blog.name, 'tags': []}, - 'id': '{}'.format(blog.id), - 'links': {'self': 'http://testserver/blogs/{}'.format(blog.id)}, - 'meta': {'copyright': datetime.now().year}, - 'type': 'blogs' + "data": { + "attributes": {"name": blog.name, "tags": []}, + "id": "{}".format(blog.id), + "links": {"self": "http://testserver/blogs/{}".format(blog.id)}, + "meta": {"copyright": datetime.now().year}, + "type": "blogs", }, - 'meta': {'apiDocs': '/docs/api/blogs'} + "meta": {"apiDocs": "/docs/api/blogs"}, } assert resp.status_code == 201 @@ -111,17 +108,17 @@ def test_blog_create(client): @pytest.mark.django_db def test_get_object_gives_correct_blog(client, blog, entry): - url = reverse('drf-entry-blog-detail', kwargs={'entry_pk': entry.id}) + url = reverse("drf-entry-blog-detail", kwargs={"entry_pk": entry.id}) resp = client.get(url) expected = { - 'data': { - 'attributes': {'name': blog.name, 'tags': []}, - 'id': '{}'.format(blog.id), - 'links': {'self': 'http://testserver/blogs/{}'.format(blog.id)}, - 'meta': {'copyright': datetime.now().year}, - 'type': 'blogs' + "data": { + "attributes": {"name": blog.name, "tags": []}, + "id": "{}".format(blog.id), + "links": {"self": "http://testserver/blogs/{}".format(blog.id)}, + "meta": {"copyright": datetime.now().year}, + "type": "blogs", }, - 'meta': {'apiDocs': '/docs/api/blogs'} + "meta": {"apiDocs": "/docs/api/blogs"}, } got = resp.json() assert got == expected @@ -130,20 +127,20 @@ def test_get_object_gives_correct_blog(client, blog, entry): @pytest.mark.django_db def test_get_object_patches_correct_blog(client, blog, entry): - url = reverse('drf-entry-blog-detail', kwargs={'entry_pk': entry.id}) + url = reverse("drf-entry-blog-detail", kwargs={"entry_pk": entry.id}) new_name = blog.name + " update" assert not new_name == blog.name request_data = { - 'data': { - 'attributes': {'name': new_name}, - 'id': '{}'.format(blog.id), - 'links': {'self': 'http://testserver/blogs/{}'.format(blog.id)}, - 'meta': {'copyright': datetime.now().year}, - 'relationships': {'tags': {'data': []}}, - 'type': 'blogs' + "data": { + "attributes": {"name": new_name}, + "id": "{}".format(blog.id), + "links": {"self": "http://testserver/blogs/{}".format(blog.id)}, + "meta": {"copyright": datetime.now().year}, + "relationships": {"tags": {"data": []}}, + "type": "blogs", }, - 'meta': {'apiDocs': '/docs/api/blogs'} + "meta": {"apiDocs": "/docs/api/blogs"}, } resp = client.patch(url, data=request_data) @@ -151,14 +148,14 @@ def test_get_object_patches_correct_blog(client, blog, entry): assert resp.status_code == 200 expected = { - 'data': { - 'attributes': {'name': new_name, 'tags': []}, - 'id': '{}'.format(blog.id), - 'links': {'self': 'http://testserver/blogs/{}'.format(blog.id)}, - 'meta': {'copyright': datetime.now().year}, - 'type': 'blogs' + "data": { + "attributes": {"name": new_name, "tags": []}, + "id": "{}".format(blog.id), + "links": {"self": "http://testserver/blogs/{}".format(blog.id)}, + "meta": {"copyright": datetime.now().year}, + "type": "blogs", }, - 'meta': {'apiDocs': '/docs/api/blogs'} + "meta": {"apiDocs": "/docs/api/blogs"}, } got = resp.json() assert got == expected @@ -167,7 +164,7 @@ def test_get_object_patches_correct_blog(client, blog, entry): @pytest.mark.django_db def test_get_object_deletes_correct_blog(client, entry): - url = reverse('drf-entry-blog-detail', kwargs={'entry_pk': entry.id}) + url = reverse("drf-entry-blog-detail", kwargs={"entry_pk": entry.id}) resp = client.delete(url) @@ -176,37 +173,29 @@ def test_get_object_deletes_correct_blog(client, entry): @pytest.mark.django_db def test_get_entry_list_with_blogs(client, entry): - url = reverse('drf-entry-suggested', kwargs={'entry_pk': entry.id}) + url = reverse("drf-entry-suggested", kwargs={"entry_pk": entry.id}) resp = client.get(url) got = resp.json() expected = { - 'links': { - 'first': 'http://testserver/drf-entries/1/suggested/?page%5Bnumber%5D=1', - 'last': 'http://testserver/drf-entries/1/suggested/?page%5Bnumber%5D=1', - 'next': None, - 'prev': None, + "links": { + "first": "http://testserver/drf-entries/1/suggested/?page%5Bnumber%5D=1", + "last": "http://testserver/drf-entries/1/suggested/?page%5Bnumber%5D=1", + "next": None, + "prev": None, }, - 'data': [ + "data": [ { - 'type': 'entries', - 'id': '1', - 'attributes': { - 'tags': [], + "type": "entries", + "id": "1", + "attributes": { + "tags": [], }, - 'links': { - 'self': 'http://testserver/drf-blogs/1' - } + "links": {"self": "http://testserver/drf-blogs/1"}, } ], - 'meta': { - 'pagination': { - 'page': 1, - 'pages': 1, - 'count': 1 - } - } + "meta": {"pagination": {"page": 1, "pages": 1, "count": 1}}, } assert resp.status_code == 200 diff --git a/example/tests/unit/test_factories.py b/example/tests/unit/test_factories.py index 8acc9e74..ac9d7b2a 100644 --- a/example/tests/unit/test_factories.py +++ b/example/tests/unit/test_factories.py @@ -17,25 +17,31 @@ def test_model_instance(blog): def test_multiple_blog(blog_factory): - another_blog = blog_factory(name='Cool Blog') - new_blog = blog_factory(name='Awesome Blog') + another_blog = blog_factory(name="Cool Blog") + new_blog = blog_factory(name="Awesome Blog") - assert another_blog.name == 'Cool Blog' - assert new_blog.name == 'Awesome Blog' + assert another_blog.name == "Cool Blog" + assert new_blog.name == "Awesome Blog" def test_factories_with_relations(author_factory, entry_factory): author = author_factory(name="Joel Spolsky") entry = entry_factory( - headline=("The Absolute Minimum Every Software Developer" - "Absolutely, Positively Must Know About Unicode " - "and Character Sets (No Excuses!)"), - blog__name='Joel on Software', authors=(author, )) - - assert entry.blog.name == 'Joel on Software' - assert entry.headline == ("The Absolute Minimum Every Software Developer" - "Absolutely, Positively Must Know About Unicode " - "and Character Sets (No Excuses!)") + headline=( + "The Absolute Minimum Every Software Developer" + "Absolutely, Positively Must Know About Unicode " + "and Character Sets (No Excuses!)" + ), + blog__name="Joel on Software", + authors=(author,), + ) + + assert entry.blog.name == "Joel on Software" + assert entry.headline == ( + "The Absolute Minimum Every Software Developer" + "Absolutely, Positively Must Know About Unicode " + "and Character Sets (No Excuses!)" + ) assert entry.authors.all().count() == 1 - assert entry.authors.all()[0].name == 'Joel Spolsky' + assert entry.authors.all()[0].name == "Joel Spolsky" diff --git a/example/tests/unit/test_filter_schema_params.py b/example/tests/unit/test_filter_schema_params.py index 2044c467..9c78dc61 100644 --- a/example/tests/unit/test_filter_schema_params.py +++ b/example/tests/unit/test_filter_schema_params.py @@ -7,12 +7,16 @@ class DummyEntryViewSet(EntryViewSet): - filter_backends = (dja_filters.QueryParameterValidationFilter, dja_filters.OrderingFilter, - backends.DjangoFilterBackend, drf_filters.SearchFilter) + filter_backends = ( + dja_filters.QueryParameterValidationFilter, + dja_filters.OrderingFilter, + backends.DjangoFilterBackend, + drf_filters.SearchFilter, + ) filterset_fields = { - 'id': ('exact',), - 'headline': ('exact', 'contains'), - 'blog__name': ('contains', ), + "id": ("exact",), + "headline": ("exact", "contains"), + "blog__name": ("contains",), } def __init__(self, **kwargs): @@ -28,38 +32,63 @@ def test_filters_get_schema_params(): # list of tuples: (filter, expected result) filters = [ (dja_filters.QueryParameterValidationFilter, []), - (backends.DjangoFilterBackend, [ - { - 'name': 'filter[id]', 'required': False, 'in': 'query', - 'description': 'id', 'schema': {'type': 'string'} - }, - { - 'name': 'filter[headline]', 'required': False, 'in': 'query', - 'description': 'headline', 'schema': {'type': 'string'} - }, - { - 'name': 'filter[headline.contains]', 'required': False, 'in': 'query', - 'description': 'headline__contains', 'schema': {'type': 'string'} - }, - { - 'name': 'filter[blog.name.contains]', 'required': False, 'in': 'query', - 'description': 'blog__name__contains', 'schema': {'type': 'string'} - }, - ]), - (dja_filters.OrderingFilter, [ - { - 'name': 'sort', 'required': False, 'in': 'query', - 'description': 'Which field to use when ordering the results.', - 'schema': {'type': 'string'} - } - ]), - (drf_filters.SearchFilter, [ - { - 'name': 'filter[search]', 'required': False, 'in': 'query', - 'description': 'A search term.', - 'schema': {'type': 'string'} - } - ]), + ( + backends.DjangoFilterBackend, + [ + { + "name": "filter[id]", + "required": False, + "in": "query", + "description": "id", + "schema": {"type": "string"}, + }, + { + "name": "filter[headline]", + "required": False, + "in": "query", + "description": "headline", + "schema": {"type": "string"}, + }, + { + "name": "filter[headline.contains]", + "required": False, + "in": "query", + "description": "headline__contains", + "schema": {"type": "string"}, + }, + { + "name": "filter[blog.name.contains]", + "required": False, + "in": "query", + "description": "blog__name__contains", + "schema": {"type": "string"}, + }, + ], + ), + ( + dja_filters.OrderingFilter, + [ + { + "name": "sort", + "required": False, + "in": "query", + "description": "Which field to use when ordering the results.", + "schema": {"type": "string"}, + } + ], + ), + ( + drf_filters.SearchFilter, + [ + { + "name": "filter[search]", + "required": False, + "in": "query", + "description": "A search term.", + "schema": {"type": "string"}, + } + ], + ), ] view = DummyEntryViewSet() @@ -71,7 +100,7 @@ def test_filters_get_schema_params(): continue # py35: the result list/dict ordering isn't guaranteed for res_item in result: - assert 'name' in res_item + assert "name" in res_item for exp_item in expected: - if res_item['name'] == exp_item['name']: + if res_item["name"] == exp_item["name"]: assert res_item == exp_item diff --git a/example/tests/unit/test_pagination.py b/example/tests/unit/test_pagination.py index aeb5f87e..45042939 100644 --- a/example/tests/unit/test_pagination.py +++ b/example/tests/unit/test_pagination.py @@ -21,7 +21,7 @@ class ExamplePagination(pagination.JsonApiLimitOffsetPagination): self.pagination = ExamplePagination() self.queryset = range(1, 101) - self.base_url = 'http://testserver/' + self.base_url = "http://testserver/" def paginate_queryset(self, request): return list(self.pagination.paginate_queryset(self.queryset, request)) @@ -31,7 +31,7 @@ def get_paginated_content(self, queryset): return response.data def get_test_request(self, arguments): - return Request(factory.get('/', arguments)) + return Request(factory.get("/", arguments)) def test_valid_offset_limit(self): """ @@ -44,34 +44,48 @@ def test_valid_offset_limit(self): next_offset = 15 prev_offset = 5 - request = self.get_test_request({ - self.pagination.limit_query_param: limit, - self.pagination.offset_query_param: offset - }) - base_url = replace_query_param(self.base_url, self.pagination.limit_query_param, limit) - last_url = replace_query_param(base_url, self.pagination.offset_query_param, last_offset) + request = self.get_test_request( + { + self.pagination.limit_query_param: limit, + self.pagination.offset_query_param: offset, + } + ) + base_url = replace_query_param( + self.base_url, self.pagination.limit_query_param, limit + ) + last_url = replace_query_param( + base_url, self.pagination.offset_query_param, last_offset + ) first_url = base_url - next_url = replace_query_param(base_url, self.pagination.offset_query_param, next_offset) - prev_url = replace_query_param(base_url, self.pagination.offset_query_param, prev_offset) + next_url = replace_query_param( + base_url, self.pagination.offset_query_param, next_offset + ) + prev_url = replace_query_param( + base_url, self.pagination.offset_query_param, prev_offset + ) queryset = self.paginate_queryset(request) content = self.get_paginated_content(queryset) next_offset = offset + limit expected_content = { - 'results': list(range(offset + 1, next_offset + 1)), - 'links': OrderedDict([ - ('first', first_url), - ('last', last_url), - ('next', next_url), - ('prev', prev_url), - ]), - 'meta': { - 'pagination': OrderedDict([ - ('count', count), - ('limit', limit), - ('offset', offset), - ]) - } + "results": list(range(offset + 1, next_offset + 1)), + "links": OrderedDict( + [ + ("first", first_url), + ("last", last_url), + ("next", next_url), + ("prev", prev_url), + ] + ), + "meta": { + "pagination": OrderedDict( + [ + ("count", count), + ("limit", limit), + ("offset", offset), + ] + ) + }, } assert queryset == list(range(offset + 1, next_offset + 1)) diff --git a/example/tests/unit/test_renderer_class_methods.py b/example/tests/unit/test_renderer_class_methods.py index 7a9230d3..c599abbd 100644 --- a/example/tests/unit/test_renderer_class_methods.py +++ b/example/tests/unit/test_renderer_class_methods.py @@ -11,34 +11,36 @@ class ResourceSerializer(serializers.ModelSerializer): version = serializers.SerializerMethodField() def get_version(self, obj): - return '1.0.0' + return "1.0.0" class Meta: - fields = ('username',) - meta_fields = ('version',) + fields = ("username",) + meta_fields = ("version",) model = get_user_model() def test_build_json_resource_obj(): resource = { - 'pk': 1, - 'username': 'Alice', + "pk": 1, + "username": "Alice", } - serializer = ResourceSerializer(data={'username': 'Alice'}) + serializer = ResourceSerializer(data={"username": "Alice"}) serializer.is_valid() resource_instance = serializer.save() output = { - 'type': 'user', - 'id': '1', - 'attributes': { - 'username': 'Alice' - }, + "type": "user", + "id": "1", + "attributes": {"username": "Alice"}, } - assert JSONRenderer.build_json_resource_obj( - serializer.fields, resource, resource_instance, 'user') == output + assert ( + JSONRenderer.build_json_resource_obj( + serializer.fields, resource, resource_instance, "user" + ) + == output + ) def test_can_override_methods(): @@ -46,20 +48,18 @@ def test_can_override_methods(): Make sure extract_attributes and extract_relationships can be overriden. """ resource = { - 'pk': 1, - 'username': 'Alice', + "pk": 1, + "username": "Alice", } - serializer = ResourceSerializer(data={'username': 'Alice'}) + serializer = ResourceSerializer(data={"username": "Alice"}) serializer.is_valid() resource_instance = serializer.save() output = { - 'type': 'user', - 'id': '1', - 'attributes': { - 'username': 'Alice' - }, + "type": "user", + "id": "1", + "attributes": {"username": "Alice"}, } class CustomRenderer(JSONRenderer): @@ -78,35 +78,37 @@ def extract_relationships(cls, fields, resource, resource_instance): fields, resource, resource_instance ) - assert CustomRenderer.build_json_resource_obj( - serializer.fields, resource, resource_instance, 'user') == output + assert ( + CustomRenderer.build_json_resource_obj( + serializer.fields, resource, resource_instance, "user" + ) + == output + ) assert CustomRenderer.extract_attributes_was_overriden assert CustomRenderer.extract_relationships_was_overriden def test_extract_attributes(): fields = { - 'id': serializers.Field(), - 'username': serializers.Field(), - 'deleted': serializers.ReadOnlyField(), - } - resource = {'id': 1, 'deleted': None, 'username': 'jerel'} - expected = { - 'username': 'jerel', - 'deleted': None + "id": serializers.Field(), + "username": serializers.Field(), + "deleted": serializers.ReadOnlyField(), } - assert sorted(JSONRenderer.extract_attributes(fields, resource)) == sorted(expected), ( - 'Regular fields should be extracted' - ) + resource = {"id": 1, "deleted": None, "username": "jerel"} + expected = {"username": "jerel", "deleted": None} + assert sorted(JSONRenderer.extract_attributes(fields, resource)) == sorted( + expected + ), "Regular fields should be extracted" assert sorted(JSONRenderer.extract_attributes(fields, {})) == sorted( - {'username': ''}), 'Should not extract read_only fields on empty serializer' + {"username": ""} + ), "Should not extract read_only fields on empty serializer" def test_extract_meta(): - serializer = ResourceSerializer(data={'username': 'jerel', 'version': '1.0.0'}) + serializer = ResourceSerializer(data={"username": "jerel", "version": "1.0.0"}) serializer.is_valid() expected = { - 'version': '1.0.0', + "version": "1.0.0", } assert JSONRenderer.extract_meta(serializer, serializer.data) == expected @@ -114,33 +116,27 @@ def test_extract_meta(): class ExtractRootMetaResourceSerializer(ResourceSerializer): def get_root_meta(self, resource, many): if many: - return { - 'foo': 'meta-many-value' - } + return {"foo": "meta-many-value"} else: - return { - 'foo': 'meta-value' - } + return {"foo": "meta-value"} class InvalidExtractRootMetaResourceSerializer(ResourceSerializer): def get_root_meta(self, resource, many): - return 'not a dict' + return "not a dict" def test_extract_root_meta(): serializer = ExtractRootMetaResourceSerializer() expected = { - 'foo': 'meta-value', + "foo": "meta-value", } assert JSONRenderer.extract_root_meta(serializer, {}) == expected def test_extract_root_meta_many(): serializer = ExtractRootMetaResourceSerializer(many=True) - expected = { - 'foo': 'meta-many-value' - } + expected = {"foo": "meta-many-value"} assert JSONRenderer.extract_root_meta(serializer, {}) == expected diff --git a/example/tests/unit/test_renderers.py b/example/tests/unit/test_renderers.py index 034cc45f..47d37b5b 100644 --- a/example/tests/unit/test_renderers.py +++ b/example/tests/unit/test_renderers.py @@ -11,40 +11,41 @@ # serializers class RelatedModelSerializer(serializers.ModelSerializer): - blog = serializers.ReadOnlyField(source='entry.blog') + blog = serializers.ReadOnlyField(source="entry.blog") class Meta: model = Comment - fields = ('id', 'blog') + fields = ("id", "blog") class DummyTestSerializer(serializers.ModelSerializer): - ''' + """ This serializer is a simple compound document serializer which includes only a single embedded relation - ''' + """ + related_models = RelatedModelSerializer( - source='comments', many=True, read_only=True) + source="comments", many=True, read_only=True + ) json_field = serializers.SerializerMethodField() def get_json_field(self, entry): - return {'JsonKey': 'JsonValue'} + return {"JsonKey": "JsonValue"} class Meta: model = Entry - fields = ('related_models', 'json_field') + fields = ("related_models", "json_field") class JSONAPIMeta: - included_resources = ('related_models',) + included_resources = ("related_models",) class EntryDRFSerializers(serializers.ModelSerializer): - class Meta: model = Entry - fields = ('headline', 'body_text') - read_only_fields = ('tags',) + fields = ("headline", "body_text") + read_only_fields = ("tags",) class CommentWithNestedFieldsSerializer(serializers.ModelSerializer): @@ -52,7 +53,7 @@ class CommentWithNestedFieldsSerializer(serializers.ModelSerializer): class Meta: model = Comment - exclude = ('created_at', 'modified_at', 'author') + exclude = ("created_at", "modified_at", "author") # fields = ('entry', 'body', 'author',) @@ -61,7 +62,7 @@ class AuthorWithNestedFieldsSerializer(serializers.ModelSerializer): class Meta: model = Author - fields = ('name', 'email', 'comments') + fields = ("name", "email", "comments") # views @@ -78,59 +79,54 @@ class ReadOnlyDummyTestViewSet(views.ReadOnlyModelViewSet): class AuthorWithNestedFieldsViewSet(views.ModelViewSet): queryset = Author.objects.all() serializer_class = AuthorWithNestedFieldsSerializer - resource_name = 'authors' + resource_name = "authors" def render_dummy_test_serialized_view(view_class, instance): serializer = view_class.serializer_class(instance=instance) renderer = JSONRenderer() - return renderer.render( - serializer.data, - renderer_context={'view': view_class()}) + return renderer.render(serializer.data, renderer_context={"view": view_class()}) def test_simple_reverse_relation_included_renderer(): - ''' + """ Test renderer when a single reverse fk relation is passed. - ''' - rendered = render_dummy_test_serialized_view( - DummyTestViewSet, Entry()) + """ + rendered = render_dummy_test_serialized_view(DummyTestViewSet, Entry()) assert rendered def test_simple_reverse_relation_included_read_only_viewset(): - rendered = render_dummy_test_serialized_view( - ReadOnlyDummyTestViewSet, Entry()) + rendered = render_dummy_test_serialized_view(ReadOnlyDummyTestViewSet, Entry()) assert rendered def test_render_format_field_names(settings): """Test that json field is kept untouched.""" - settings.JSON_API_FORMAT_FIELD_NAMES = 'dasherize' + settings.JSON_API_FORMAT_FIELD_NAMES = "dasherize" rendered = render_dummy_test_serialized_view(DummyTestViewSet, Entry()) result = json.loads(rendered.decode()) - assert result['data']['attributes']['json-field'] == {'JsonKey': 'JsonValue'} + assert result["data"]["attributes"]["json-field"] == {"JsonKey": "JsonValue"} def test_writeonly_not_in_response(): """Test that writeonly fields are not shown in list response""" class WriteonlyTestSerializer(serializers.ModelSerializer): - '''Serializer for testing the absence of write_only fields''' + """Serializer for testing the absence of write_only fields""" + comments = serializers.ResourceRelatedField( - many=True, - write_only=True, - queryset=Comment.objects.all() + many=True, write_only=True, queryset=Comment.objects.all() ) rating = serializers.IntegerField(write_only=True) class Meta: model = Entry - fields = ('comments', 'rating') + fields = ("comments", "rating") class WriteOnlyDummyTestViewSet(views.ReadOnlyModelViewSet): queryset = Entry.objects.all() @@ -139,8 +135,8 @@ class WriteOnlyDummyTestViewSet(views.ReadOnlyModelViewSet): rendered = render_dummy_test_serialized_view(WriteOnlyDummyTestViewSet, Entry()) result = json.loads(rendered.decode()) - assert 'rating' not in result['data']['attributes'] - assert 'relationships' not in result['data'] + assert "rating" not in result["data"]["attributes"] + assert "relationships" not in result["data"] def test_render_empty_relationship_reverse_lookup(): @@ -149,7 +145,7 @@ def test_render_empty_relationship_reverse_lookup(): class EmptyRelationshipSerializer(serializers.ModelSerializer): class Meta: model = Author - fields = ('bio', ) + fields = ("bio",) class EmptyRelationshipViewSet(views.ReadOnlyModelViewSet): queryset = Author.objects.all() @@ -157,9 +153,9 @@ class EmptyRelationshipViewSet(views.ReadOnlyModelViewSet): rendered = render_dummy_test_serialized_view(EmptyRelationshipViewSet, Author()) result = json.loads(rendered.decode()) - assert 'relationships' in result['data'] - assert 'bio' in result['data']['relationships'] - assert result['data']['relationships']['bio'] == {'data': None} + assert "relationships" in result["data"] + assert "bio" in result["data"]["relationships"] + assert result["data"]["relationships"]["bio"] == {"data": None} @pytest.mark.django_db @@ -167,32 +163,30 @@ def test_extract_relation_instance(comment): serializer = RelatedModelSerializer(instance=comment) got = JSONRenderer.extract_relation_instance( - field=serializer.fields['blog'], resource_instance=comment + field=serializer.fields["blog"], resource_instance=comment ) assert got == comment.entry.blog def test_render_serializer_as_attribute(db): # setting up - blog = Blog.objects.create(name='Some Blog', tagline="It's a blog") + blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") entry = Entry.objects.create( blog=blog, - headline='headline', - body_text='body_text', + headline="headline", + body_text="body_text", pub_date=timezone.now(), mod_date=timezone.now(), n_comments=0, n_pingbacks=0, - rating=3 + rating=3, ) - author = Author.objects.create(name='some_author', email='some_author@example.org') + author = Author.objects.create(name="some_author", email="some_author@example.org") entry.authors.add(author) Comment.objects.create( - entry=entry, - body='testing one two three', - author=Author.objects.first() + entry=entry, body="testing one two three", author=Author.objects.first() ) rendered = render_dummy_test_serialized_view(AuthorWithNestedFieldsViewSet, author) @@ -209,13 +203,13 @@ def test_render_serializer_as_attribute(db): { "id": 1, "entry": { - 'headline': 'headline', - 'body_text': 'body_text', + "headline": "headline", + "body_text": "body_text", }, - "body": "testing one two three" + "body": "testing one two three", } - ] - } + ], + }, } } assert expected == result diff --git a/example/tests/unit/test_serializer_method_field.py b/example/tests/unit/test_serializer_method_field.py index 89e18295..37e74ce6 100644 --- a/example/tests/unit/test_serializer_method_field.py +++ b/example/tests/unit/test_serializer_method_field.py @@ -13,28 +13,27 @@ class BlogSerializer(serializers.ModelSerializer): class Meta: model = Blog - fields = ['one_entry'] + fields = ["one_entry"] def get_one_entry(self, instance): return Entry(id=100) serializer = BlogSerializer(instance=Blog()) - assert serializer.data['one_entry']['id'] == '100' + assert serializer.data["one_entry"]["id"] == "100" def test_method_name_custom(): class BlogSerializer(serializers.ModelSerializer): one_entry = SerializerMethodResourceRelatedField( - model=Entry, - method_name='get_custom_entry' + model=Entry, method_name="get_custom_entry" ) class Meta: model = Blog - fields = ['one_entry'] + fields = ["one_entry"] def get_custom_entry(self, instance): return Entry(id=100) serializer = BlogSerializer(instance=Blog()) - assert serializer.data['one_entry']['id'] == '100' + assert serializer.data["one_entry"]["id"] == "100" diff --git a/example/tests/unit/test_settings.py b/example/tests/unit/test_settings.py index e6b82a24..666eae4a 100644 --- a/example/tests/unit/test_settings.py +++ b/example/tests/unit/test_settings.py @@ -13,5 +13,5 @@ def test_settings_default(): def test_settings_override(settings): - settings.JSON_API_FORMAT_FIELD_NAMES = 'dasherize' - assert json_api_settings.FORMAT_FIELD_NAMES == 'dasherize' + settings.JSON_API_FORMAT_FIELD_NAMES = "dasherize" + assert json_api_settings.FORMAT_FIELD_NAMES == "dasherize" diff --git a/example/urls.py b/example/urls.py index 9b882ce5..800b9f79 100644 --- a/example/urls.py +++ b/example/urls.py @@ -19,70 +19,95 @@ EntryViewSet, NonPaginatedEntryViewSet, ProjectTypeViewset, - ProjectViewset + ProjectViewset, ) router = routers.DefaultRouter(trailing_slash=False) -router.register(r'blogs', BlogViewSet) -router.register(r'entries', EntryViewSet) -router.register(r'nopage-entries', NonPaginatedEntryViewSet, 'nopage-entry') -router.register(r'authors', AuthorViewSet) -router.register(r'comments', CommentViewSet) -router.register(r'companies', CompanyViewset) -router.register(r'projects', ProjectViewset) -router.register(r'project-types', ProjectTypeViewset) +router.register(r"blogs", BlogViewSet) +router.register(r"entries", EntryViewSet) +router.register(r"nopage-entries", NonPaginatedEntryViewSet, "nopage-entry") +router.register(r"authors", AuthorViewSet) +router.register(r"comments", CommentViewSet) +router.register(r"companies", CompanyViewset) +router.register(r"projects", ProjectViewset) +router.register(r"project-types", ProjectTypeViewset) urlpatterns = [ - url(r'^', include(router.urls)), - url(r'^entries/(?P[^/.]+)/suggested/$', - EntryViewSet.as_view({'get': 'list'}), - name='entry-suggested' - ), - url(r'entries/(?P[^/.]+)/blog$', - BlogViewSet.as_view({'get': 'retrieve'}), - name='entry-blog'), - url(r'entries/(?P[^/.]+)/comments$', - CommentViewSet.as_view({'get': 'list'}), - name='entry-comments'), - url(r'entries/(?P[^/.]+)/authors$', - AuthorViewSet.as_view({'get': 'list'}), - name='entry-authors'), - url(r'entries/(?P[^/.]+)/featured$', - EntryViewSet.as_view({'get': 'retrieve'}), - name='entry-featured'), - - url(r'^authors/(?P[^/.]+)/(?P\w+)/$', - AuthorViewSet.as_view({'get': 'retrieve_related'}), - name='author-related'), - - url(r'^entries/(?P[^/.]+)/relationships/(?P\w+)$', + url(r"^", include(router.urls)), + url( + r"^entries/(?P[^/.]+)/suggested/$", + EntryViewSet.as_view({"get": "list"}), + name="entry-suggested", + ), + url( + r"entries/(?P[^/.]+)/blog$", + BlogViewSet.as_view({"get": "retrieve"}), + name="entry-blog", + ), + url( + r"entries/(?P[^/.]+)/comments$", + CommentViewSet.as_view({"get": "list"}), + name="entry-comments", + ), + url( + r"entries/(?P[^/.]+)/authors$", + AuthorViewSet.as_view({"get": "list"}), + name="entry-authors", + ), + url( + r"entries/(?P[^/.]+)/featured$", + EntryViewSet.as_view({"get": "retrieve"}), + name="entry-featured", + ), + url( + r"^authors/(?P[^/.]+)/(?P\w+)/$", + AuthorViewSet.as_view({"get": "retrieve_related"}), + name="author-related", + ), + url( + r"^entries/(?P[^/.]+)/relationships/(?P\w+)$", EntryRelationshipView.as_view(), - name='entry-relationships'), - url(r'^blogs/(?P[^/.]+)/relationships/(?P\w+)$', + name="entry-relationships", + ), + url( + r"^blogs/(?P[^/.]+)/relationships/(?P\w+)$", BlogRelationshipView.as_view(), - name='blog-relationships'), - url(r'^comments/(?P[^/.]+)/relationships/(?P\w+)$', + name="blog-relationships", + ), + url( + r"^comments/(?P[^/.]+)/relationships/(?P\w+)$", CommentRelationshipView.as_view(), - name='comment-relationships'), - url(r'^authors/(?P[^/.]+)/relationships/(?P\w+)$', + name="comment-relationships", + ), + url( + r"^authors/(?P[^/.]+)/relationships/(?P\w+)$", AuthorRelationshipView.as_view(), - name='author-relationships'), - path('openapi', get_schema_view( - title="Example API", - description="API for all things …", - version="1.0.0", - generator_class=SchemaGenerator - ), name='openapi-schema'), - path('swagger-ui/', TemplateView.as_view( - template_name='swagger-ui.html', - extra_context={'schema_url': 'openapi-schema'} - ), name='swagger-ui'), + name="author-relationships", + ), + path( + "openapi", + get_schema_view( + title="Example API", + description="API for all things …", + version="1.0.0", + generator_class=SchemaGenerator, + ), + name="openapi-schema", + ), + path( + "swagger-ui/", + TemplateView.as_view( + template_name="swagger-ui.html", + extra_context={"schema_url": "openapi-schema"}, + ), + name="swagger-ui", + ), ] if settings.DEBUG: import debug_toolbar urlpatterns = [ - url(r'^__debug__/', include(debug_toolbar.urls)), + url(r"^__debug__/", include(debug_toolbar.urls)), ] + urlpatterns diff --git a/example/urls_test.py b/example/urls_test.py index 44ae6f58..7e875936 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -18,73 +18,90 @@ NoFiltersetEntryViewSet, NonPaginatedEntryViewSet, ProjectTypeViewset, - ProjectViewset + ProjectViewset, ) router = routers.DefaultRouter(trailing_slash=False) -router.register(r'blogs', BlogViewSet) +router.register(r"blogs", BlogViewSet) # router to test default DRF blog functionalities -router.register(r'drf-blogs', DRFBlogViewSet, 'drf-entry-blog') -router.register(r'entries', EntryViewSet) +router.register(r"drf-blogs", DRFBlogViewSet, "drf-entry-blog") +router.register(r"entries", EntryViewSet) # these "flavors" of entries are used for various tests: -router.register(r'nopage-entries', NonPaginatedEntryViewSet, 'nopage-entry') -router.register(r'filterset-entries', FiltersetEntryViewSet, 'filterset-entry') -router.register(r'nofilterset-entries', NoFiltersetEntryViewSet, 'nofilterset-entry') -router.register(r'authors', AuthorViewSet) -router.register(r'comments', CommentViewSet) -router.register(r'companies', CompanyViewset) -router.register(r'projects', ProjectViewset) -router.register(r'project-types', ProjectTypeViewset) +router.register(r"nopage-entries", NonPaginatedEntryViewSet, "nopage-entry") +router.register(r"filterset-entries", FiltersetEntryViewSet, "filterset-entry") +router.register(r"nofilterset-entries", NoFiltersetEntryViewSet, "nofilterset-entry") +router.register(r"authors", AuthorViewSet) +router.register(r"comments", CommentViewSet) +router.register(r"companies", CompanyViewset) +router.register(r"projects", ProjectViewset) +router.register(r"project-types", ProjectTypeViewset) # for the old tests -router.register(r'identities', Identity) +router.register(r"identities", Identity) urlpatterns = [ # old tests - re_path(r'identities/default/(?P\d+)$', - GenericIdentity.as_view(), name='user-default'), - - - re_path(r'^entries/(?P[^/.]+)/blog$', - BlogViewSet.as_view({'get': 'retrieve'}), - name='entry-blog' - ), - re_path(r'^entries/(?P[^/.]+)/comments$', - CommentViewSet.as_view({'get': 'list'}), - name='entry-comments' - ), - re_path(r'^entries/(?P[^/.]+)/suggested/$', - EntryViewSet.as_view({'get': 'list'}), - name='entry-suggested' - ), - re_path(r'^drf-entries/(?P[^/.]+)/suggested/$', - DRFEntryViewSet.as_view({'get': 'list'}), - name='drf-entry-suggested' - ), - re_path(r'entries/(?P[^/.]+)/authors$', - AuthorViewSet.as_view({'get': 'list'}), - name='entry-authors'), - re_path(r'entries/(?P[^/.]+)/featured$', - EntryViewSet.as_view({'get': 'retrieve'}), - name='entry-featured'), - - re_path(r'^authors/(?P[^/.]+)/(?P\w+)/$', - AuthorViewSet.as_view({'get': 'retrieve_related'}), - name='author-related'), - - re_path(r'^entries/(?P[^/.]+)/relationships/(?P\w+)$', - EntryRelationshipView.as_view(), - name='entry-relationships'), - re_path(r'^blogs/(?P[^/.]+)/relationships/(?P\w+)$', - BlogRelationshipView.as_view(), - name='blog-relationships'), - re_path(r'^comments/(?P[^/.]+)/relationships/(?P\w+)$', - CommentRelationshipView.as_view(), - name='comment-relationships'), - re_path(r'^authors/(?P[^/.]+)/relationships/(?P\w+)$', - AuthorRelationshipView.as_view(), - name='author-relationships'), + re_path( + r"identities/default/(?P\d+)$", + GenericIdentity.as_view(), + name="user-default", + ), + re_path( + r"^entries/(?P[^/.]+)/blog$", + BlogViewSet.as_view({"get": "retrieve"}), + name="entry-blog", + ), + re_path( + r"^entries/(?P[^/.]+)/comments$", + CommentViewSet.as_view({"get": "list"}), + name="entry-comments", + ), + re_path( + r"^entries/(?P[^/.]+)/suggested/$", + EntryViewSet.as_view({"get": "list"}), + name="entry-suggested", + ), + re_path( + r"^drf-entries/(?P[^/.]+)/suggested/$", + DRFEntryViewSet.as_view({"get": "list"}), + name="drf-entry-suggested", + ), + re_path( + r"entries/(?P[^/.]+)/authors$", + AuthorViewSet.as_view({"get": "list"}), + name="entry-authors", + ), + re_path( + r"entries/(?P[^/.]+)/featured$", + EntryViewSet.as_view({"get": "retrieve"}), + name="entry-featured", + ), + re_path( + r"^authors/(?P[^/.]+)/(?P\w+)/$", + AuthorViewSet.as_view({"get": "retrieve_related"}), + name="author-related", + ), + re_path( + r"^entries/(?P[^/.]+)/relationships/(?P\w+)$", + EntryRelationshipView.as_view(), + name="entry-relationships", + ), + re_path( + r"^blogs/(?P[^/.]+)/relationships/(?P\w+)$", + BlogRelationshipView.as_view(), + name="blog-relationships", + ), + re_path( + r"^comments/(?P[^/.]+)/relationships/(?P\w+)$", + CommentRelationshipView.as_view(), + name="comment-relationships", + ), + re_path( + r"^authors/(?P[^/.]+)/relationships/(?P\w+)$", + AuthorRelationshipView.as_view(), + name="author-relationships", + ), ] urlpatterns += router.urls diff --git a/example/utils.py b/example/utils.py index 65403038..1c9ef459 100644 --- a/example/utils.py +++ b/example/utils.py @@ -6,7 +6,7 @@ class BrowsableAPIRendererWithoutForms(BrowsableAPIRenderer): def get_context(self, *args, **kwargs): ctx = super().get_context(*args, **kwargs) - ctx['display_edit_forms'] = False + ctx["display_edit_forms"] = False return ctx def show_form_for_method(self, view, method, request, obj): diff --git a/example/views.py b/example/views.py index 99a54193..65bcb301 100644 --- a/example/views.py +++ b/example/views.py @@ -8,7 +8,10 @@ import rest_framework_json_api.parsers import rest_framework_json_api.renderers from rest_framework_json_api.django_filters import DjangoFilterBackend -from rest_framework_json_api.filters import OrderingFilter, QueryParameterValidationFilter +from rest_framework_json_api.filters import ( + OrderingFilter, + QueryParameterValidationFilter, +) from rest_framework_json_api.pagination import JsonApiPageNumberPagination from rest_framework_json_api.utils import format_drf_errors from rest_framework_json_api.views import ModelViewSet, RelationshipView @@ -25,7 +28,7 @@ EntryDRFSerializers, EntrySerializer, ProjectSerializer, - ProjectTypeSerializer + ProjectTypeSerializer, ) HTTP_422_UNPROCESSABLE_ENTITY = 422 @@ -36,7 +39,7 @@ class BlogViewSet(ModelViewSet): serializer_class = BlogSerializer def get_object(self): - entry_pk = self.kwargs.get('entry_pk', None) + entry_pk = self.kwargs.get("entry_pk", None) if entry_pk is not None: return Entry.objects.get(id=entry_pk).blog @@ -46,7 +49,7 @@ def get_object(self): class DRFBlogViewSet(ModelViewSet): queryset = Blog.objects.all() serializer_class = BlogDRFSerializer - lookup_url_kwarg = 'entry_pk' + lookup_url_kwarg = "entry_pk" def get_object(self): entry_pk = self.kwargs.get(self.lookup_url_kwarg, None) @@ -62,6 +65,7 @@ class JsonApiViewSet(ModelViewSet): within a class. It allows using DRF-jsonapi alongside vanilla DRF API views. """ + parser_classes = [ rest_framework_json_api.parsers.JSONParser, rest_framework.parsers.FormParser, @@ -92,14 +96,14 @@ class BlogCustomViewSet(JsonApiViewSet): class EntryViewSet(ModelViewSet): queryset = Entry.objects.all() - resource_name = 'posts' + resource_name = "posts" def get_serializer_class(self): return EntrySerializer def get_object(self): # Handle featured - entry_pk = self.kwargs.get('entry_pk', None) + entry_pk = self.kwargs.get("entry_pk", None) if entry_pk is not None: return Entry.objects.exclude(pk=entry_pk).first() @@ -109,7 +113,7 @@ def get_object(self): class DRFEntryViewSet(ModelViewSet): queryset = Entry.objects.all() serializer_class = EntryDRFSerializers - lookup_url_kwarg = 'entry_pk' + lookup_url_kwarg = "entry_pk" def get_object(self): # Handle featured @@ -128,30 +132,42 @@ class NonPaginatedEntryViewSet(EntryViewSet): pagination_class = NoPagination # override the default filter backends in order to test QueryParameterValidationFilter without # breaking older usage of non-standard query params like `page_size`. - filter_backends = (QueryParameterValidationFilter, OrderingFilter, - DjangoFilterBackend, SearchFilter) - ordering_fields = ('headline', 'body_text', 'blog__name', 'blog__id') - rels = ('exact', 'iexact', - 'contains', 'icontains', - 'gt', 'gte', 'lt', 'lte', - 'in', 'regex', 'isnull',) + filter_backends = ( + QueryParameterValidationFilter, + OrderingFilter, + DjangoFilterBackend, + SearchFilter, + ) + ordering_fields = ("headline", "body_text", "blog__name", "blog__id") + rels = ( + "exact", + "iexact", + "contains", + "icontains", + "gt", + "gte", + "lt", + "lte", + "in", + "regex", + "isnull", + ) filterset_fields = { - 'id': ('exact', 'in'), - 'headline': rels, - 'body_text': rels, - 'blog__name': rels, - 'blog__tagline': rels, + "id": ("exact", "in"), + "headline": rels, + "body_text": rels, + "blog__name": rels, + "blog__tagline": rels, } - search_fields = ('headline', 'body_text', 'blog__name', 'blog__tagline') + search_fields = ("headline", "body_text", "blog__name", "blog__tagline") class EntryFilter(filters.FilterSet): - bname = filters.CharFilter(field_name="blog__name", - lookup_expr="exact") + bname = filters.CharFilter(field_name="blog__name", lookup_expr="exact") authors__id = filters.ModelMultipleChoiceFilter( - field_name='authors', - to_field_name='id', + field_name="authors", + to_field_name="id", conjoined=True, # to "and" the ids queryset=Author.objects.all(), ) @@ -159,10 +175,10 @@ class EntryFilter(filters.FilterSet): class Meta: model = Entry fields = { - 'id': ('exact',), - 'headline': ('exact',), - 'body_text': ('exact',), - 'authors__id': ('in',), + "id": ("exact",), + "headline": ("exact",), + "body_text": ("exact",), + "authors__id": ("in",), } @@ -170,16 +186,21 @@ class FiltersetEntryViewSet(EntryViewSet): """ like above but use filterset_class instead of filterset_fields """ + pagination_class = NoPagination filterset_fields = None filterset_class = EntryFilter - filter_backends = (QueryParameterValidationFilter, DjangoFilterBackend,) + filter_backends = ( + QueryParameterValidationFilter, + DjangoFilterBackend, + ) class NoFiltersetEntryViewSet(EntryViewSet): """ like above but no filtersets """ + pagination_class = NoPagination filterset_fields = None filterset_class = None @@ -189,7 +210,8 @@ class AuthorViewSet(ModelViewSet): queryset = Author.objects.all() serializer_classes = { "list": AuthorListSerializer, - "retrieve": AuthorDetailSerializer} + "retrieve": AuthorDetailSerializer, + } serializer_class = AuthorSerializer # fallback def get_serializer_class(self): @@ -202,16 +224,14 @@ def get_serializer_class(self): class CommentViewSet(ModelViewSet): queryset = Comment.objects.all() serializer_class = CommentSerializer - select_for_includes = { - 'writer': ['author__bio'] - } + select_for_includes = {"writer": ["author__bio"]} prefetch_for_includes = { - '__all__': [], - 'author': ['author__bio', 'author__entries'], + "__all__": [], + "author": ["author__bio", "author__entries"], } def get_queryset(self, *args, **kwargs): - entry_pk = self.kwargs.get('entry_pk', None) + entry_pk = self.kwargs.get("entry_pk", None) if entry_pk is not None: return self.queryset.filter(entry_id=entry_pk) @@ -224,7 +244,7 @@ class CompanyViewset(ModelViewSet): class ProjectViewset(ModelViewSet): - queryset = Project.objects.all().order_by('pk') + queryset = Project.objects.all().order_by("pk") serializer_class = ProjectSerializer @@ -247,4 +267,4 @@ class CommentRelationshipView(RelationshipView): class AuthorRelationshipView(RelationshipView): queryset = Author.objects.all() - self_link_view_name = 'author-relationships' + self_link_view_name = "author-relationships" diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index f059f020..4c4f115d 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- -__title__ = 'djangorestframework-jsonapi' -__version__ = '4.0.0' -__author__ = '' -__license__ = 'BSD' -__copyright__ = '' +__title__ = "djangorestframework-jsonapi" +__version__ = "4.0.0" +__author__ = "" +__license__ = "BSD" +__copyright__ = "" # Version synonym VERSION = __version__ diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index 814a79f3..bb24756c 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -54,6 +54,7 @@ class DjangoFilterBackend(DjangoFilterBackend): keyword. The default is "search" unless overriden but it's used here just to make sure we don't complain about it being an invalid filter. """ + search_param = api_settings.SEARCH_PARAM # Make this regex check for 'filter' as well as 'filter[...]' @@ -63,7 +64,9 @@ class DjangoFilterBackend(DjangoFilterBackend): # regex `\w` matches [a-zA-Z0-9_]. # TODO: U+0080 and above allowed but not recommended. Leave them out for now.e # Also, ' ' (space) is allowed within a member name but not recommended. - filter_regex = re.compile(r'^filter(?P\[?)(?P[\w\.\-]*)(?P\]?$)') + filter_regex = re.compile( + r"^filter(?P\[?)(?P[\w\.\-]*)(?P\]?$)" + ) def _validate_filter(self, keys, filterset_class): """ @@ -74,7 +77,7 @@ def _validate_filter(self, keys, filterset_class): :raises ValidationError: if key not in FilterSet keys or no FilterSet. """ for k in keys: - if ((not filterset_class) or (k not in filterset_class.base_filters)): + if (not filterset_class) or (k not in filterset_class.base_filters): raise ValidationError("invalid filter[{}]".format(k)) def get_filterset(self, request, queryset, view): @@ -86,7 +89,7 @@ def get_filterset(self, request, queryset, view): # TODO: .base_filters vs. .filters attr (not always present) filterset_class = self.get_filterset_class(view, queryset) kwargs = self.get_filterset_kwargs(request, queryset, view) - self._validate_filter(kwargs.pop('filter_keys'), filterset_class) + self._validate_filter(kwargs.pop("filter_keys"), filterset_class) if filterset_class is None: return None return filterset_class(**kwargs) @@ -103,24 +106,29 @@ def get_filterset_kwargs(self, request, queryset, view): data = request.query_params.copy() for qp, val in request.query_params.lists(): m = self.filter_regex.match(qp) - if m and (not m.groupdict()['assoc'] or - m.groupdict()['ldelim'] != '[' or m.groupdict()['rdelim'] != ']'): + if m and ( + not m.groupdict()["assoc"] + or m.groupdict()["ldelim"] != "[" + or m.groupdict()["rdelim"] != "]" + ): raise ValidationError("invalid query parameter: {}".format(qp)) if m and qp != self.search_param: if not all(val): - raise ValidationError("missing value for query parameter {}".format(qp)) + raise ValidationError( + "missing value for query parameter {}".format(qp) + ) # convert jsonapi relationship path to Django ORM's __ notation - key = m.groupdict()['assoc'].replace('.', '__') + key = m.groupdict()["assoc"].replace(".", "__") # undo JSON_API_FORMAT_FIELD_NAMES conversion: - key = format_value(key, 'underscore') + key = format_value(key, "underscore") data.setlist(key, val) filter_keys.append(key) del data[qp] return { - 'data': data, - 'queryset': queryset, - 'request': request, - 'filter_keys': filter_keys, + "data": data, + "queryset": queryset, + "request": request, + "filter_keys": filter_keys, } def get_schema_operation_parameters(self, view): @@ -133,6 +141,6 @@ def get_schema_operation_parameters(self, view): """ result = super(DjangoFilterBackend, self).get_schema_operation_parameters(view) for res in result: - if 'name' in res: - res['name'] = 'filter[{}]'.format(res['name']).replace('__', '.') + if "name" in res: + res["name"] = "filter[{}]".format(res["name"]).replace("__", ".") return result diff --git a/rest_framework_json_api/exceptions.py b/rest_framework_json_api/exceptions.py index 938a0c77..05f56756 100644 --- a/rest_framework_json_api/exceptions.py +++ b/rest_framework_json_api/exceptions.py @@ -8,7 +8,8 @@ def rendered_with_json_api(view): from rest_framework_json_api.renderers import JSONRenderer - for renderer_class in getattr(view, 'renderer_classes', []): + + for renderer_class in getattr(view, "renderer_classes", []): if issubclass(renderer_class, JSONRenderer): return True return False @@ -29,7 +30,7 @@ def exception_handler(exc, context): return response # Use regular DRF format if not rendered by DRF JSON API and not uniform - is_json_api_view = rendered_with_json_api(context['view']) + is_json_api_view = rendered_with_json_api(context["view"]) is_uniform = json_api_settings.UNIFORM_EXCEPTIONS if not is_json_api_view and not is_uniform: return response @@ -46,4 +47,4 @@ def exception_handler(exc, context): class Conflict(exceptions.APIException): status_code = status.HTTP_409_CONFLICT - default_detail = _('Conflict.') + default_detail = _("Conflict.") diff --git a/rest_framework_json_api/filters.py b/rest_framework_json_api/filters.py index eafcdb76..06f7667e 100644 --- a/rest_framework_json_api/filters.py +++ b/rest_framework_json_api/filters.py @@ -18,9 +18,10 @@ class OrderingFilter(OrderingFilter): Also applies DJA format_value() to convert (e.g. camelcase) to underscore. (See JSON_API_FORMAT_FIELD_NAMES in docs/usage.md) """ + #: override :py:attr:`rest_framework.filters.OrderingFilter.ordering_param` #: with JSON:API-compliant query parameter name. - ordering_param = 'sort' + ordering_param = "sort" def remove_invalid_fields(self, queryset, fields, view, request): """ @@ -31,16 +32,21 @@ def remove_invalid_fields(self, queryset, fields, view, request): :raises ValidationError: if a sort field is invalid. """ valid_fields = [ - item[0] for item in self.get_valid_fields(queryset, view, - {'request': request}) + 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 + 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))) + 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. @@ -48,14 +54,16 @@ def remove_invalid_fields(self, queryset, fields, view, request): underscore_fields = [] for item in fields: item_rewritten = item.replace(".", "__") - if item_rewritten.startswith('-'): + if item_rewritten.startswith("-"): underscore_fields.append( - '-' + format_value(item_rewritten.lstrip('-'), "underscore")) + "-" + format_value(item_rewritten.lstrip("-"), "underscore") + ) else: underscore_fields.append(format_value(item_rewritten, "underscore")) return super(OrderingFilter, self).remove_invalid_fields( - queryset, underscore_fields, view, request) + queryset, underscore_fields, view, request + ) class QueryParameterValidationFilter(BaseFilterBackend): @@ -68,9 +76,12 @@ class QueryParameterValidationFilter(BaseFilterBackend): override :py:attr:`query_regex` adding the new parameters. Make sure to comply with the rules at http://jsonapi.org/format/#query-parameters. """ + #: compiled regex that matches the allowed http://jsonapi.org/format/#query-parameters: #: `sort` and `include` stand alone; `filter`, `fields`, and `page` have []'s - query_regex = re.compile(r'^(sort|include)$|^(?Pfilter|fields|page)(\[[\w\.\-]+\])?$') + query_regex = re.compile( + r"^(sort|include)$|^(?Pfilter|fields|page)(\[[\w\.\-]+\])?$" + ) def validate_query_params(self, request): """ @@ -84,10 +95,14 @@ def validate_query_params(self, request): for qp in request.query_params.keys(): m = self.query_regex.match(qp) if not m: - raise ValidationError('invalid query parameter: {}'.format(qp)) - if not m.group('type') == 'filter' and len(request.query_params.getlist(qp)) > 1: + raise ValidationError("invalid query parameter: {}".format(qp)) + if ( + not m.group("type") == "filter" + and len(request.query_params.getlist(qp)) > 1 + ): raise ValidationError( - 'repeated query parameter not allowed: {}'.format(qp)) + "repeated query parameter not allowed: {}".format(qp) + ) def filter_queryset(self, request, queryset, view): """ diff --git a/rest_framework_json_api/metadata.py b/rest_framework_json_api/metadata.py index ef3356fe..a48af532 100644 --- a/rest_framework_json_api/metadata.py +++ b/rest_framework_json_api/metadata.py @@ -17,56 +17,65 @@ class JSONAPIMetadata(SimpleMetadata): There are not any formalized standards for `OPTIONS` responses for us to base this on. """ - type_lookup = ClassLookupDict({ - serializers.Field: 'GenericField', - serializers.RelatedField: 'Relationship', - serializers.BooleanField: 'Boolean', - serializers.NullBooleanField: 'Boolean', - serializers.CharField: 'String', - serializers.URLField: 'URL', - serializers.EmailField: 'Email', - serializers.RegexField: 'Regex', - serializers.SlugField: 'Slug', - serializers.IntegerField: 'Integer', - serializers.FloatField: 'Float', - serializers.DecimalField: 'Decimal', - serializers.DateField: 'Date', - serializers.DateTimeField: 'DateTime', - serializers.TimeField: 'Time', - serializers.ChoiceField: 'Choice', - serializers.MultipleChoiceField: 'MultipleChoice', - serializers.FileField: 'File', - serializers.ImageField: 'Image', - serializers.ListField: 'List', - serializers.DictField: 'Dict', - serializers.Serializer: 'Serializer', - }) + + type_lookup = ClassLookupDict( + { + serializers.Field: "GenericField", + serializers.RelatedField: "Relationship", + serializers.BooleanField: "Boolean", + serializers.NullBooleanField: "Boolean", + serializers.CharField: "String", + serializers.URLField: "URL", + serializers.EmailField: "Email", + serializers.RegexField: "Regex", + serializers.SlugField: "Slug", + serializers.IntegerField: "Integer", + serializers.FloatField: "Float", + serializers.DecimalField: "Decimal", + serializers.DateField: "Date", + serializers.DateTimeField: "DateTime", + serializers.TimeField: "Time", + serializers.ChoiceField: "Choice", + serializers.MultipleChoiceField: "MultipleChoice", + serializers.FileField: "File", + serializers.ImageField: "Image", + serializers.ListField: "List", + serializers.DictField: "Dict", + serializers.Serializer: "Serializer", + } + ) try: - relation_type_lookup = ClassLookupDict({ - related.ManyToManyDescriptor: 'ManyToMany', - related.ReverseManyToOneDescriptor: 'OneToMany', - related.ForwardManyToOneDescriptor: 'ManyToOne', - }) + relation_type_lookup = ClassLookupDict( + { + related.ManyToManyDescriptor: "ManyToMany", + related.ReverseManyToOneDescriptor: "OneToMany", + related.ForwardManyToOneDescriptor: "ManyToOne", + } + ) except AttributeError: - relation_type_lookup = ClassLookupDict({ - related.ManyRelatedObjectsDescriptor: 'ManyToMany', - related.ReverseManyRelatedObjectsDescriptor: 'ManyToMany', - related.ForeignRelatedObjectsDescriptor: 'OneToMany', - related.ReverseSingleRelatedObjectDescriptor: 'ManyToOne', - }) + relation_type_lookup = ClassLookupDict( + { + related.ManyRelatedObjectsDescriptor: "ManyToMany", + related.ReverseManyRelatedObjectsDescriptor: "ManyToMany", + related.ForeignRelatedObjectsDescriptor: "OneToMany", + related.ReverseSingleRelatedObjectDescriptor: "ManyToOne", + } + ) def determine_metadata(self, request, view): metadata = OrderedDict() - metadata['name'] = view.get_view_name() - metadata['description'] = view.get_view_description() - metadata['renders'] = [renderer.media_type for renderer in view.renderer_classes] - metadata['parses'] = [parser.media_type for parser in view.parser_classes] - metadata['allowed_methods'] = view.allowed_methods - if hasattr(view, 'get_serializer'): + metadata["name"] = view.get_view_name() + metadata["description"] = view.get_view_description() + metadata["renders"] = [ + renderer.media_type for renderer in view.renderer_classes + ] + metadata["parses"] = [parser.media_type for parser in view.parser_classes] + metadata["allowed_methods"] = view.allowed_methods + if hasattr(view, "get_serializer"): actions = self.determine_actions(request, view) if actions: - metadata['actions'] = actions + metadata["actions"] = actions return metadata def get_serializer_info(self, serializer): @@ -74,7 +83,7 @@ def get_serializer_info(self, serializer): Given an instance of a serializer, return a dictionary of metadata about its fields. """ - if hasattr(serializer, 'child'): + if hasattr(serializer, "child"): # If this is a `ListSerializer` then we want to examine the # underlying child serializer instance instead. serializer = serializer.child @@ -82,10 +91,12 @@ def get_serializer_info(self, serializer): # Remove the URL field if present serializer.fields.pop(api_settings.URL_FIELD_NAME, None) - return OrderedDict([ - (format_value(field_name), self.get_field_info(field)) - for field_name, field in serializer.fields.items() - ]) + return OrderedDict( + [ + (format_value(field_name), self.get_field_info(field)) + for field_name, field in serializer.fields.items() + ] + ) def get_field_info(self, field): """ @@ -96,13 +107,13 @@ def get_field_info(self, field): serializer = field.parent if isinstance(field, serializers.ManyRelatedField): - field_info['type'] = self.type_lookup[field.child_relation] + field_info["type"] = self.type_lookup[field.child_relation] else: - field_info['type'] = self.type_lookup[field] + field_info["type"] = self.type_lookup[field] try: - serializer_model = getattr(serializer.Meta, 'model') - field_info['relationship_type'] = self.relation_type_lookup[ + serializer_model = getattr(serializer.Meta, "model") + field_info["relationship_type"] = self.relation_type_lookup[ getattr(serializer_model, field.field_name) ] except KeyError: @@ -110,40 +121,51 @@ def get_field_info(self, field): except AttributeError: pass else: - field_info['relationship_resource'] = get_related_resource_type(field) + field_info["relationship_resource"] = get_related_resource_type(field) - field_info['required'] = getattr(field, 'required', False) + field_info["required"] = getattr(field, "required", False) attrs = [ - 'read_only', 'write_only', 'label', 'help_text', - 'min_length', 'max_length', - 'min_value', 'max_value', 'initial' + "read_only", + "write_only", + "label", + "help_text", + "min_length", + "max_length", + "min_value", + "max_value", + "initial", ] for attr in attrs: value = getattr(field, attr, None) - if value is not None and value != '': + if value is not None and value != "": field_info[attr] = force_str(value, strings_only=True) - if getattr(field, 'child', None): - field_info['child'] = self.get_field_info(field.child) - elif getattr(field, 'fields', None): - field_info['children'] = self.get_serializer_info(field) + if getattr(field, "child", None): + field_info["child"] = self.get_field_info(field.child) + elif getattr(field, "fields", None): + field_info["children"] = self.get_serializer_info(field) if ( - not field_info.get('read_only') and - not field_info.get('relationship_resource') and - hasattr(field, 'choices') + not field_info.get("read_only") + and not field_info.get("relationship_resource") + and hasattr(field, "choices") ): - field_info['choices'] = [ + field_info["choices"] = [ { - 'value': choice_value, - 'display_name': force_str(choice_name, strings_only=True) + "value": choice_value, + "display_name": force_str(choice_name, strings_only=True), } for choice_value, choice_name in field.choices.items() ] - if hasattr(serializer, 'included_serializers') and 'relationship_resource' in field_info: - field_info['allows_include'] = field.field_name in serializer.included_serializers + if ( + hasattr(serializer, "included_serializers") + and "relationship_resource" in field_info + ): + field_info["allows_include"] = ( + field.field_name in serializer.included_serializers + ) return field_info diff --git a/rest_framework_json_api/pagination.py b/rest_framework_json_api/pagination.py index 2e57b937..468f684c 100644 --- a/rest_framework_json_api/pagination.py +++ b/rest_framework_json_api/pagination.py @@ -12,14 +12,15 @@ class JsonApiPageNumberPagination(PageNumberPagination): """ A json-api compatible pagination format. """ - page_query_param = 'page[number]' - page_size_query_param = 'page[size]' + + page_query_param = "page[number]" + page_size_query_param = "page[size]" max_page_size = 100 def build_link(self, index): if not index: return None - url = self.request and self.request.build_absolute_uri() or '' + url = self.request and self.request.build_absolute_uri() or "" return replace_query_param(url, self.page_query_param, index) def get_paginated_response(self, data): @@ -31,22 +32,28 @@ def get_paginated_response(self, data): if self.page.has_previous(): previous = self.page.previous_page_number() - return Response({ - 'results': data, - 'meta': { - 'pagination': OrderedDict([ - ('page', self.page.number), - ('pages', self.page.paginator.num_pages), - ('count', self.page.paginator.count), - ]) - }, - 'links': OrderedDict([ - ('first', self.build_link(1)), - ('last', self.build_link(self.page.paginator.num_pages)), - ('next', self.build_link(next)), - ('prev', self.build_link(previous)) - ]) - }) + return Response( + { + "results": data, + "meta": { + "pagination": OrderedDict( + [ + ("page", self.page.number), + ("pages", self.page.paginator.num_pages), + ("count", self.page.paginator.count), + ] + ) + }, + "links": OrderedDict( + [ + ("first", self.build_link(1)), + ("last", self.build_link(self.page.paginator.num_pages)), + ("next", self.build_link(next)), + ("prev", self.build_link(previous)), + ] + ), + } + ) class JsonApiLimitOffsetPagination(LimitOffsetPagination): @@ -59,8 +66,9 @@ class JsonApiLimitOffsetPagination(LimitOffsetPagination): http://api.example.org/accounts/?page[offset]=400&page[limit]=100 """ - limit_query_param = 'page[limit]' - offset_query_param = 'page[offset]' + + limit_query_param = "page[limit]" + offset_query_param = "page[offset]" max_limit = 100 def get_last_link(self): @@ -85,19 +93,25 @@ def get_first_link(self): return remove_query_param(url, self.offset_query_param) def get_paginated_response(self, data): - return Response({ - 'results': data, - 'meta': { - 'pagination': OrderedDict([ - ('count', self.count), - ('limit', self.limit), - ('offset', self.offset), - ]) - }, - 'links': OrderedDict([ - ('first', self.get_first_link()), - ('last', self.get_last_link()), - ('next', self.get_next_link()), - ('prev', self.get_previous_link()) - ]) - }) + return Response( + { + "results": data, + "meta": { + "pagination": OrderedDict( + [ + ("count", self.count), + ("limit", self.limit), + ("offset", self.offset), + ] + ) + }, + "links": OrderedDict( + [ + ("first", self.get_first_link()), + ("last", self.get_last_link()), + ("next", self.get_next_link()), + ("prev", self.get_previous_link()), + ] + ), + } + ) diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index 88c4f522..e3315334 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -31,41 +31,44 @@ class JSONParser(parsers.JSONParser): We extract the attributes so that DRF serializers can work as normal. """ - media_type = 'application/vnd.api+json' + + media_type = "application/vnd.api+json" renderer_class = renderers.JSONRenderer @staticmethod def parse_attributes(data): - attributes = data.get('attributes') + attributes = data.get("attributes") uses_format_translation = json_api_settings.FORMAT_FIELD_NAMES if not attributes: return dict() elif uses_format_translation: # convert back to python/rest_framework's preferred underscore format - return utils.format_field_names(attributes, 'underscore') + return utils.format_field_names(attributes, "underscore") else: return attributes @staticmethod def parse_relationships(data): uses_format_translation = json_api_settings.FORMAT_FIELD_NAMES - relationships = data.get('relationships') + relationships = data.get("relationships") if not relationships: relationships = dict() elif uses_format_translation: # convert back to python/rest_framework's preferred underscore format - relationships = utils.format_field_names(relationships, 'underscore') + relationships = utils.format_field_names(relationships, "underscore") # Parse the relationships parsed_relationships = dict() for field_name, field_data in relationships.items(): - field_data = field_data.get('data') + field_data = field_data.get("data") if isinstance(field_data, dict) or field_data is None: parsed_relationships[field_name] = field_data elif isinstance(field_data, list): - parsed_relationships[field_name] = list(relation for relation in field_data) + parsed_relationships[field_name] = list( + relation for relation in field_data + ) return parsed_relationships @staticmethod @@ -75,9 +78,9 @@ def parse_metadata(result): it reads the `meta` content in the request body and returns it in a dictionary with a `_meta` top level key. """ - metadata = result.get('meta') + metadata = result.get("meta") if metadata: - return {'_meta': metadata} + return {"_meta": metadata} else: return {} @@ -89,79 +92,92 @@ def parse(self, stream, media_type=None, parser_context=None): stream, media_type=media_type, parser_context=parser_context ) - if not isinstance(result, dict) or 'data' not in result: - raise ParseError('Received document does not contain primary data') + if not isinstance(result, dict) or "data" not in result: + raise ParseError("Received document does not contain primary data") - data = result.get('data') - view = parser_context['view'] + data = result.get("data") + view = parser_context["view"] from rest_framework_json_api.views import RelationshipView + if isinstance(view, RelationshipView): # We skip parsing the object as JSONAPI Resource Identifier Object and not a regular # Resource Object if isinstance(data, list): for resource_identifier_object in data: if not ( - resource_identifier_object.get('id') and - resource_identifier_object.get('type') + resource_identifier_object.get("id") + and resource_identifier_object.get("type") ): raise ParseError( - 'Received data contains one or more malformed JSONAPI ' - 'Resource Identifier Object(s)' + "Received data contains one or more malformed JSONAPI " + "Resource Identifier Object(s)" ) - elif not (data.get('id') and data.get('type')): - raise ParseError('Received data is not a valid JSONAPI Resource Identifier Object') + elif not (data.get("id") and data.get("type")): + raise ParseError( + "Received data is not a valid JSONAPI Resource Identifier Object" + ) return data - request = parser_context.get('request') + request = parser_context.get("request") # Sanity check if not isinstance(data, dict): - raise ParseError('Received data is not a valid JSONAPI Resource Identifier Object') + raise ParseError( + "Received data is not a valid JSONAPI Resource Identifier Object" + ) # Check for inconsistencies - if request.method in ('PUT', 'POST', 'PATCH'): + if request.method in ("PUT", "POST", "PATCH"): resource_name = utils.get_resource_name( - parser_context, expand_polymorphic_types=True) + parser_context, expand_polymorphic_types=True + ) if isinstance(resource_name, str): - if data.get('type') != resource_name: + if data.get("type") != resource_name: raise exceptions.Conflict( "The resource object's type ({data_type}) is not the type that " "constitute the collection represented by the endpoint " "({resource_type}).".format( - data_type=data.get('type'), - resource_type=resource_name)) + data_type=data.get("type"), resource_type=resource_name + ) + ) else: - if data.get('type') not in resource_name: + if data.get("type") not in resource_name: raise exceptions.Conflict( "The resource object's type ({data_type}) is not the type that " "constitute the collection represented by the endpoint " "(one of [{resource_types}]).".format( - data_type=data.get('type'), - resource_types=", ".join(resource_name))) - if not data.get('id') and request.method in ('PATCH', 'PUT'): - raise ParseError("The resource identifier object must contain an 'id' member") - - if request.method in ('PATCH', 'PUT'): - lookup_url_kwarg = getattr(view, 'lookup_url_kwarg', None) or \ - getattr(view, 'lookup_field', None) - if lookup_url_kwarg and str(data.get('id')) != str(view.kwargs[lookup_url_kwarg]): + data_type=data.get("type"), + resource_types=", ".join(resource_name), + ) + ) + if not data.get("id") and request.method in ("PATCH", "PUT"): + raise ParseError( + "The resource identifier object must contain an 'id' member" + ) + + if request.method in ("PATCH", "PUT"): + lookup_url_kwarg = getattr(view, "lookup_url_kwarg", None) or getattr( + view, "lookup_field", None + ) + if lookup_url_kwarg and str(data.get("id")) != str( + view.kwargs[lookup_url_kwarg] + ): raise exceptions.Conflict( "The resource object's id ({data_id}) does not match url's " "lookup id ({url_id})".format( - data_id=data.get('id'), - url_id=view.kwargs[lookup_url_kwarg] + data_id=data.get("id"), url_id=view.kwargs[lookup_url_kwarg] ) ) # Construct the return data - serializer_class = getattr(view, 'serializer_class', None) - parsed_data = {'id': data.get('id')} if 'id' in data else {} + serializer_class = getattr(view, "serializer_class", None) + parsed_data = {"id": data.get("id")} if "id" in data else {} # `type` field needs to be allowed in none polymorphic serializers if serializer_class is not None: if issubclass(serializer_class, serializers.PolymorphicModelSerializer): - parsed_data['type'] = data.get('type') + parsed_data["type"] = data.get("type") parsed_data.update(self.parse_attributes(data)) parsed_data.update(self.parse_relationships(data)) parsed_data.update(self.parse_metadata(result)) diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index 95df1d48..2924cd56 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -18,14 +18,14 @@ get_included_serializers, get_resource_type_from_instance, get_resource_type_from_queryset, - get_resource_type_from_serializer + get_resource_type_from_serializer, ) LINKS_PARAMS = [ - 'self_link_view_name', - 'related_link_view_name', - 'related_link_lookup_field', - 'related_link_url_kwarg' + "self_link_view_name", + "related_link_view_name", + "related_link_lookup_field", + "related_link_url_kwarg", ] @@ -52,7 +52,7 @@ class ManyRelatedFieldWithNoData(SkipDataMixin, DRFManyRelatedField): class HyperlinkedMixin(object): self_link_view_name = None related_link_view_name = None - related_link_lookup_field = 'pk' + related_link_lookup_field = "pk" def __init__(self, self_link_view_name=None, related_link_view_name=None, **kwargs): if self_link_view_name is not None: @@ -61,10 +61,10 @@ def __init__(self, self_link_view_name=None, related_link_view_name=None, **kwar self.related_link_view_name = related_link_view_name self.related_link_lookup_field = kwargs.pop( - 'related_link_lookup_field', self.related_link_lookup_field + "related_link_lookup_field", self.related_link_lookup_field ) self.related_link_url_kwarg = kwargs.pop( - 'related_link_url_kwarg', self.related_link_lookup_field + "related_link_url_kwarg", self.related_link_lookup_field ) # We include this simply for dependency injection in tests. @@ -91,7 +91,7 @@ def get_url(self, name, view_name, kwargs, request): url = self.reverse(view_name, kwargs=kwargs, request=request) except NoReverseMatch: msg = ( - 'Could not resolve URL for hyperlinked relationship using ' + "Could not resolve URL for hyperlinked relationship using " 'view name "%s".' ) raise ImproperlyConfigured(msg % view_name) @@ -101,18 +101,26 @@ def get_url(self, name, view_name, kwargs, request): return Hyperlink(url, name) - def get_links(self, obj=None, lookup_field='pk'): - request = self.context.get('request', None) - view = self.context.get('view', None) + def get_links(self, obj=None, lookup_field="pk"): + request = self.context.get("request", None) + view = self.context.get("view", None) return_data = OrderedDict() - kwargs = {lookup_field: getattr(obj, lookup_field) if obj else view.kwargs[lookup_field]} + kwargs = { + lookup_field: getattr(obj, lookup_field) + if obj + else view.kwargs[lookup_field] + } self_kwargs = kwargs.copy() - self_kwargs.update({ - 'related_field': self.field_name if self.field_name else self.parent.field_name - }) - self_link = self.get_url('self', self.self_link_view_name, self_kwargs, request) + self_kwargs.update( + { + "related_field": self.field_name + if self.field_name + else self.parent.field_name + } + ) + self_link = self.get_url("self", self.self_link_view_name, self_kwargs, request) # Assuming RelatedField will be declared in two ways: # 1. url(r'^authors/(?P[^/.]+)/(?P\w+)/$', @@ -120,22 +128,25 @@ def get_links(self, obj=None, lookup_field='pk'): # 2. url(r'^authors/(?P[^/.]+)/bio/$', # AuthorBioViewSet.as_view({'get': 'retrieve'})) # So, if related_link_url_kwarg == 'pk' it will add 'related_field' parameter to reverse() - if self.related_link_url_kwarg == 'pk': + if self.related_link_url_kwarg == "pk": related_kwargs = self_kwargs else: - related_kwargs = {self.related_link_url_kwarg: kwargs[self.related_link_lookup_field]} + related_kwargs = { + self.related_link_url_kwarg: kwargs[self.related_link_lookup_field] + } - related_link = self.get_url('related', self.related_link_view_name, related_kwargs, request) + related_link = self.get_url( + "related", self.related_link_view_name, related_kwargs, request + ) if self_link: - return_data.update({'self': self_link}) + return_data.update({"self": self_link}) if related_link: - return_data.update({'related': related_link}) + return_data.update({"related": related_link}) return return_data class HyperlinkedRelatedField(HyperlinkedMixin, SkipDataMixin, RelatedField): - @classmethod def many_init(cls, *args, **kwargs): """ @@ -155,7 +166,7 @@ def many_init(cls, *args, **kwargs): kwargs['child'] = cls() return CustomManyRelatedField(*args, **kwargs) """ - list_kwargs = {'child_relation': cls(*args, **kwargs)} + list_kwargs = {"child_relation": cls(*args, **kwargs)} for key in kwargs: if key in MANY_RELATION_KWARGS: list_kwargs[key] = kwargs[key] @@ -166,25 +177,27 @@ class ResourceRelatedField(HyperlinkedMixin, PrimaryKeyRelatedField): _skip_polymorphic_optimization = True self_link_view_name = None related_link_view_name = None - related_link_lookup_field = 'pk' + related_link_lookup_field = "pk" default_error_messages = { - 'required': _('This field is required.'), - 'does_not_exist': _('Invalid pk "{pk_value}" - object does not exist.'), - 'incorrect_type': _( - 'Incorrect type. Expected resource identifier object, received {data_type}.' + "required": _("This field is required."), + "does_not_exist": _('Invalid pk "{pk_value}" - object does not exist.'), + "incorrect_type": _( + "Incorrect type. Expected resource identifier object, received {data_type}." ), - 'incorrect_relation_type': _( - 'Incorrect relation type. Expected {relation_type}, received {received_type}.' + "incorrect_relation_type": _( + "Incorrect relation type. Expected {relation_type}, received {received_type}." ), - 'missing_type': _('Invalid resource identifier object: missing \'type\' attribute'), - 'missing_id': _('Invalid resource identifier object: missing \'id\' attribute'), - 'no_match': _('Invalid hyperlink - No URL match.'), + "missing_type": _( + "Invalid resource identifier object: missing 'type' attribute" + ), + "missing_id": _("Invalid resource identifier object: missing 'id' attribute"), + "no_match": _("Invalid hyperlink - No URL match."), } def __init__(self, **kwargs): # check for a model class that was passed in for the relation type - model = kwargs.pop('model', None) + model = kwargs.pop("model", None) if model: self.model = model @@ -213,9 +226,9 @@ def to_internal_value(self, data): data = json.loads(data) except ValueError: # show a useful error if they send a `pk` instead of resource object - self.fail('incorrect_type', data_type=type(data).__name__) + self.fail("incorrect_type", data_type=type(data).__name__) if not isinstance(data, dict): - self.fail('incorrect_type', data_type=type(data).__name__) + self.fail("incorrect_type", data_type=type(data).__name__) expected_relation_type = get_resource_type_from_queryset(self.get_queryset()) serializer_resource_type = self.get_resource_type_from_included_serializer() @@ -223,23 +236,23 @@ def to_internal_value(self, data): if serializer_resource_type is not None: expected_relation_type = serializer_resource_type - if 'type' not in data: - self.fail('missing_type') + if "type" not in data: + self.fail("missing_type") - if 'id' not in data: - self.fail('missing_id') + if "id" not in data: + self.fail("missing_id") - if data['type'] != expected_relation_type: + if data["type"] != expected_relation_type: self.conflict( - 'incorrect_relation_type', + "incorrect_relation_type", relation_type=expected_relation_type, - received_type=data['type'] + received_type=data["type"], ) - return super(ResourceRelatedField, self).to_internal_value(data['id']) + return super(ResourceRelatedField, self).to_internal_value(data["id"]) def to_representation(self, value): - if getattr(self, 'pk_field', None) is not None: + if getattr(self, "pk_field", None) is not None: pk = self.pk_field.to_representation(value.pk) else: pk = value.pk @@ -248,7 +261,7 @@ def to_representation(self, value): if resource_type is None or not self._skip_polymorphic_optimization: resource_type = get_resource_type_from_instance(value) - return OrderedDict([('type', resource_type), ('id', str(pk))]) + return OrderedDict([("type", resource_type), ("id", str(pk))]) def get_resource_type_from_included_serializer(self): """ @@ -262,7 +275,7 @@ def get_resource_type_from_included_serializer(self): # accept both singular and plural versions of field_name field_names = [ inflection.singularize(field_name), - inflection.pluralize(field_name) + inflection.pluralize(field_name), ] includes = get_included_serializers(parent) for field in field_names: @@ -272,7 +285,7 @@ def get_resource_type_from_included_serializer(self): return None def get_parent_serializer(self): - if hasattr(self.parent, 'parent') and self.is_serializer(self.parent.parent): + if hasattr(self.parent, "parent") and self.is_serializer(self.parent.parent): return self.parent.parent elif self.is_serializer(self.parent): return self.parent @@ -292,13 +305,12 @@ def get_choices(self, cutoff=None): if cutoff is not None: queryset = queryset[:cutoff] - return OrderedDict([ - ( - json.dumps(self.to_representation(item)), - self.display_value(item) - ) - for item in queryset - ]) + return OrderedDict( + [ + (json.dumps(self.to_representation(item)), self.display_value(item)) + for item in queryset + ] + ) class PolymorphicResourceRelatedField(ResourceRelatedField): @@ -309,10 +321,15 @@ class PolymorphicResourceRelatedField(ResourceRelatedField): """ _skip_polymorphic_optimization = False - default_error_messages = dict(ResourceRelatedField.default_error_messages, **{ - 'incorrect_relation_type': _('Incorrect relation type. Expected one of [{relation_type}], ' - 'received {received_type}.'), - }) + default_error_messages = dict( + ResourceRelatedField.default_error_messages, + **{ + "incorrect_relation_type": _( + "Incorrect relation type. Expected one of [{relation_type}], " + "received {received_type}." + ), + } + ) def __init__(self, polymorphic_serializer, *args, **kwargs): self.polymorphic_serializer = polymorphic_serializer @@ -327,34 +344,37 @@ def to_internal_value(self, data): data = json.loads(data) except ValueError: # show a useful error if they send a `pk` instead of resource object - self.fail('incorrect_type', data_type=type(data).__name__) + self.fail("incorrect_type", data_type=type(data).__name__) if not isinstance(data, dict): - self.fail('incorrect_type', data_type=type(data).__name__) + self.fail("incorrect_type", data_type=type(data).__name__) - if 'type' not in data: - self.fail('missing_type') + if "type" not in data: + self.fail("missing_type") - if 'id' not in data: - self.fail('missing_id') + if "id" not in data: + self.fail("missing_id") expected_relation_types = self.polymorphic_serializer.get_polymorphic_types() - if data['type'] not in expected_relation_types: - self.conflict('incorrect_relation_type', relation_type=", ".join( - expected_relation_types), received_type=data['type']) + if data["type"] not in expected_relation_types: + self.conflict( + "incorrect_relation_type", + relation_type=", ".join(expected_relation_types), + received_type=data["type"], + ) - return super(ResourceRelatedField, self).to_internal_value(data['id']) + return super(ResourceRelatedField, self).to_internal_value(data["id"]) class SerializerMethodFieldBase(Field): def __init__(self, method_name=None, **kwargs): self.method_name = method_name - kwargs['source'] = '*' - kwargs['read_only'] = True + kwargs["source"] = "*" + kwargs["read_only"] = True super().__init__(**kwargs) def bind(self, field_name, parent): - default_method_name = 'get_{field_name}'.format(field_name=field_name) + default_method_name = "get_{field_name}".format(field_name=field_name) if self.method_name is None: self.method_name = default_method_name super().bind(field_name, parent) @@ -364,40 +384,46 @@ def get_attribute(self, instance): return serializer_method(instance) -class ManySerializerMethodResourceRelatedField(SerializerMethodFieldBase, ResourceRelatedField): +class ManySerializerMethodResourceRelatedField( + SerializerMethodFieldBase, ResourceRelatedField +): def __init__(self, child_relation=None, *args, **kwargs): - assert child_relation is not None, '`child_relation` is a required argument.' + assert child_relation is not None, "`child_relation` is a required argument." self.child_relation = child_relation super().__init__(**kwargs) - self.child_relation.bind(field_name='', parent=self) + self.child_relation.bind(field_name="", parent=self) def to_representation(self, value): return [self.child_relation.to_representation(item) for item in value] -class SerializerMethodResourceRelatedField(SerializerMethodFieldBase, ResourceRelatedField): +class SerializerMethodResourceRelatedField( + SerializerMethodFieldBase, ResourceRelatedField +): """ Allows us to use serializer method RelatedFields with return querysets """ - many_kwargs = [*MANY_RELATION_KWARGS, *LINKS_PARAMS, 'method_name', 'model'] + many_kwargs = [*MANY_RELATION_KWARGS, *LINKS_PARAMS, "method_name", "model"] many_cls = ManySerializerMethodResourceRelatedField @classmethod def many_init(cls, *args, **kwargs): - list_kwargs = {'child_relation': cls(**kwargs)} + list_kwargs = {"child_relation": cls(**kwargs)} for key in kwargs: if key in cls.many_kwargs: list_kwargs[key] = kwargs[key] return cls.many_cls(**list_kwargs) -class ManySerializerMethodHyperlinkedRelatedField(SkipDataMixin, - ManySerializerMethodResourceRelatedField): +class ManySerializerMethodHyperlinkedRelatedField( + SkipDataMixin, ManySerializerMethodResourceRelatedField +): pass -class SerializerMethodHyperlinkedRelatedField(SkipDataMixin, - SerializerMethodResourceRelatedField): +class SerializerMethodHyperlinkedRelatedField( + SkipDataMixin, SerializerMethodResourceRelatedField +): many_cls = ManySerializerMethodHyperlinkedRelatedField diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index a9b8dd82..4733288f 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -17,7 +17,11 @@ import rest_framework_json_api from rest_framework_json_api import utils -from rest_framework_json_api.relations import HyperlinkedMixin, ResourceRelatedField, SkipDataMixin +from rest_framework_json_api.relations import ( + HyperlinkedMixin, + ResourceRelatedField, + SkipDataMixin, +) class JSONRenderer(renderers.JSONRenderer): @@ -44,8 +48,8 @@ class JSONRenderer(renderers.JSONRenderer): } """ - media_type = 'application/vnd.api+json' - format = 'vnd.api+json' + media_type = "application/vnd.api+json" + format = "vnd.api+json" @classmethod def extract_attributes(cls, fields, resource): @@ -55,15 +59,13 @@ def extract_attributes(cls, fields, resource): data = OrderedDict() for field_name, field in iter(fields.items()): # ID is always provided in the root of JSON API so remove it from attributes - if field_name == 'id': + if field_name == "id": continue # don't output a key for write only fields if fields[field_name].write_only: continue # Skip fields with relations - if isinstance( - field, (relations.RelatedField, relations.ManyRelatedField) - ): + if isinstance(field, (relations.RelatedField, relations.ManyRelatedField)): continue # Skip read_only attribute fields when `resource` is an empty @@ -75,9 +77,7 @@ def extract_attributes(cls, fields, resource): if fields[field_name].read_only: continue - data.update({ - field_name: resource.get(field_name) - }) + data.update({field_name: resource.get(field_name)}) return utils.format_field_names(data) @@ -123,67 +123,74 @@ def extract_relationships(cls, fields, resource, resource_instance): relation_data = list() # Don't try to query an empty relation - relation_queryset = relation_instance \ - if relation_instance is not None else list() + relation_queryset = ( + relation_instance if relation_instance is not None else list() + ) for related_object in relation_queryset: relation_data.append( - OrderedDict([ - ('type', relation_type), - ('id', encoding.force_str(related_object.pk)) - ]) + OrderedDict( + [ + ("type", relation_type), + ("id", encoding.force_str(related_object.pk)), + ] + ) ) - data.update({field_name: { - 'links': { - "related": resource.get(field_name)}, - 'data': relation_data, - 'meta': { - 'count': len(relation_data) + data.update( + { + field_name: { + "links": {"related": resource.get(field_name)}, + "data": relation_data, + "meta": {"count": len(relation_data)}, + } } - }}) + ) continue relation_data = {} if isinstance(field, HyperlinkedMixin): - field_links = field.get_links(resource_instance, field.related_link_lookup_field) - relation_data.update({'links': field_links} if field_links else dict()) + field_links = field.get_links( + resource_instance, field.related_link_lookup_field + ) + relation_data.update({"links": field_links} if field_links else dict()) data.update({field_name: relation_data}) - if isinstance(field, (ResourceRelatedField, )): + if isinstance(field, (ResourceRelatedField,)): if not isinstance(field, SkipDataMixin): - relation_data.update({'data': resource.get(field_name)}) + relation_data.update({"data": resource.get(field_name)}) data.update({field_name: relation_data}) continue if isinstance( - field, (relations.PrimaryKeyRelatedField, relations.HyperlinkedRelatedField) + field, + (relations.PrimaryKeyRelatedField, relations.HyperlinkedRelatedField), ): resolved, relation = utils.get_relation_instance( - resource_instance, '%s_id' % source, field.parent + resource_instance, "%s_id" % source, field.parent ) if not resolved: continue relation_id = relation if resource.get(field_name) else None relation_data = { - 'data': ( - OrderedDict([ - ('type', relation_type), ('id', encoding.force_str(relation_id)) - ]) - if relation_id is not None else None) + "data": ( + OrderedDict( + [ + ("type", relation_type), + ("id", encoding.force_str(relation_id)), + ] + ) + if relation_id is not None + else None + ) } - if ( - isinstance(field, relations.HyperlinkedRelatedField) and - resource.get(field_name) - ): + if isinstance( + field, relations.HyperlinkedRelatedField + ) and resource.get(field_name): relation_data.update( - { - 'links': { - 'related': resource.get(field_name) - } - } + {"links": {"related": resource.get(field_name)}} ) data.update({field_name: relation_data}) continue @@ -199,25 +206,20 @@ def extract_relationships(cls, fields, resource, resource_instance): if isinstance(resource.get(field_name), Iterable): relation_data.update( - { - 'meta': {'count': len(resource.get(field_name))} - } + {"meta": {"count": len(resource.get(field_name))}} ) if isinstance(field.child_relation, ResourceRelatedField): # special case for ResourceRelatedField - relation_data.update( - {'data': resource.get(field_name)} - ) + relation_data.update({"data": resource.get(field_name)}) if isinstance(field.child_relation, HyperlinkedMixin): field_links = field.child_relation.get_links( resource_instance, - field.child_relation.related_link_lookup_field + field.child_relation.related_link_lookup_field, ) relation_data.update( - {'links': field_links} - if field_links else dict() + {"links": field_links} if field_links else dict() ) data.update({field_name: relation_data}) @@ -226,22 +228,28 @@ def extract_relationships(cls, fields, resource, resource_instance): relation_data = list() for nested_resource_instance in relation_instance: nested_resource_instance_type = ( - relation_type or - utils.get_resource_type_from_instance(nested_resource_instance) + relation_type + or utils.get_resource_type_from_instance( + nested_resource_instance + ) ) - relation_data.append(OrderedDict([ - ('type', nested_resource_instance_type), - ('id', encoding.force_str(nested_resource_instance.pk)) - ])) - data.update({ - field_name: { - 'data': relation_data, - 'meta': { - 'count': len(relation_data) + relation_data.append( + OrderedDict( + [ + ("type", nested_resource_instance_type), + ("id", encoding.force_str(nested_resource_instance.pk)), + ] + ) + ) + data.update( + { + field_name: { + "data": relation_data, + "meta": {"count": len(relation_data)}, } } - }) + ) continue return utils.format_field_names(data) @@ -263,8 +271,9 @@ def extract_relation_instance(cls, field, resource_instance): return None @classmethod - def extract_included(cls, fields, resource, resource_instance, included_resources, - included_cache): + def extract_included( + cls, fields, resource, resource_instance, included_resources, included_cache + ): """ Adds related data to the top level included key when the request includes ?include=example,example_field2 @@ -277,7 +286,9 @@ def extract_included(cls, fields, resource, resource_instance, included_resource context = current_serializer.context included_serializers = utils.get_included_serializers(current_serializer) included_resources = copy.copy(included_resources) - included_resources = [inflection.underscore(value) for value in included_resources] + included_resources = [ + inflection.underscore(value) for value in included_resources + ] for field_name, field in iter(fields.items()): # Skip URL field @@ -295,12 +306,12 @@ def extract_included(cls, fields, resource, resource_instance, included_resource except ValueError: # Skip fields not in requested included resources # If no child field, directly continue with the next field - if field_name not in [node.split('.')[0] for node in included_resources]: + if field_name not in [ + node.split(".")[0] for node in included_resources + ]: continue - relation_instance = cls.extract_relation_instance( - field, resource_instance - ) + relation_instance = cls.extract_relation_instance(field, resource_instance) if isinstance(relation_instance, Manager): relation_instance = relation_instance.all() @@ -315,11 +326,14 @@ def extract_included(cls, fields, resource, resource_instance, included_resource if relation_instance is None or not serializer_data: continue - many = field._kwargs.get('child_relation', None) is not None + many = field._kwargs.get("child_relation", None) is not None if isinstance(field, ResourceRelatedField) and not many: - already_included = serializer_data['type'] in included_cache and \ - serializer_data['id'] in included_cache[serializer_data['type']] + already_included = ( + serializer_data["type"] in included_cache + and serializer_data["id"] + in included_cache[serializer_data["type"]] + ) if already_included: continue @@ -328,9 +342,11 @@ def extract_included(cls, fields, resource, resource_instance, included_resource field = serializer_class(relation_instance, many=many, context=context) serializer_data = field.data - new_included_resources = [key.replace('%s.' % field_name, '', 1) - for key in included_resources - if field_name == key.split('.')[0]] + new_included_resources = [ + key.replace("%s." % field_name, "", 1) + for key in included_resources + if field_name == key.split(".")[0] + ] if isinstance(field, ListSerializer): serializer = field.child @@ -342,8 +358,10 @@ def extract_included(cls, fields, resource, resource_instance, included_resource serializer_resource = serializer_data[position] nested_resource_instance = relation_queryset[position] resource_type = ( - relation_type or - utils.get_resource_type_from_instance(nested_resource_instance) + relation_type + or utils.get_resource_type_from_instance( + nested_resource_instance + ) ) serializer_fields = utils.get_serializer_fields( serializer.__class__( @@ -355,10 +373,11 @@ def extract_included(cls, fields, resource, resource_instance, included_resource serializer_resource, nested_resource_instance, resource_type, - getattr(serializer, '_poly_force_type_resolution', False) + getattr(serializer, "_poly_force_type_resolution", False), ) - included_cache[new_item['type']][new_item['id']] = \ - utils.format_field_names(new_item) + included_cache[new_item["type"]][ + new_item["id"] + ] = utils.format_field_names(new_item) cls.extract_included( serializer_fields, serializer_resource, @@ -378,11 +397,11 @@ def extract_included(cls, fields, resource, resource_instance, included_resource serializer_data, relation_instance, relation_type, - getattr(field, '_poly_force_type_resolution', False) - ) - included_cache[new_item['type']][new_item['id']] = utils.format_field_names( - new_item + getattr(field, "_poly_force_type_resolution", False), ) + included_cache[new_item["type"]][ + new_item["id"] + ] = utils.format_field_names(new_item) cls.extract_included( serializer_fields, serializer_data, @@ -397,16 +416,14 @@ def extract_meta(cls, serializer, resource): Gathers the data from serializer fields specified in meta_fields and adds it to the meta object. """ - if hasattr(serializer, 'child'): - meta = getattr(serializer.child, 'Meta', None) + if hasattr(serializer, "child"): + meta = getattr(serializer.child, "Meta", None) else: - meta = getattr(serializer, 'Meta', None) - meta_fields = getattr(meta, 'meta_fields', []) + meta = getattr(serializer, "Meta", None) + meta_fields = getattr(meta, "meta_fields", []) data = OrderedDict() for field_name in meta_fields: - data.update({ - field_name: resource.get(field_name) - }) + data.update({field_name: resource.get(field_name)}) return data @classmethod @@ -415,20 +432,26 @@ def extract_root_meta(cls, serializer, resource): Calls a `get_root_meta` function on a serializer, if it exists. """ many = False - if hasattr(serializer, 'child'): + if hasattr(serializer, "child"): many = True serializer = serializer.child data = {} - if getattr(serializer, 'get_root_meta', None): + if getattr(serializer, "get_root_meta", None): json_api_meta = serializer.get_root_meta(resource, many) - assert isinstance(json_api_meta, dict), 'get_root_meta must return a dict' + assert isinstance(json_api_meta, dict), "get_root_meta must return a dict" data.update(json_api_meta) return data @classmethod - def build_json_resource_obj(cls, fields, resource, resource_instance, resource_name, - force_type_resolution=False): + def build_json_resource_obj( + cls, + fields, + resource, + resource_instance, + resource_name, + force_type_resolution=False, + ): """ Builds the resource object (type, id, attributes) and extracts relationships. """ @@ -436,28 +459,34 @@ def build_json_resource_obj(cls, fields, resource, resource_instance, resource_n if force_type_resolution: resource_name = utils.get_resource_type_from_instance(resource_instance) resource_data = [ - ('type', resource_name), - ('id', encoding.force_str(resource_instance.pk) if resource_instance else None), - ('attributes', cls.extract_attributes(fields, resource)), + ("type", resource_name), + ( + "id", + encoding.force_str(resource_instance.pk) if resource_instance else None, + ), + ("attributes", cls.extract_attributes(fields, resource)), ] relationships = cls.extract_relationships(fields, resource, resource_instance) if relationships: - resource_data.append(('relationships', relationships)) + resource_data.append(("relationships", relationships)) # Add 'self' link if field is present and valid - if api_settings.URL_FIELD_NAME in resource and \ - isinstance(fields[api_settings.URL_FIELD_NAME], relations.RelatedField): - resource_data.append(('links', {'self': resource[api_settings.URL_FIELD_NAME]})) + if api_settings.URL_FIELD_NAME in resource and isinstance( + fields[api_settings.URL_FIELD_NAME], relations.RelatedField + ): + resource_data.append( + ("links", {"self": resource[api_settings.URL_FIELD_NAME]}) + ) return OrderedDict(resource_data) - def render_relationship_view(self, data, accepted_media_type=None, renderer_context=None): + def render_relationship_view( + self, data, accepted_media_type=None, renderer_context=None + ): # Special case for RelationshipView view = renderer_context.get("view", None) - render_data = OrderedDict([ - ('data', data) - ]) + render_data = OrderedDict([("data", data)]) links = view.get_links() if links: - render_data.update({'links': links}), + render_data.update({"links": links}), return super(JSONRenderer, self).render( render_data, accepted_media_type, renderer_context ) @@ -478,20 +507,23 @@ def render(self, data, accepted_media_type=None, renderer_context=None): resource_name = utils.get_resource_name(renderer_context) # If this is an error response, skip the rest. - if resource_name == 'errors': + if resource_name == "errors": return self.render_errors(data, accepted_media_type, renderer_context) # if response.status_code is 204 then the data to be rendered must # be None - response = renderer_context.get('response', None) + response = renderer_context.get("response", None) if response is not None and response.status_code == 204: return super(JSONRenderer, self).render( None, accepted_media_type, renderer_context ) from rest_framework_json_api.views import RelationshipView + if isinstance(view, RelationshipView): - return self.render_relationship_view(data, accepted_media_type, renderer_context) + return self.render_relationship_view( + data, accepted_media_type, renderer_context + ) # If `resource_name` is set to None then render default as the dev # wants to build the output format manually. @@ -502,15 +534,15 @@ def render(self, data, accepted_media_type=None, renderer_context=None): json_api_data = data # initialize json_api_meta with pagination meta or an empty dict - json_api_meta = data.get('meta', {}) if isinstance(data, dict) else {} + json_api_meta = data.get("meta", {}) if isinstance(data, dict) else {} included_cache = defaultdict(dict) - if data and 'results' in data: + if data and "results" in data: serializer_data = data["results"] else: serializer_data = data - serializer = getattr(serializer_data, 'serializer', None) + serializer = getattr(serializer_data, "serializer", None) included_resources = utils.get_included_resources(request, serializer) @@ -519,66 +551,92 @@ def render(self, data, accepted_media_type=None, renderer_context=None): # Extract root meta for any type of serializer json_api_meta.update(self.extract_root_meta(serializer, serializer_data)) - if getattr(serializer, 'many', False): + if getattr(serializer, "many", False): json_api_data = list() for position in range(len(serializer_data)): resource = serializer_data[position] # Get current resource - resource_instance = serializer.instance[position] # Get current instance - - if isinstance(serializer.child, rest_framework_json_api. - serializers.PolymorphicModelSerializer): - resource_serializer_class = serializer.child.\ - get_polymorphic_serializer_for_instance(resource_instance)( - context=serializer.child.context - ) + resource_instance = serializer.instance[ + position + ] # Get current instance + + if isinstance( + serializer.child, + rest_framework_json_api.serializers.PolymorphicModelSerializer, + ): + resource_serializer_class = ( + serializer.child.get_polymorphic_serializer_for_instance( + resource_instance + )(context=serializer.child.context) + ) else: resource_serializer_class = serializer.child fields = utils.get_serializer_fields(resource_serializer_class) force_type_resolution = getattr( - resource_serializer_class, '_poly_force_type_resolution', False) + resource_serializer_class, "_poly_force_type_resolution", False + ) json_resource_obj = self.build_json_resource_obj( - fields, resource, resource_instance, resource_name, force_type_resolution + fields, + resource, + resource_instance, + resource_name, + force_type_resolution, ) meta = self.extract_meta(serializer, resource) if meta: - json_resource_obj.update({'meta': utils.format_field_names(meta)}) + json_resource_obj.update( + {"meta": utils.format_field_names(meta)} + ) json_api_data.append(json_resource_obj) self.extract_included( - fields, resource, resource_instance, included_resources, included_cache + fields, + resource, + resource_instance, + included_resources, + included_cache, ) else: fields = utils.get_serializer_fields(serializer) - force_type_resolution = getattr(serializer, '_poly_force_type_resolution', False) + force_type_resolution = getattr( + serializer, "_poly_force_type_resolution", False + ) resource_instance = serializer.instance json_api_data = self.build_json_resource_obj( - fields, serializer_data, resource_instance, resource_name, force_type_resolution + fields, + serializer_data, + resource_instance, + resource_name, + force_type_resolution, ) meta = self.extract_meta(serializer, serializer_data) if meta: - json_api_data.update({'meta': utils.format_field_names(meta)}) + json_api_data.update({"meta": utils.format_field_names(meta)}) self.extract_included( - fields, serializer_data, resource_instance, included_resources, included_cache + fields, + serializer_data, + resource_instance, + included_resources, + included_cache, ) # Make sure we render data in a specific order render_data = OrderedDict() - if isinstance(data, dict) and data.get('links'): - render_data['links'] = data.get('links') + if isinstance(data, dict) and data.get("links"): + render_data["links"] = data.get("links") # format the api root link list - if view.__class__ and view.__class__.__name__ == 'APIRoot': - render_data['data'] = None - render_data['links'] = json_api_data + if view.__class__ and view.__class__.__name__ == "APIRoot": + render_data["data"] = None + render_data["links"] = json_api_data else: - render_data['data'] = json_api_data + render_data["data"] = json_api_data if included_cache: if isinstance(json_api_data, list): @@ -587,22 +645,23 @@ def render(self, data, accepted_media_type=None, renderer_context=None): objects = [json_api_data] for object in objects: - obj_type = object.get('type') - obj_id = object.get('id') - if obj_type in included_cache and \ - obj_id in included_cache[obj_type]: + obj_type = object.get("type") + obj_id = object.get("id") + if obj_type in included_cache and obj_id in included_cache[obj_type]: del included_cache[obj_type][obj_id] if not included_cache[obj_type]: del included_cache[obj_type] if included_cache: - render_data['included'] = list() + render_data["included"] = list() for included_type in sorted(included_cache.keys()): for included_id in sorted(included_cache[included_type].keys()): - render_data['included'].append(included_cache[included_type][included_id]) + render_data["included"].append( + included_cache[included_type][included_id] + ) if json_api_meta: - render_data['meta'] = utils.format_field_names(json_api_meta) + render_data["meta"] = utils.format_field_names(json_api_meta) return super(JSONRenderer, self).render( render_data, accepted_media_type, renderer_context @@ -610,21 +669,21 @@ def render(self, data, accepted_media_type=None, renderer_context=None): class BrowsableAPIRenderer(renderers.BrowsableAPIRenderer): - template = 'rest_framework_json_api/api.html' - includes_template = 'rest_framework_json_api/includes.html' + template = "rest_framework_json_api/api.html" + includes_template = "rest_framework_json_api/includes.html" def get_context(self, data, accepted_media_type, renderer_context): context = super(BrowsableAPIRenderer, self).get_context( data, accepted_media_type, renderer_context ) - view = renderer_context['view'] + view = renderer_context["view"] - context['includes_form'] = self.get_includes_form(view) + context["includes_form"] = self.get_includes_form(view) return context @classmethod - def _get_included_serializers(cls, serializer, prefix='', already_seen=None): + def _get_included_serializers(cls, serializer, prefix="", already_seen=None): if not already_seen: already_seen = set() @@ -634,12 +693,15 @@ def _get_included_serializers(cls, serializer, prefix='', already_seen=None): included_serializers = [] already_seen.add(serializer) - for include, included_serializer in utils.get_included_serializers(serializer).items(): - included_serializers.append(f'{prefix}{include}') + for include, included_serializer in utils.get_included_serializers( + serializer + ).items(): + included_serializers.append(f"{prefix}{include}") included_serializers.extend( cls._get_included_serializers( - included_serializer, f'{prefix}{include}.', - already_seen=already_seen + included_serializer, + f"{prefix}{include}.", + already_seen=already_seen, ) ) @@ -651,9 +713,9 @@ def get_includes_form(self, view): except AttributeError: return - if not hasattr(serializer_class, 'included_serializers'): + if not hasattr(serializer_class, "included_serializers"): return template = loader.get_template(self.includes_template) - context = {'elements': self._get_included_serializers(serializer_class)} + context = {"elements": self._get_included_serializers(serializer_class)} return template.render(context) diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index fe6b095e..b51f366f 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -14,109 +14,104 @@ class SchemaGenerator(drf_openapi.SchemaGenerator): """ Extend DRF's SchemaGenerator to implement jsonapi-flavored generateschema command. """ + #: These JSONAPI component definitions are referenced by the generated OAS schema. #: If you need to add more or change these static component definitions, extend this dict. jsonapi_components = { - 'schemas': { - 'jsonapi': { - 'type': 'object', - 'description': "The server's implementation", - 'properties': { - 'version': {'type': 'string'}, - 'meta': {'$ref': '#/components/schemas/meta'} + "schemas": { + "jsonapi": { + "type": "object", + "description": "The server's implementation", + "properties": { + "version": {"type": "string"}, + "meta": {"$ref": "#/components/schemas/meta"}, }, - 'additionalProperties': False + "additionalProperties": False, }, - 'resource': { - 'type': 'object', - 'required': ['type', 'id'], - 'additionalProperties': False, - 'properties': { - 'type': { - '$ref': '#/components/schemas/type' - }, - 'id': { - '$ref': '#/components/schemas/id' - }, - 'attributes': { - 'type': 'object', + "resource": { + "type": "object", + "required": ["type", "id"], + "additionalProperties": False, + "properties": { + "type": {"$ref": "#/components/schemas/type"}, + "id": {"$ref": "#/components/schemas/id"}, + "attributes": { + "type": "object", # ... }, - 'relationships': { - 'type': 'object', + "relationships": { + "type": "object", # ... }, - 'links': { - '$ref': '#/components/schemas/links' - }, - 'meta': {'$ref': '#/components/schemas/meta'}, - } + "links": {"$ref": "#/components/schemas/links"}, + "meta": {"$ref": "#/components/schemas/meta"}, + }, }, - 'link': { - 'oneOf': [ + "link": { + "oneOf": [ { - 'description': "a string containing the link's URL", - 'type': 'string', - 'format': 'uri-reference' + "description": "a string containing the link's URL", + "type": "string", + "format": "uri-reference", }, { - 'type': 'object', - 'required': ['href'], - 'properties': { - 'href': { - 'description': "a string containing the link's URL", - 'type': 'string', - 'format': 'uri-reference' + "type": "object", + "required": ["href"], + "properties": { + "href": { + "description": "a string containing the link's URL", + "type": "string", + "format": "uri-reference", }, - 'meta': {'$ref': '#/components/schemas/meta'} - } - } + "meta": {"$ref": "#/components/schemas/meta"}, + }, + }, ] }, - 'links': { - 'type': 'object', - 'additionalProperties': {'$ref': '#/components/schemas/link'} + "links": { + "type": "object", + "additionalProperties": {"$ref": "#/components/schemas/link"}, }, - 'reltoone': { - 'description': "a singular 'to-one' relationship", - 'type': 'object', - 'properties': { - 'links': {'$ref': '#/components/schemas/relationshipLinks'}, - 'data': {'$ref': '#/components/schemas/relationshipToOne'}, - 'meta': {'$ref': '#/components/schemas/meta'} - } + "reltoone": { + "description": "a singular 'to-one' relationship", + "type": "object", + "properties": { + "links": {"$ref": "#/components/schemas/relationshipLinks"}, + "data": {"$ref": "#/components/schemas/relationshipToOne"}, + "meta": {"$ref": "#/components/schemas/meta"}, + }, }, - 'relationshipToOne': { - 'description': "reference to other resource in a to-one relationship", - 'anyOf': [ - {'$ref': '#/components/schemas/nulltype'}, - {'$ref': '#/components/schemas/linkage'} + "relationshipToOne": { + "description": "reference to other resource in a to-one relationship", + "anyOf": [ + {"$ref": "#/components/schemas/nulltype"}, + {"$ref": "#/components/schemas/linkage"}, ], }, - 'reltomany': { - 'description': "a multiple 'to-many' relationship", - 'type': 'object', - 'properties': { - 'links': {'$ref': '#/components/schemas/relationshipLinks'}, - 'data': {'$ref': '#/components/schemas/relationshipToMany'}, - 'meta': {'$ref': '#/components/schemas/meta'} - } + "reltomany": { + "description": "a multiple 'to-many' relationship", + "type": "object", + "properties": { + "links": {"$ref": "#/components/schemas/relationshipLinks"}, + "data": {"$ref": "#/components/schemas/relationshipToMany"}, + "meta": {"$ref": "#/components/schemas/meta"}, + }, }, - 'relationshipLinks': { - 'description': 'optional references to other resource objects', - 'type': 'object', - 'additionalProperties': True, - 'properties': { - 'self': {'$ref': '#/components/schemas/link'}, - 'related': {'$ref': '#/components/schemas/link'} - } + "relationshipLinks": { + "description": "optional references to other resource objects", + "type": "object", + "additionalProperties": True, + "properties": { + "self": {"$ref": "#/components/schemas/link"}, + "related": {"$ref": "#/components/schemas/link"}, + }, }, - 'relationshipToMany': { - 'description': "An array of objects each containing the " - "'type' and 'id' for to-many relationships", - 'type': 'array', - 'items': {'$ref': '#/components/schemas/linkage'}, - 'uniqueItems': True + "relationshipToMany": { + "description": "An array of objects each containing the " + "'type' and 'id' for to-many relationships", + "type": "array", + "items": {"$ref": "#/components/schemas/linkage"}, + "uniqueItems": True, }, # A RelationshipView uses a ResourceIdentifierObjectSerializer (hence the name # ResourceIdentifierObject returned by get_component_name()) which serializes type and @@ -124,159 +119,140 @@ class SchemaGenerator(drf_openapi.SchemaGenerator): # toMany or toOne so offer both options since we are not iterating over all the # possible {related_field}'s but rather rendering one path schema which may represent # toMany and toOne relationships. - 'ResourceIdentifierObject': { - 'oneOf': [ - {'$ref': '#/components/schemas/relationshipToOne'}, - {'$ref': '#/components/schemas/relationshipToMany'} + "ResourceIdentifierObject": { + "oneOf": [ + {"$ref": "#/components/schemas/relationshipToOne"}, + {"$ref": "#/components/schemas/relationshipToMany"}, ] }, - 'linkage': { - 'type': 'object', - 'description': "the 'type' and 'id'", - 'required': ['type', 'id'], - 'properties': { - 'type': {'$ref': '#/components/schemas/type'}, - 'id': {'$ref': '#/components/schemas/id'}, - 'meta': {'$ref': '#/components/schemas/meta'} - } + "linkage": { + "type": "object", + "description": "the 'type' and 'id'", + "required": ["type", "id"], + "properties": { + "type": {"$ref": "#/components/schemas/type"}, + "id": {"$ref": "#/components/schemas/id"}, + "meta": {"$ref": "#/components/schemas/meta"}, + }, }, - 'pagination': { - 'type': 'object', - 'properties': { - 'first': {'$ref': '#/components/schemas/pageref'}, - 'last': {'$ref': '#/components/schemas/pageref'}, - 'prev': {'$ref': '#/components/schemas/pageref'}, - 'next': {'$ref': '#/components/schemas/pageref'}, - } + "pagination": { + "type": "object", + "properties": { + "first": {"$ref": "#/components/schemas/pageref"}, + "last": {"$ref": "#/components/schemas/pageref"}, + "prev": {"$ref": "#/components/schemas/pageref"}, + "next": {"$ref": "#/components/schemas/pageref"}, + }, }, - 'pageref': { - 'oneOf': [ - {'type': 'string', 'format': 'uri-reference'}, - {'$ref': '#/components/schemas/nulltype'} + "pageref": { + "oneOf": [ + {"type": "string", "format": "uri-reference"}, + {"$ref": "#/components/schemas/nulltype"}, ] }, - 'failure': { - 'type': 'object', - 'required': ['errors'], - 'properties': { - 'errors': {'$ref': '#/components/schemas/errors'}, - 'meta': {'$ref': '#/components/schemas/meta'}, - 'jsonapi': {'$ref': '#/components/schemas/jsonapi'}, - 'links': {'$ref': '#/components/schemas/links'} - } + "failure": { + "type": "object", + "required": ["errors"], + "properties": { + "errors": {"$ref": "#/components/schemas/errors"}, + "meta": {"$ref": "#/components/schemas/meta"}, + "jsonapi": {"$ref": "#/components/schemas/jsonapi"}, + "links": {"$ref": "#/components/schemas/links"}, + }, }, - 'errors': { - 'type': 'array', - 'items': {'$ref': '#/components/schemas/error'}, - 'uniqueItems': True + "errors": { + "type": "array", + "items": {"$ref": "#/components/schemas/error"}, + "uniqueItems": True, }, - 'error': { - 'type': 'object', - 'additionalProperties': False, - 'properties': { - 'id': {'type': 'string'}, - 'status': {'type': 'string'}, - 'links': {'$ref': '#/components/schemas/links'}, - 'code': {'type': 'string'}, - 'title': {'type': 'string'}, - 'detail': {'type': 'string'}, - 'source': { - 'type': 'object', - 'properties': { - 'pointer': { - 'type': 'string', - 'description': - "A [JSON Pointer](https://tools.ietf.org/html/rfc6901) " - "to the associated entity in the request document " - "[e.g. `/data` for a primary data object, or " - "`/data/attributes/title` for a specific attribute." + "error": { + "type": "object", + "additionalProperties": False, + "properties": { + "id": {"type": "string"}, + "status": {"type": "string"}, + "links": {"$ref": "#/components/schemas/links"}, + "code": {"type": "string"}, + "title": {"type": "string"}, + "detail": {"type": "string"}, + "source": { + "type": "object", + "properties": { + "pointer": { + "type": "string", + "description": "A [JSON Pointer](https://tools.ietf.org/html/rfc6901) " + "to the associated entity in the request document " + "[e.g. `/data` for a primary data object, or " + "`/data/attributes/title` for a specific attribute.", }, - 'parameter': { - 'type': 'string', - 'description': - "A string indicating which query parameter " - "caused the error." + "parameter": { + "type": "string", + "description": "A string indicating which query parameter " + "caused the error.", }, - 'meta': {'$ref': '#/components/schemas/meta'} - } - } - } - }, - 'onlymeta': { - 'additionalProperties': False, - 'properties': { - 'meta': {'$ref': '#/components/schemas/meta'} - } + "meta": {"$ref": "#/components/schemas/meta"}, + }, + }, + }, }, - 'meta': { - 'type': 'object', - 'additionalProperties': True + "onlymeta": { + "additionalProperties": False, + "properties": {"meta": {"$ref": "#/components/schemas/meta"}}, }, - 'datum': { - 'description': 'singular item', - 'properties': { - 'data': {'$ref': '#/components/schemas/resource'} - } + "meta": {"type": "object", "additionalProperties": True}, + "datum": { + "description": "singular item", + "properties": {"data": {"$ref": "#/components/schemas/resource"}}, }, - 'nulltype': { - 'type': 'object', - 'nullable': True, - 'default': None + "nulltype": {"type": "object", "nullable": True, "default": None}, + "type": { + "type": "string", + "description": "The [type]" + "(https://jsonapi.org/format/#document-resource-object-identification) " + "member is used to describe resource objects that share common attributes " + "and relationships.", }, - 'type': { - 'type': 'string', - 'description': - 'The [type]' - '(https://jsonapi.org/format/#document-resource-object-identification) ' - 'member is used to describe resource objects that share common attributes ' - 'and relationships.' - }, - 'id': { - 'type': 'string', - 'description': - "Each resource object’s type and id pair MUST " - "[identify]" - "(https://jsonapi.org/format/#document-resource-object-identification) " - "a single, unique resource." + "id": { + "type": "string", + "description": "Each resource object’s type and id pair MUST " + "[identify]" + "(https://jsonapi.org/format/#document-resource-object-identification) " + "a single, unique resource.", }, }, - 'parameters': { - 'include': { - 'name': 'include', - 'in': 'query', - 'description': '[list of included related resources]' - '(https://jsonapi.org/format/#fetching-includes)', - 'required': False, - 'style': 'form', - 'schema': { - 'type': 'string' - } + "parameters": { + "include": { + "name": "include", + "in": "query", + "description": "[list of included related resources]" + "(https://jsonapi.org/format/#fetching-includes)", + "required": False, + "style": "form", + "schema": {"type": "string"}, }, # TODO: deepObject not well defined/supported: # https://github.com/OAI/OpenAPI-Specification/issues/1706 - 'fields': { - 'name': 'fields', - 'in': 'query', - 'description': '[sparse fieldsets]' - '(https://jsonapi.org/format/#fetching-sparse-fieldsets).\n' - 'Use fields[\\]=field1,field2,...,fieldN', - 'required': False, - 'style': 'deepObject', - 'schema': { - 'type': 'object', + "fields": { + "name": "fields", + "in": "query", + "description": "[sparse fieldsets]" + "(https://jsonapi.org/format/#fetching-sparse-fieldsets).\n" + "Use fields[\\]=field1,field2,...,fieldN", + "required": False, + "style": "deepObject", + "schema": { + "type": "object", }, - 'explode': True + "explode": True, }, - 'sort': { - 'name': 'sort', - 'in': 'query', - 'description': '[list of fields to sort by]' - '(https://jsonapi.org/format/#fetching-sorting)', - 'required': False, - 'style': 'form', - 'schema': { - 'type': 'string' - } + "sort": { + "name": "sort", + "in": "query", + "description": "[list of fields to sort by]" + "(https://jsonapi.org/format/#fetching-sorting)", + "required": False, + "style": "form", + "schema": {"type": "string"}, }, }, } @@ -299,10 +275,14 @@ def get_schema(self, request=None, public=False): #: - 'action' copy of current view.action (list/fetch) as this gets reset for each request. expanded_endpoints = [] for path, method, view in view_endpoints: - if hasattr(view, 'action') and view.action == 'retrieve_related': - expanded_endpoints += self._expand_related(path, method, view, view_endpoints) + if hasattr(view, "action") and view.action == "retrieve_related": + expanded_endpoints += self._expand_related( + path, method, view, view_endpoints + ) else: - expanded_endpoints.append((path, method, view, getattr(view, 'action', None))) + expanded_endpoints.append( + (path, method, view, getattr(view, "action", None)) + ) for path, method, view, action in expanded_endpoints: if not self.has_view_permissions(path, method, view): @@ -312,7 +292,7 @@ def get_schema(self, request=None, public=False): # (to_many). This patches the view.action appropriately so that # view.schema.get_operation() "does the right thing" for fetch vs. list. current_action = None - if hasattr(view, 'action'): + if hasattr(view, "action"): current_action = view.action view.action = action operation = view.schema.get_operation(path, method) @@ -323,16 +303,19 @@ def get_schema(self, request=None, public=False): if components_schemas[k] == components[k]: continue warnings.warn( - 'Schema component "{}" has been overriden with a different value.'.format(k)) + 'Schema component "{}" has been overriden with a different value.'.format( + k + ) + ) components_schemas.update(components) - if hasattr(view, 'action'): + if hasattr(view, "action"): view.action = current_action # Normalise path for any provided mount url. - if path.startswith('/'): + if path.startswith("/"): path = path[1:] - path = urljoin(self.url or '/', path) + path = urljoin(self.url or "/", path) paths.setdefault(path, {}) paths[path][method.lower()] = operation @@ -340,9 +323,9 @@ def get_schema(self, request=None, public=False): self.check_duplicate_operation_id(paths) # Compile final schema, overriding stuff from super class. - schema['paths'] = paths - schema['components'] = self.jsonapi_components - schema['components']['schemas'].update(components_schemas) + schema["paths"] = paths + schema["components"] = self.jsonapi_components + schema["components"]["schemas"].update(components_schemas) return schema @@ -362,18 +345,25 @@ def _expand_related(self, path, method, view, view_endpoints): # It's not obvious if it's allowed to have both included_ and related_ serializers, # so just merge both dicts. serializers = {} - if hasattr(serializer, 'included_serializers'): + if hasattr(serializer, "included_serializers"): serializers = {**serializers, **serializer.included_serializers} - if hasattr(serializer, 'related_serializers'): + if hasattr(serializer, "related_serializers"): serializers = {**serializers, **serializer.related_serializers} related_fields = [fs for fs in serializers.items()] for field, related_serializer in related_fields: - related_view = self._find_related_view(view_endpoints, related_serializer, view) + related_view = self._find_related_view( + view_endpoints, related_serializer, view + ) if related_view: action = self._field_is_one_or_many(field, view) result.append( - (path.replace('{related_field}', field), method, related_view, action) + ( + path.replace("{related_field}", field), + method, + related_view, + action, + ) ) return result @@ -390,7 +380,9 @@ def _find_related_view(self, view_endpoints, related_serializer, parent_view): for path, method, view in view_endpoints: view_serializer = view.get_serializer() if not isinstance(related_serializer, type): - related_serializer_class = import_class_from_dotted_path(related_serializer) + related_serializer_class = import_class_from_dotted_path( + related_serializer + ) else: related_serializer_class = related_serializer if isinstance(view_serializer, related_serializer_class): @@ -401,17 +393,18 @@ def _find_related_view(self, view_endpoints, related_serializer, parent_view): def _field_is_one_or_many(self, field, view): serializer = view.get_serializer() if isinstance(serializer.fields[field], ManyRelatedField): - return 'list' + return "list" else: - return 'fetch' + return "fetch" class AutoSchema(drf_openapi.AutoSchema): """ Extend DRF's openapi.AutoSchema for JSONAPI serialization. """ + #: ignore all the media types and only generate a JSONAPI schema. - content_types = ['application/vnd.api+json'] + content_types = ["application/vnd.api+json"] def get_operation(self, path, method): """ @@ -421,31 +414,31 @@ def get_operation(self, path, method): - special handling for POST, PATCH, DELETE """ operation = {} - operation['operationId'] = self.get_operation_id(path, method) - operation['description'] = self.get_description(path, method) + operation["operationId"] = self.get_operation_id(path, method) + operation["description"] = self.get_description(path, method) parameters = [] parameters += self.get_path_parameters(path, method) # pagination, filters only apply to GET/HEAD of collections and items - if method in ['GET', 'HEAD']: + if method in ["GET", "HEAD"]: parameters += self._get_include_parameters(path, method) parameters += self._get_fields_parameters(path, method) parameters += self._get_sort_parameters(path, method) parameters += self.get_pagination_parameters(path, method) parameters += self.get_filter_parameters(path, method) - operation['parameters'] = parameters + operation["parameters"] = parameters # get request and response code schemas - if method == 'GET': + if method == "GET": if is_list_view(path, method, self.view): self._add_get_collection_response(operation) else: self._add_get_item_response(operation) - elif method == 'POST': + elif method == "POST": self._add_post_item_response(operation, path) - elif method == 'PATCH': + elif method == "PATCH": self._add_patch_item_response(operation, path) - elif method == 'DELETE': + elif method == "DELETE": # should only allow deleting a resource, not a collection # TODO: implement delete of a relationship in future release. self._add_delete_item_response(operation, path) @@ -457,9 +450,9 @@ def get_operation_id(self, path, method): used for the main path as well as such as related and relationships. This concatenates the (mapped) method name and path as the spec allows most any """ - method_name = getattr(self.view, 'action', method.lower()) + method_name = getattr(self.view, "action", method.lower()) if is_list_view(path, method, self.view): - action = 'List' + action = "List" elif method_name not in self.method_mapping: action = method_name else: @@ -470,7 +463,7 @@ def _get_include_parameters(self, path, method): """ includes parameter: https://jsonapi.org/format/#fetching-includes """ - return [{'$ref': '#/components/parameters/include'}] + return [{"$ref": "#/components/parameters/include"}] def _get_fields_parameters(self, path, method): """ @@ -490,20 +483,20 @@ def _get_fields_parameters(self, path, method): # world: # type: string # noqa F821 # explode: true - return [{'$ref': '#/components/parameters/fields'}] + return [{"$ref": "#/components/parameters/fields"}] def _get_sort_parameters(self, path, method): """ sort parameter: https://jsonapi.org/format/#fetching-sorting """ - return [{'$ref': '#/components/parameters/sort'}] + return [{"$ref": "#/components/parameters/sort"}] def _add_get_collection_response(self, operation): """ Add GET 200 response for a collection to operation """ - operation['responses'] = { - '200': self._get_toplevel_200_response(operation, collection=True) + operation["responses"] = { + "200": self._get_toplevel_200_response(operation, collection=True) } self._add_get_4xx_responses(operation) @@ -511,8 +504,8 @@ def _add_get_item_response(self, operation): """ add GET 200 response for an item to operation """ - operation['responses'] = { - '200': self._get_toplevel_200_response(operation, collection=False) + operation["responses"] = { + "200": self._get_toplevel_200_response(operation, collection=False) } self._add_get_4xx_responses(operation) @@ -525,58 +518,57 @@ def _get_toplevel_200_response(self, operation, collection=True): Uses a $ref to the components.schemas. component definition. """ if collection: - data = {'type': 'array', 'items': self._get_reference(self.view.get_serializer())} + data = { + "type": "array", + "items": self._get_reference(self.view.get_serializer()), + } else: data = self._get_reference(self.view.get_serializer()) return { - 'description': operation['operationId'], - 'content': { - 'application/vnd.api+json': { - 'schema': { - 'type': 'object', - 'required': ['data'], - 'properties': { - 'data': data, - 'included': { - 'type': 'array', - 'uniqueItems': True, - 'items': { - '$ref': '#/components/schemas/resource' - } + "description": operation["operationId"], + "content": { + "application/vnd.api+json": { + "schema": { + "type": "object", + "required": ["data"], + "properties": { + "data": data, + "included": { + "type": "array", + "uniqueItems": True, + "items": {"$ref": "#/components/schemas/resource"}, }, - 'links': { - 'description': 'Link members related to primary data', - 'allOf': [ - {'$ref': '#/components/schemas/links'}, - {'$ref': '#/components/schemas/pagination'} - ] + "links": { + "description": "Link members related to primary data", + "allOf": [ + {"$ref": "#/components/schemas/links"}, + {"$ref": "#/components/schemas/pagination"}, + ], }, - 'jsonapi': { - '$ref': '#/components/schemas/jsonapi' - } - } + "jsonapi": {"$ref": "#/components/schemas/jsonapi"}, + }, } } - } + }, } def _add_post_item_response(self, operation, path): """ add response for POST of an item to operation """ - operation['requestBody'] = self.get_request_body(path, 'POST') - operation['responses'] = { - '201': self._get_toplevel_200_response(operation, collection=False) + operation["requestBody"] = self.get_request_body(path, "POST") + operation["responses"] = { + "201": self._get_toplevel_200_response(operation, collection=False) } - operation['responses']['201']['description'] = ( - '[Created](https://jsonapi.org/format/#crud-creating-responses-201). ' - 'Assigned `id` and/or any other changes are in this response.' + operation["responses"]["201"]["description"] = ( + "[Created](https://jsonapi.org/format/#crud-creating-responses-201). " + "Assigned `id` and/or any other changes are in this response." ) self._add_async_response(operation) - operation['responses']['204'] = { - 'description': '[Created](https://jsonapi.org/format/#crud-creating-responses-204) ' - 'with the supplied `id`. No other changes from what was POSTed.' + operation["responses"]["204"] = { + "description": "[Created](https://jsonapi.org/format/#crud-creating-responses-204) " + "with the supplied `id`. No other changes from what was POSTed." } self._add_post_4xx_responses(operation) @@ -584,9 +576,9 @@ def _add_patch_item_response(self, operation, path): """ Add PATCH response for an item to operation """ - operation['requestBody'] = self.get_request_body(path, 'PATCH') - operation['responses'] = { - '200': self._get_toplevel_200_response(operation, collection=False) + operation["requestBody"] = self.get_request_body(path, "PATCH") + operation["responses"] = { + "200": self._get_toplevel_200_response(operation, collection=False) } self._add_patch_4xx_responses(operation) @@ -596,7 +588,7 @@ def _add_delete_item_response(self, operation, path): """ # Only DELETE of relationships has a requestBody if isinstance(self.view, views.RelationshipView): - operation['requestBody'] = self.get_request_body(path, 'DELETE') + operation["requestBody"] = self.get_request_body(path, "DELETE") self._add_delete_responses(operation) def get_request_body(self, path, method): @@ -604,7 +596,7 @@ def get_request_body(self, path, method): A request body is required by jsonapi for POST, PATCH, and DELETE methods. """ serializer = self.get_serializer(path, method) - if not isinstance(serializer, (serializers.BaseSerializer, )): + if not isinstance(serializer, (serializers.BaseSerializer,)): return {} is_relationship = isinstance(self.view, views.RelationshipView) @@ -617,32 +609,35 @@ def get_request_body(self, path, method): # Another subclassed from base with required type/id but no required attributes (PATCH) if is_relationship: - item_schema = {'$ref': '#/components/schemas/ResourceIdentifierObject'} + item_schema = {"$ref": "#/components/schemas/ResourceIdentifierObject"} else: item_schema = self.map_serializer(serializer) - if method == 'POST': + if method == "POST": # 'type' and 'id' are both required for: # - all relationship operations # - regular PATCH or DELETE # Only 'type' is required for POST: system may assign the 'id'. - item_schema['required'] = ['type'] + item_schema["required"] = ["type"] - if 'properties' in item_schema and 'attributes' in item_schema['properties']: + if "properties" in item_schema and "attributes" in item_schema["properties"]: # No required attributes for PATCH - if method in ['PATCH', 'PUT'] and 'required' in item_schema['properties']['attributes']: - del item_schema['properties']['attributes']['required'] + if ( + method in ["PATCH", "PUT"] + and "required" in item_schema["properties"]["attributes"] + ): + del item_schema["properties"]["attributes"]["required"] # No read_only fields for request. - for name, schema in item_schema['properties']['attributes']['properties'].copy().items(): # noqa E501 - if 'readOnly' in schema: - del item_schema['properties']['attributes']['properties'][name] + for name, schema in ( + item_schema["properties"]["attributes"]["properties"].copy().items() + ): # noqa E501 + if "readOnly" in schema: + del item_schema["properties"]["attributes"]["properties"][name] return { - 'content': { + "content": { ct: { - 'schema': { - 'required': ['data'], - 'properties': { - 'data': item_schema - } + "schema": { + "required": ["data"], + "properties": {"data": item_schema}, } } for ct in self.content_types @@ -666,10 +661,14 @@ def map_serializer(self, serializer): if isinstance(field, serializers.HiddenField): continue if isinstance(field, serializers.RelatedField): - relationships[field.field_name] = {'$ref': '#/components/schemas/reltoone'} + relationships[field.field_name] = { + "$ref": "#/components/schemas/reltoone" + } continue if isinstance(field, serializers.ManyRelatedField): - relationships[field.field_name] = {'$ref': '#/components/schemas/reltomany'} + relationships[field.field_name] = { + "$ref": "#/components/schemas/reltomany" + } continue if field.required: @@ -677,47 +676,45 @@ def map_serializer(self, serializer): schema = self.map_field(field) if field.read_only: - schema['readOnly'] = True + schema["readOnly"] = True if field.write_only: - schema['writeOnly'] = True + schema["writeOnly"] = True if field.allow_null: - schema['nullable'] = True + schema["nullable"] = True if field.default and field.default != empty: - schema['default'] = field.default + schema["default"] = field.default if field.help_text: # Ensure django gettext_lazy is rendered correctly - schema['description'] = str(field.help_text) + schema["description"] = str(field.help_text) self.map_field_validators(field, schema) attributes[field.field_name] = schema result = { - 'type': 'object', - 'required': ['type', 'id'], - 'additionalProperties': False, - 'properties': { - 'type': {'$ref': '#/components/schemas/type'}, - 'id': {'$ref': '#/components/schemas/id'}, - 'links': { - 'type': 'object', - 'properties': { - 'self': {'$ref': '#/components/schemas/link'} - } - } - } + "type": "object", + "required": ["type", "id"], + "additionalProperties": False, + "properties": { + "type": {"$ref": "#/components/schemas/type"}, + "id": {"$ref": "#/components/schemas/id"}, + "links": { + "type": "object", + "properties": {"self": {"$ref": "#/components/schemas/link"}}, + }, + }, } if attributes: - result['properties']['attributes'] = { - 'type': 'object', - 'properties': attributes + result["properties"]["attributes"] = { + "type": "object", + "properties": attributes, } if required: - result['properties']['attributes']['required'] = required + result["properties"]["attributes"]["required"] = required if relationships: - result['properties']['relationships'] = { - 'type': 'object', - 'properties': relationships + result["properties"]["relationships"] = { + "type": "object", + "properties": relationships, } return result @@ -725,14 +722,14 @@ def _add_async_response(self, operation): """ Add async response to operation """ - operation['responses']['202'] = { - 'description': 'Accepted for [asynchronous processing]' - '(https://jsonapi.org/recommendations/#asynchronous-processing)', - 'content': { - 'application/vnd.api+json': { - 'schema': {'$ref': '#/components/schemas/datum'} + operation["responses"]["202"] = { + "description": "Accepted for [asynchronous processing]" + "(https://jsonapi.org/recommendations/#asynchronous-processing)", + "content": { + "application/vnd.api+json": { + "schema": {"$ref": "#/components/schemas/datum"} } - } + }, } def _failure_response(self, reason): @@ -740,28 +737,30 @@ def _failure_response(self, reason): Return failure response reason as the description """ return { - 'description': reason, - 'content': { - 'application/vnd.api+json': { - 'schema': {'$ref': '#/components/schemas/failure'} + "description": reason, + "content": { + "application/vnd.api+json": { + "schema": {"$ref": "#/components/schemas/failure"} } - } + }, } def _add_generic_failure_responses(self, operation): """ Add generic failure response(s) to operation """ - for code, reason in [('401', 'not authorized'), ]: - operation['responses'][code] = self._failure_response(reason) + for code, reason in [ + ("401", "not authorized"), + ]: + operation["responses"][code] = self._failure_response(reason) def _add_get_4xx_responses(self, operation): """ Add generic 4xx GET responses to operation """ self._add_generic_failure_responses(operation) - for code, reason in [('404', 'not found')]: - operation['responses'][code] = self._failure_response(reason) + for code, reason in [("404", "not found")]: + operation["responses"][code] = self._failure_response(reason) def _add_post_4xx_responses(self, operation): """ @@ -769,12 +768,21 @@ def _add_post_4xx_responses(self, operation): """ self._add_generic_failure_responses(operation) for code, reason in [ - ('403', '[Forbidden](https://jsonapi.org/format/#crud-creating-responses-403)'), - ('404', '[Related resource does not exist]' - '(https://jsonapi.org/format/#crud-creating-responses-404)'), - ('409', '[Conflict](https://jsonapi.org/format/#crud-creating-responses-409)'), + ( + "403", + "[Forbidden](https://jsonapi.org/format/#crud-creating-responses-403)", + ), + ( + "404", + "[Related resource does not exist]" + "(https://jsonapi.org/format/#crud-creating-responses-404)", + ), + ( + "409", + "[Conflict](https://jsonapi.org/format/#crud-creating-responses-409)", + ), ]: - operation['responses'][code] = self._failure_response(reason) + operation["responses"][code] = self._failure_response(reason) def _add_patch_4xx_responses(self, operation): """ @@ -782,37 +790,49 @@ def _add_patch_4xx_responses(self, operation): """ self._add_generic_failure_responses(operation) for code, reason in [ - ('403', '[Forbidden](https://jsonapi.org/format/#crud-updating-responses-403)'), - ('404', '[Related resource does not exist]' - '(https://jsonapi.org/format/#crud-updating-responses-404)'), - ('409', '[Conflict]([Conflict]' - '(https://jsonapi.org/format/#crud-updating-responses-409)'), + ( + "403", + "[Forbidden](https://jsonapi.org/format/#crud-updating-responses-403)", + ), + ( + "404", + "[Related resource does not exist]" + "(https://jsonapi.org/format/#crud-updating-responses-404)", + ), + ( + "409", + "[Conflict]([Conflict]" + "(https://jsonapi.org/format/#crud-updating-responses-409)", + ), ]: - operation['responses'][code] = self._failure_response(reason) + operation["responses"][code] = self._failure_response(reason) def _add_delete_responses(self, operation): """ Add generic DELETE responses to operation """ # the 2xx statuses: - operation['responses'] = { - '200': { - 'description': '[OK](https://jsonapi.org/format/#crud-deleting-responses-200)', - 'content': { - 'application/vnd.api+json': { - 'schema': {'$ref': '#/components/schemas/onlymeta'} + operation["responses"] = { + "200": { + "description": "[OK](https://jsonapi.org/format/#crud-deleting-responses-200)", + "content": { + "application/vnd.api+json": { + "schema": {"$ref": "#/components/schemas/onlymeta"} } - } + }, } } self._add_async_response(operation) - operation['responses']['204'] = { - 'description': '[no content](https://jsonapi.org/format/#crud-deleting-responses-204)', + operation["responses"]["204"] = { + "description": "[no content](https://jsonapi.org/format/#crud-deleting-responses-204)", } # the 4xx errors: self._add_generic_failure_responses(operation) for code, reason in [ - ('404', '[Resource does not exist]' - '(https://jsonapi.org/format/#crud-deleting-responses-404)'), + ( + "404", + "[Resource does not exist]" + "(https://jsonapi.org/format/#crud-deleting-responses-404)", + ), ]: - operation['responses'][code] = self._failure_response(reason) + operation["responses"][code] = self._failure_response(reason) diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 31f2a86b..50b546b6 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -12,45 +12,47 @@ get_included_serializers, get_resource_type_from_instance, get_resource_type_from_model, - get_resource_type_from_serializer + get_resource_type_from_serializer, ) class ResourceIdentifierObjectSerializer(BaseSerializer): default_error_messages = { - 'incorrect_model_type': _( - 'Incorrect model type. Expected {model_type}, received {received_type}.' + "incorrect_model_type": _( + "Incorrect model type. Expected {model_type}, received {received_type}." ), - 'does_not_exist': _('Invalid pk "{pk_value}" - object does not exist.'), - 'incorrect_type': _('Incorrect type. Expected pk value, received {data_type}.'), + "does_not_exist": _('Invalid pk "{pk_value}" - object does not exist.'), + "incorrect_type": _("Incorrect type. Expected pk value, received {data_type}."), } model_class = None def __init__(self, *args, **kwargs): - self.model_class = kwargs.pop('model_class', self.model_class) + self.model_class = kwargs.pop("model_class", self.model_class) # this has no fields but assumptions are made elsewhere that self.fields exists. self.fields = {} super(ResourceIdentifierObjectSerializer, self).__init__(*args, **kwargs) def to_representation(self, instance): return { - 'type': get_resource_type_from_instance(instance), - 'id': str(instance.pk) + "type": get_resource_type_from_instance(instance), + "id": str(instance.pk), } def to_internal_value(self, data): - if data['type'] != get_resource_type_from_model(self.model_class): + if data["type"] != get_resource_type_from_model(self.model_class): self.fail( - 'incorrect_model_type', model_type=self.model_class, received_type=data['type'] + "incorrect_model_type", + model_type=self.model_class, + received_type=data["type"], ) - pk = data['id'] + pk = data["id"] try: return self.model_class.objects.get(pk=pk) except ObjectDoesNotExist: - self.fail('does_not_exist', pk_value=pk) + self.fail("does_not_exist", pk_value=pk) except (TypeError, ValueError): - self.fail('incorrect_type', data_type=type(data['pk']).__name__) + self.fail("incorrect_type", data_type=type(data["pk"]).__name__) class SparseFieldsetsMixin(object): @@ -62,26 +64,30 @@ class SparseFieldsetsMixin(object): def __init__(self, *args, **kwargs): super(SparseFieldsetsMixin, self).__init__(*args, **kwargs) - context = kwargs.get('context') - request = context.get('request') if context else None + context = kwargs.get("context") + request = context.get("request") if context else None if request: - sparse_fieldset_query_param = 'fields[{}]'.format( + sparse_fieldset_query_param = "fields[{}]".format( get_resource_type_from_serializer(self) ) try: param_name = next( - key for key in request.query_params if sparse_fieldset_query_param == key + key + for key in request.query_params + if sparse_fieldset_query_param == key ) except StopIteration: pass else: - fieldset = request.query_params.get(param_name).split(',') + fieldset = request.query_params.get(param_name).split(",") # iterate over a *copy* of self.fields' underlying OrderedDict, because we may # modify the original during the iteration. # self.fields is a `rest_framework.utils.serializer_helpers.BindingDict` for field_name, field in self.fields.fields.copy().items(): - if field_name == api_settings.URL_FIELD_NAME: # leave self link there + if ( + field_name == api_settings.URL_FIELD_NAME + ): # leave self link there continue if field_name not in fieldset: self.fields.pop(field_name) @@ -96,19 +102,19 @@ class IncludedResourcesValidationMixin(object): """ def __init__(self, *args, **kwargs): - context = kwargs.get('context') - request = context.get('request') if context else None - view = context.get('view') if context else None + context = kwargs.get("context") + request = context.get("request") if context else None + view = context.get("view") if context else None def validate_path(serializer_class, field_path, path): serializers = get_included_serializers(serializer_class) if serializers is None: - raise ParseError('This endpoint does not support the include parameter') + raise ParseError("This endpoint does not support the include parameter") this_field_name = inflection.underscore(field_path[0]) this_included_serializer = serializers.get(this_field_name) if this_included_serializer is None: raise ParseError( - 'This endpoint does not support the include parameter for path {}'.format( + "This endpoint does not support the include parameter for path {}".format( path ) ) @@ -120,10 +126,12 @@ def validate_path(serializer_class, field_path, path): if request and view: included_resources = get_included_resources(request) for included_field_name in included_resources: - included_field_path = included_field_name.split('.') + included_field_path = included_field_name.split(".") this_serializer_class = view.get_serializer_class() # lets validate the current path - validate_path(this_serializer_class, included_field_path, included_field_name) + validate_path( + this_serializer_class, included_field_path, included_field_name + ) super(IncludedResourcesValidationMixin, self).__init__(*args, **kwargs) @@ -135,8 +143,10 @@ class SerializerMetaclass(SerializerMetaclass): # If user imports serializer from here we can catch class definition and check # nested serializers for depricated use. class Serializer( - IncludedResourcesValidationMixin, SparseFieldsetsMixin, Serializer, - metaclass=SerializerMetaclass + IncludedResourcesValidationMixin, + SparseFieldsetsMixin, + Serializer, + metaclass=SerializerMetaclass, ): """ A `Serializer` is a model-less serializer class with additional @@ -156,8 +166,10 @@ class Serializer( class HyperlinkedModelSerializer( - IncludedResourcesValidationMixin, SparseFieldsetsMixin, HyperlinkedModelSerializer, - metaclass=SerializerMetaclass + IncludedResourcesValidationMixin, + SparseFieldsetsMixin, + HyperlinkedModelSerializer, + metaclass=SerializerMetaclass, ): """ A type of `ModelSerializer` that uses hyperlinked relationships instead @@ -173,8 +185,12 @@ class HyperlinkedModelSerializer( """ -class ModelSerializer(IncludedResourcesValidationMixin, SparseFieldsetsMixin, ModelSerializer, - metaclass=SerializerMetaclass): +class ModelSerializer( + IncludedResourcesValidationMixin, + SparseFieldsetsMixin, + ModelSerializer, + metaclass=SerializerMetaclass, +): """ A `ModelSerializer` is just a regular `Serializer`, except that: @@ -196,6 +212,7 @@ class ModelSerializer(IncludedResourcesValidationMixin, SparseFieldsetsMixin, Mo * A mixin class to enable sparse fieldsets is included * A mixin class to enable validation of included resources is included """ + serializer_related_field = ResourceRelatedField def get_field_names(self, declared_fields, info): @@ -203,7 +220,7 @@ def get_field_names(self, declared_fields, info): We override the parent to omit explicity defined meta fields (such as SerializerMethodFields) from the list of declared fields """ - meta_fields = getattr(self.Meta, 'meta_fields', []) + meta_fields = getattr(self.Meta, "meta_fields", []) declared = OrderedDict() for field_name in set(declared_fields.keys()): @@ -211,7 +228,7 @@ def get_field_names(self, declared_fields, info): if field_name not in meta_fields: declared[field_name] = field fields = super(ModelSerializer, self).get_field_names(declared, info) - return list(fields) + list(getattr(self.Meta, 'meta_fields', list())) + return list(fields) + list(getattr(self.Meta, "meta_fields", list())) class PolymorphicSerializerMetaclass(SerializerMetaclass): @@ -221,7 +238,9 @@ class PolymorphicSerializerMetaclass(SerializerMetaclass): """ def __new__(cls, name, bases, attrs): - new_class = super(PolymorphicSerializerMetaclass, cls).__new__(cls, name, bases, attrs) + new_class = super(PolymorphicSerializerMetaclass, cls).__new__( + cls, name, bases, attrs + ) # Ensure initialization is only performed for subclasses of PolymorphicModelSerializer # (excluding PolymorphicModelSerializer class itself). @@ -229,17 +248,21 @@ def __new__(cls, name, bases, attrs): if not parents: return new_class - polymorphic_serializers = getattr(new_class, 'polymorphic_serializers', None) + polymorphic_serializers = getattr(new_class, "polymorphic_serializers", None) if not polymorphic_serializers: raise NotImplementedError( - "A PolymorphicModelSerializer must define a `polymorphic_serializers` attribute.") + "A PolymorphicModelSerializer must define a `polymorphic_serializers` attribute." + ) serializer_to_model = { - serializer: serializer.Meta.model for serializer in polymorphic_serializers} + serializer: serializer.Meta.model for serializer in polymorphic_serializers + } model_to_serializer = { - serializer.Meta.model: serializer for serializer in polymorphic_serializers} + serializer.Meta.model: serializer for serializer in polymorphic_serializers + } type_to_serializer = { - get_resource_type_from_serializer(serializer): serializer for - serializer in polymorphic_serializers} + get_resource_type_from_serializer(serializer): serializer + for serializer in polymorphic_serializers + } new_class._poly_serializer_model_map = serializer_to_model new_class._poly_model_serializer_map = model_to_serializer new_class._poly_type_serializer_map = type_to_serializer @@ -252,21 +275,30 @@ def __new__(cls, name, bases, attrs): return new_class -class PolymorphicModelSerializer(ModelSerializer, metaclass=PolymorphicSerializerMetaclass): +class PolymorphicModelSerializer( + ModelSerializer, metaclass=PolymorphicSerializerMetaclass +): """ A serializer for polymorphic models. Useful for "lazy" parent models. Leaves should be represented with a regular serializer. """ + def get_fields(self): """ Return an exhaustive list of the polymorphic serializer fields. """ if self.instance not in (None, []): if not isinstance(self.instance, QuerySet): - serializer_class = self.get_polymorphic_serializer_for_instance(self.instance) - return serializer_class(self.instance, context=self.context).get_fields() + serializer_class = self.get_polymorphic_serializer_for_instance( + self.instance + ) + return serializer_class( + self.instance, context=self.context + ).get_fields() else: - raise Exception("Cannot get fields from a polymorphic serializer given a queryset") + raise Exception( + "Cannot get fields from a polymorphic serializer given a queryset" + ) return super(PolymorphicModelSerializer, self).get_fields() @classmethod @@ -281,7 +313,9 @@ def get_polymorphic_serializer_for_instance(cls, instance): except KeyError: raise NotImplementedError( "No polymorphic serializer has been found for model {}".format( - instance._meta.model.__name__)) + instance._meta.model.__name__ + ) + ) @classmethod def get_polymorphic_model_for_serializer(cls, serializer): @@ -294,7 +328,10 @@ def get_polymorphic_model_for_serializer(cls, serializer): return cls._poly_serializer_model_map[serializer] except KeyError: raise NotImplementedError( - "No polymorphic model has been found for serializer {}".format(serializer.__name__)) + "No polymorphic model has been found for serializer {}".format( + serializer.__name__ + ) + ) @classmethod def get_polymorphic_serializer_for_type(cls, obj_type): @@ -307,7 +344,8 @@ def get_polymorphic_serializer_for_type(cls, obj_type): return cls._poly_type_serializer_map[obj_type] except KeyError: raise NotImplementedError( - "No polymorphic serializer has been found for type {}".format(obj_type)) + "No polymorphic serializer has been found for type {}".format(obj_type) + ) @classmethod def get_polymorphic_model_for_type(cls, obj_type): @@ -317,7 +355,8 @@ def get_polymorphic_model_for_type(cls, obj_type): means that a serializer is missing in the class's `polymorphic_serializers` attribute. """ return cls.get_polymorphic_model_for_serializer( - cls.get_polymorphic_serializer_for_type(obj_type)) + cls.get_polymorphic_serializer_for_type(obj_type) + ) @classmethod def get_polymorphic_types(cls): @@ -331,21 +370,27 @@ def to_representation(self, instance): Retrieve the appropriate polymorphic serializer and use this to handle representation. """ serializer_class = self.get_polymorphic_serializer_for_instance(instance) - return serializer_class(instance, context=self.context).to_representation(instance) + return serializer_class(instance, context=self.context).to_representation( + instance + ) def to_internal_value(self, data): """ Ensure that the given type is one of the expected polymorphic types, then retrieve the appropriate polymorphic serializer and use this to handle internal value. """ - received_type = data.get('type') + received_type = data.get("type") expected_types = self.get_polymorphic_types() if received_type not in expected_types: raise Conflict( - 'Incorrect relation type. Expected on of [{expected_types}], ' - 'received {received_type}.'.format( - expected_types=', '.join(expected_types), received_type=received_type)) + "Incorrect relation type. Expected on of [{expected_types}], " + "received {received_type}.".format( + expected_types=", ".join(expected_types), + received_type=received_type, + ) + ) serializer_class = self.get_polymorphic_serializer_for_type(received_type) self.__class__ = serializer_class - return serializer_class(self.instance, data, context=self.context, - partial=self.partial).to_internal_value(data) + return serializer_class( + self.instance, data, context=self.context, partial=self.partial + ).to_internal_value(data) diff --git a/rest_framework_json_api/settings.py b/rest_framework_json_api/settings.py index 1385630c..0384894c 100644 --- a/rest_framework_json_api/settings.py +++ b/rest_framework_json_api/settings.py @@ -7,13 +7,13 @@ from django.conf import settings from django.core.signals import setting_changed -JSON_API_SETTINGS_PREFIX = 'JSON_API_' +JSON_API_SETTINGS_PREFIX = "JSON_API_" DEFAULTS = { - 'FORMAT_FIELD_NAMES': False, - 'FORMAT_TYPES': False, - 'PLURALIZE_TYPES': False, - 'UNIFORM_EXCEPTIONS': False, + "FORMAT_FIELD_NAMES": False, + "FORMAT_TYPES": False, + "PLURALIZE_TYPES": False, + "UNIFORM_EXCEPTIONS": False, } @@ -31,7 +31,9 @@ def __getattr__(self, attr): if attr not in self.defaults: raise AttributeError("Invalid JSON API setting: '%s'" % attr) - value = getattr(self.user_settings, JSON_API_SETTINGS_PREFIX + attr, self.defaults[attr]) + value = getattr( + self.user_settings, JSON_API_SETTINGS_PREFIX + attr, self.defaults[attr] + ) # Cache the result setattr(self, attr, value) @@ -42,9 +44,9 @@ def __getattr__(self, attr): def reload_json_api_settings(*args, **kwargs): - django_setting = kwargs['setting'] - setting = django_setting.replace(JSON_API_SETTINGS_PREFIX, '') - value = kwargs['value'] + django_setting = kwargs["setting"] + setting = django_setting.replace(JSON_API_SETTINGS_PREFIX, "") + value = kwargs["value"] if setting in DEFAULTS.keys(): if value is not None: setattr(json_api_settings, setting, value) diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index b99de91a..41683303 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -8,7 +8,7 @@ from django.db.models import Manager from django.db.models.fields.related_descriptors import ( ManyToManyDescriptor, - ReverseManyToOneDescriptor + ReverseManyToOneDescriptor, ) from django.http import Http404 from django.utils import encoding @@ -20,7 +20,7 @@ from .settings import json_api_settings # Generic relation descriptor from django.contrib.contenttypes. -if 'django.contrib.contenttypes' not in settings.INSTALLED_APPS: # pragma: no cover +if "django.contrib.contenttypes" not in settings.INSTALLED_APPS: # pragma: no cover # Target application does not use contenttypes. Importing would cause errors. ReverseGenericManyToOneDescriptor = object() else: @@ -32,7 +32,8 @@ def get_resource_name(context, expand_polymorphic_types=False): Return the name of a resource. """ from rest_framework_json_api.serializers import PolymorphicModelSerializer - view = context.get('view') + + view = context.get("view") # Sanity check to make sure we have a view. if not view: @@ -45,18 +46,20 @@ def get_resource_name(context, expand_polymorphic_types=False): except (AttributeError, ValueError): pass else: - if code.startswith('4') or code.startswith('5'): - return 'errors' + if code.startswith("4") or code.startswith("5"): + return "errors" try: - resource_name = getattr(view, 'resource_name') + resource_name = getattr(view, "resource_name") except AttributeError: try: - if 'kwargs' in context and 'related_field' in context['kwargs']: + if "kwargs" in context and "related_field" in context["kwargs"]: serializer = view.get_related_serializer_class() else: serializer = view.get_serializer_class() - if expand_polymorphic_types and issubclass(serializer, PolymorphicModelSerializer): + if expand_polymorphic_types and issubclass( + serializer, PolymorphicModelSerializer + ): return serializer.get_polymorphic_types() else: return get_resource_type_from_serializer(serializer) @@ -78,15 +81,15 @@ def get_resource_name(context, expand_polymorphic_types=False): def get_serializer_fields(serializer): fields = None - if hasattr(serializer, 'child'): - fields = getattr(serializer.child, 'fields') - meta = getattr(serializer.child, 'Meta', None) - if hasattr(serializer, 'fields'): - fields = getattr(serializer, 'fields') - meta = getattr(serializer, 'Meta', None) + if hasattr(serializer, "child"): + fields = getattr(serializer.child, "fields") + meta = getattr(serializer.child, "Meta", None) + if hasattr(serializer, "fields"): + fields = getattr(serializer, "fields") + meta = getattr(serializer, "Meta", None) if fields is not None: - meta_fields = getattr(meta, 'meta_fields', {}) + meta_fields = getattr(meta, "meta_fields", {}) for field in meta_fields: try: fields.pop(field) @@ -118,15 +121,15 @@ def format_field_names(obj, format_type=None): def format_value(value, format_type=None): if format_type is None: format_type = json_api_settings.FORMAT_FIELD_NAMES - if format_type == 'dasherize': + if format_type == "dasherize": # inflection can't dasherize camelCase value = inflection.underscore(value) value = inflection.dasherize(value) - elif format_type == 'camelize': + elif format_type == "camelize": value = inflection.camelize(value, False) - elif format_type == 'capitalize': + elif format_type == "capitalize": value = inflection.camelize(value) - elif format_type == 'underscore': + elif format_type == "underscore": value = inflection.underscore(value) return value @@ -147,22 +150,24 @@ def format_resource_type(value, format_type=None, pluralize=None): def get_related_resource_type(relation): from rest_framework_json_api.serializers import PolymorphicModelSerializer + try: return get_resource_type_from_serializer(relation) except AttributeError: pass relation_model = None - if hasattr(relation, '_meta'): + if hasattr(relation, "_meta"): relation_model = relation._meta.model - elif hasattr(relation, 'model'): + elif hasattr(relation, "model"): # 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: + elif hasattr(relation, "get_queryset") and relation.get_queryset() is not None: relation_model = relation.get_queryset().model elif ( - getattr(relation, 'many', False) and - hasattr(relation.child, 'Meta') and - hasattr(relation.child.Meta, 'model')): + getattr(relation, "many", False) + and hasattr(relation.child, "Meta") + and hasattr(relation.child.Meta, "model") + ): # For ManyToMany relationships, get the model from the child # serializer of the list serializer relation_model = relation.child.Meta.model @@ -171,20 +176,25 @@ def get_related_resource_type(relation): parent_model = None if isinstance(parent_serializer, PolymorphicModelSerializer): parent_model = parent_serializer.get_polymorphic_serializer_for_instance( - parent_serializer.instance).Meta.model - elif 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_serializer.instance + ).Meta.model + elif 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) if parent_model is not None: if relation.source: - if relation.source != '*': + if relation.source != "*": parent_model_relation = getattr(parent_model, relation.source) else: 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 + ) parent_model_relation_type = type(parent_model_relation) if parent_model_relation_type is ReverseManyToOneDescriptor: @@ -196,7 +206,7 @@ def get_related_resource_type(relation): relation_model = parent_model_relation.field.model elif parent_model_relation_type is ReverseGenericManyToOneDescriptor: relation_model = parent_model_relation.rel.model - elif hasattr(parent_model_relation, 'field'): + elif hasattr(parent_model_relation, "field"): try: relation_model = parent_model_relation.field.remote_field.model except AttributeError: @@ -205,17 +215,16 @@ 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) def get_resource_type_from_model(model): - json_api_meta = getattr(model, 'JSONAPIMeta', None) - return getattr( - json_api_meta, - 'resource_name', - format_resource_type(model.__name__)) + json_api_meta = getattr(model, "JSONAPIMeta", None) + return getattr(json_api_meta, "resource_name", format_resource_type(model.__name__)) def get_resource_type_from_queryset(qs): @@ -223,7 +232,7 @@ def get_resource_type_from_queryset(qs): def get_resource_type_from_instance(instance): - if hasattr(instance, '_meta'): + if hasattr(instance, "_meta"): return get_resource_type_from_model(instance._meta.model) @@ -232,39 +241,41 @@ def get_resource_type_from_manager(manager): def get_resource_type_from_serializer(serializer): - json_api_meta = getattr(serializer, 'JSONAPIMeta', None) - meta = getattr(serializer, 'Meta', None) - if hasattr(json_api_meta, 'resource_name'): + json_api_meta = getattr(serializer, "JSONAPIMeta", None) + meta = getattr(serializer, "Meta", None) + if hasattr(json_api_meta, "resource_name"): return json_api_meta.resource_name - elif hasattr(meta, 'resource_name'): + elif hasattr(meta, "resource_name"): return meta.resource_name - elif hasattr(meta, 'model'): + elif hasattr(meta, "model"): return get_resource_type_from_model(meta.model) raise AttributeError() 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(',') + return include_resources_param.split(",") else: return get_default_included_resources_from_serializer(serializer) def get_default_included_resources_from_serializer(serializer): - meta = getattr(serializer, 'JSONAPIMeta', None) - if meta is None and getattr(serializer, 'many', False): - meta = getattr(serializer.child, 'JSONAPIMeta', None) - return list(getattr(meta, 'included_resources', [])) + meta = getattr(serializer, "JSONAPIMeta", None) + if meta is None and getattr(serializer, "many", False): + meta = getattr(serializer.child, "JSONAPIMeta", None) + return list(getattr(meta, "included_resources", [])) 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 iter(included_serializers.items()): if not isinstance(value, type): - if value == 'self': + if value == "self": included_serializers[name] = ( serializer if isinstance(serializer, type) else serializer.__class__ ) @@ -281,7 +292,7 @@ def get_relation_instance(resource_instance, source, serializer): # if the field is not defined on the model then we check the serializer # and if no value is there we skip over the field completely serializer_method = getattr(serializer, source, None) - if serializer_method and hasattr(serializer_method, '__call__'): + if serializer_method and hasattr(serializer_method, "__call__"): relation_instance = serializer_method(resource_instance) else: return False, None @@ -315,12 +326,12 @@ def format_drf_errors(response, context, exc): # handle generic errors. ValidationError('test') in a view for example if isinstance(response.data, list): for message in response.data: - errors.extend(format_error_object(message, '/data', response)) + errors.extend(format_error_object(message, "/data", response)) # handle all errors thrown from serializers else: for field, error in response.data.items(): field = format_value(field) - pointer = '/data/attributes/{}'.format(field) + pointer = "/data/attributes/{}".format(field) if isinstance(exc, Http404) and isinstance(error, str): # 404 errors don't have a pointer errors.extend(format_error_object(error, None, response)) @@ -328,14 +339,14 @@ def format_drf_errors(response, context, exc): classes = inspect.getmembers(exceptions, inspect.isclass) # DRF sets the `field` to 'detail' for its own exceptions if isinstance(exc, tuple(x[1] for x in classes)): - pointer = '/data' + pointer = "/data" errors.extend(format_error_object(error, pointer, response)) elif isinstance(error, list): errors.extend(format_error_object(error, pointer, response)) else: errors.extend(format_error_object(error, pointer, response)) - context['view'].resource_name = 'errors' + context["view"].resource_name = "errors" response.data = errors return response @@ -347,41 +358,46 @@ def format_error_object(message, pointer, response): # as there is no required field in error object we check that all fields are string # except links and source which might be a dict - is_custom_error = all([ - isinstance(value, str) - for key, value in message.items() if key not in ['links', 'source'] - ]) + is_custom_error = all( + [ + isinstance(value, str) + for key, value in message.items() + if key not in ["links", "source"] + ] + ) if is_custom_error: - if 'source' not in message: - message['source'] = {} - message['source'] = { - 'pointer': pointer, + if "source" not in message: + message["source"] = {} + message["source"] = { + "pointer": pointer, } errors.append(message) else: for k, v in message.items(): - errors.extend(format_error_object(v, pointer + '/{}'.format(k), response)) + errors.extend( + format_error_object(v, pointer + "/{}".format(k), response) + ) elif isinstance(message, list): for num, error in enumerate(message): if isinstance(error, (list, dict)): - new_pointer = pointer + '/{}'.format(num) + new_pointer = pointer + "/{}".format(num) else: new_pointer = pointer if error: errors.extend(format_error_object(error, new_pointer, response)) else: error_obj = { - 'detail': message, - 'status': encoding.force_str(response.status_code), + "detail": message, + "status": encoding.force_str(response.status_code), } if pointer is not None: - error_obj['source'] = { - 'pointer': pointer, + error_obj["source"] = { + "pointer": pointer, } code = getattr(message, "code", None) if code is not None: - error_obj['code'] = code + error_obj["code"] = code errors.append(error_obj) return errors @@ -389,5 +405,5 @@ def format_error_object(message, pointer, response): 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} + data.sort(key=lambda x: x.get("source", {}).get("pointer", "")) + return {"errors": data} diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index 7c874e7a..7a558cbf 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -6,7 +6,7 @@ ForwardManyToOneDescriptor, ManyToManyDescriptor, ReverseManyToOneDescriptor, - ReverseOneToOneDescriptor + ReverseOneToOneDescriptor, ) from django.db.models.manager import Manager from django.db.models.query import QuerySet @@ -26,7 +26,7 @@ Hyperlink, OrderedDict, get_included_resources, - get_resource_type_from_instance + get_resource_type_from_instance, ) @@ -54,16 +54,16 @@ class MyViewSet(viewsets.ModelViewSet): """ def get_select_related(self, include): - return getattr(self, 'select_for_includes', {}).get(include, None) + return getattr(self, "select_for_includes", {}).get(include, None) def get_prefetch_related(self, include): - return getattr(self, 'prefetch_for_includes', {}).get(include, None) + return getattr(self, "prefetch_for_includes", {}).get(include, None) def get_queryset(self, *args, **kwargs): qs = super(PreloadIncludesMixin, self).get_queryset(*args, **kwargs) included_resources = get_included_resources(self.request) - for included in included_resources + ['__all__']: + for included in included_resources + ["__all__"]: select_related = self.get_select_related(included) if select_related is not None: @@ -83,10 +83,10 @@ def get_queryset(self, *args, **kwargs): included_resources = get_included_resources(self.request) - for included in included_resources + ['__all__']: + for included in included_resources + ["__all__"]: # If include was not defined, trying to resolve it automatically included_model = None - levels = included.split('.') + levels = included.split(".") level_model = qs.model for level in levels: if not hasattr(level_model, level): @@ -94,11 +94,11 @@ def get_queryset(self, *args, **kwargs): field = getattr(level_model, level) field_class = field.__class__ - is_forward_relation = ( - issubclass(field_class, (ForwardManyToOneDescriptor, ManyToManyDescriptor)) + is_forward_relation = issubclass( + field_class, (ForwardManyToOneDescriptor, ManyToManyDescriptor) ) - is_reverse_relation = ( - issubclass(field_class, (ReverseManyToOneDescriptor, ReverseOneToOneDescriptor)) + is_reverse_relation = issubclass( + field_class, (ReverseManyToOneDescriptor, ReverseOneToOneDescriptor) ) if not (is_forward_relation or is_reverse_relation): break @@ -118,7 +118,7 @@ def get_queryset(self, *args, **kwargs): level_model = model_field.model if included_model is not None: - qs = qs.prefetch_related(included.replace('.', '__')) + qs = qs.prefetch_related(included.replace(".", "__")) return qs @@ -132,7 +132,7 @@ def retrieve_related(self, request, *args, **kwargs): serializer_kwargs = {} instance = self.get_related_instance() - if hasattr(instance, 'all'): + if hasattr(instance, "all"): instance = instance.all() if callable(instance): @@ -142,36 +142,41 @@ def retrieve_related(self, request, *args, **kwargs): return Response(data=None) if isinstance(instance, Iterable): - serializer_kwargs['many'] = True + serializer_kwargs["many"] = True serializer = self.get_related_serializer(instance, **serializer_kwargs) return Response(serializer.data) def get_related_serializer(self, instance, **kwargs): serializer_class = self.get_related_serializer_class() - kwargs.setdefault('context', self.get_serializer_context()) + kwargs.setdefault("context", self.get_serializer_context()) return serializer_class(instance, **kwargs) def get_related_serializer_class(self): parent_serializer_class = super(RelatedMixin, self).get_serializer_class() - if 'related_field' in self.kwargs: - field_name = self.kwargs['related_field'] + if "related_field" in self.kwargs: + field_name = self.kwargs["related_field"] # Try get the class from related_serializers - if hasattr(parent_serializer_class, 'related_serializers'): - _class = parent_serializer_class.related_serializers.get(field_name, None) + if hasattr(parent_serializer_class, "related_serializers"): + _class = parent_serializer_class.related_serializers.get( + field_name, None + ) if _class is None: raise NotFound - elif hasattr(parent_serializer_class, 'included_serializers'): - _class = parent_serializer_class.included_serializers.get(field_name, None) + elif hasattr(parent_serializer_class, "included_serializers"): + _class = parent_serializer_class.included_serializers.get( + field_name, None + ) if _class is None: raise NotFound else: - assert False, \ - 'Either "included_serializers" or "related_serializers" should be configured' + assert ( + False + ), 'Either "included_serializers" or "related_serializers" should be configured' if not isinstance(_class, type): return import_class_from_dotted_path(_class) @@ -180,7 +185,7 @@ def get_related_serializer_class(self): return parent_serializer_class def get_related_field_name(self): - return self.kwargs['related_field'] + return self.kwargs["related_field"] def get_related_instance(self): parent_obj = self.get_object() @@ -206,17 +211,16 @@ def get_related_instance(self): raise NotFound -class ModelViewSet(AutoPrefetchMixin, - PreloadIncludesMixin, - RelatedMixin, - viewsets.ModelViewSet): - http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options'] +class ModelViewSet( + AutoPrefetchMixin, PreloadIncludesMixin, RelatedMixin, viewsets.ModelViewSet +): + http_method_names = ["get", "post", "patch", "delete", "head", "options"] -class ReadOnlyModelViewSet(AutoPrefetchMixin, - RelatedMixin, - viewsets.ReadOnlyModelViewSet): - http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options'] +class ReadOnlyModelViewSet( + AutoPrefetchMixin, RelatedMixin, viewsets.ReadOnlyModelViewSet +): + http_method_names = ["get", "post", "patch", "delete", "head", "options"] class RelationshipView(generics.GenericAPIView): @@ -224,10 +228,10 @@ class RelationshipView(generics.GenericAPIView): self_link_view_name = None related_link_view_name = None field_name_mapping = {} - http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options'] + http_method_names = ["get", "post", "patch", "delete", "head", "options"] def get_serializer_class(self): - if getattr(self, 'action', False) is None: + if getattr(self, "action", False) is None: return Serializer return self.serializer_class @@ -255,10 +259,10 @@ def get_url(self, name, view_name, kwargs, request): url = self.reverse(view_name, kwargs=kwargs, request=request) except NoReverseMatch: msg = ( - 'Could not resolve URL for hyperlinked relationship using ' + "Could not resolve URL for hyperlinked relationship using " 'view name "%s". You may have failed to include the related ' - 'model in your API, or incorrectly configured the ' - '`lookup_field` attribute on this field.' + "model in your API, or incorrectly configured the " + "`lookup_field` attribute on this field." ) raise ImproperlyConfigured(msg % view_name) @@ -269,15 +273,17 @@ def get_url(self, name, view_name, kwargs, request): def get_links(self): return_data = OrderedDict() - self_link = self.get_url('self', self.self_link_view_name, self.kwargs, self.request) + self_link = self.get_url( + "self", self.self_link_view_name, self.kwargs, self.request + ) related_kwargs = {self.lookup_field: self.kwargs.get(self.lookup_field)} related_link = self.get_url( - 'related', self.related_link_view_name, related_kwargs, self.request + "related", self.related_link_view_name, related_kwargs, self.request ) if self_link: - return_data.update({'self': self_link}) + return_data.update({"self": self_link}) if related_link: - return_data.update({'related': related_link}) + return_data.update({"related": related_link}) return return_data def get(self, request, *args, **kwargs): @@ -292,7 +298,7 @@ def remove_relationships(self, instance_manager, field): for obj in instance_manager.all(): setattr(obj, field_object.name, None) obj.save() - elif hasattr(instance_manager, 'clear'): + elif hasattr(instance_manager, "clear"): instance_manager.clear() else: instance_manager.all().delete() @@ -313,25 +319,33 @@ def patch(self, request, *args, **kwargs): # for to one if hasattr(related_instance_or_manager, "field"): related_instance_or_manager = self.remove_relationships( - instance_manager=related_instance_or_manager, field="field") + instance_manager=related_instance_or_manager, field="field" + ) # for to many else: related_instance_or_manager = self.remove_relationships( - instance_manager=related_instance_or_manager, field="target_field") + instance_manager=related_instance_or_manager, field="target_field" + ) # have to set bulk to False since data isn't saved yet class_name = related_instance_or_manager.__class__.__name__ - if class_name != 'ManyRelatedManager': + if class_name != "ManyRelatedManager": related_instance_or_manager.add(*serializer.validated_data, bulk=False) else: related_instance_or_manager.add(*serializer.validated_data) else: related_model_class = related_instance_or_manager.__class__ - serializer = self.get_serializer(data=request.data, model_class=related_model_class) + serializer = self.get_serializer( + data=request.data, model_class=related_model_class + ) serializer.is_valid(raise_exception=True) - setattr(parent_obj, self.get_related_field_name(), serializer.validated_data) + setattr( + parent_obj, self.get_related_field_name(), serializer.validated_data + ) parent_obj.save() - related_instance_or_manager = self.get_related_instance() # Refresh instance + related_instance_or_manager = ( + self.get_related_instance() + ) # Refresh instance result_serializer = self._instantiate_serializer(related_instance_or_manager) return Response(result_serializer.data) @@ -344,11 +358,13 @@ def post(self, request, *args, **kwargs): data=request.data, model_class=related_model_class, many=True ) serializer.is_valid(raise_exception=True) - if frozenset(serializer.validated_data) <= frozenset(related_instance_or_manager.all()): + if frozenset(serializer.validated_data) <= frozenset( + related_instance_or_manager.all() + ): return Response(status=204) related_instance_or_manager.add(*serializer.validated_data) else: - raise MethodNotAllowed('POST') + raise MethodNotAllowed("POST") result_serializer = self._instantiate_serializer(related_instance_or_manager) return Response(result_serializer.data) @@ -368,11 +384,11 @@ def delete(self, request, *args, **kwargs): related_instance_or_manager.remove(*serializer.validated_data) except AttributeError: raise Conflict( - 'This object cannot be removed from this relationship without being ' - 'added to another' + "This object cannot be removed from this relationship without being " + "added to another" ) else: - raise MethodNotAllowed('DELETE') + raise MethodNotAllowed("DELETE") result_serializer = self._instantiate_serializer(related_instance_or_manager) return Response(result_serializer.data) @@ -383,7 +399,7 @@ def get_related_instance(self): raise NotFound def get_related_field_name(self): - field_name = self.kwargs['related_field'] + field_name = self.kwargs["related_field"] if field_name in self.field_name_mapping: return self.field_name_mapping[field_name] return field_name @@ -398,7 +414,7 @@ def _instantiate_serializer(self, instance): return self.get_serializer(instance=instance, many=True) def get_resource_name(self): - if not hasattr(self, '_resource_name'): + if not hasattr(self, "_resource_name"): instance = getattr(self.get_object(), self.get_related_field_name()) self._resource_name = get_resource_type_from_instance(instance) return self._resource_name diff --git a/setup.py b/setup.py index 3bf1c728..a076a7a5 100755 --- a/setup.py +++ b/setup.py @@ -7,15 +7,15 @@ from setuptools import setup -needs_wheel = {'bdist_wheel'}.intersection(sys.argv) -wheel = ['wheel'] if needs_wheel else [] +needs_wheel = {"bdist_wheel"}.intersection(sys.argv) +wheel = ["wheel"] if needs_wheel else [] def read(*paths): """ Build a file path from paths and return the contents. """ - with open(os.path.join(*paths), 'r') as f: + with open(os.path.join(*paths), "r") as f: return f.read() @@ -23,7 +23,7 @@ def get_version(package): """ Return package version as listed in `__version__` in `init.py`. """ - init_py = open(os.path.join(package, '__init__.py')).read() + init_py = open(os.path.join(package, "__init__.py")).read() return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) @@ -31,9 +31,11 @@ def get_packages(package): """ Return root package and all sub-packages. """ - return [dirpath - for dirpath, dirnames, filenames in os.walk(package) - if os.path.exists(os.path.join(dirpath, '__init__.py'))] + return [ + dirpath + for dirpath, dirnames, filenames in os.walk(package) + if os.path.exists(os.path.join(dirpath, "__init__.py")) + ] def get_package_data(package): @@ -41,63 +43,67 @@ def get_package_data(package): Return all files under the root package, that are not in a package themselves. """ - walk = [(dirpath.replace(package + os.sep, '', 1), filenames) - for dirpath, dirnames, filenames in os.walk(package) - if not os.path.exists(os.path.join(dirpath, '__init__.py'))] + walk = [ + (dirpath.replace(package + os.sep, "", 1), filenames) + for dirpath, dirnames, filenames in os.walk(package) + if not os.path.exists(os.path.join(dirpath, "__init__.py")) + ] filepaths = [] for base, filenames in walk: - filepaths.extend([os.path.join(base, filename) - for filename in filenames]) + filepaths.extend([os.path.join(base, filename) for filename in filenames]) return {package: filepaths} -if sys.argv[-1] == 'publish': +if sys.argv[-1] == "publish": os.system("python setup.py sdist upload") os.system("python setup.py bdist_wheel upload") print("You probably want to also tag the version now:") - print(" git tag -a {0} -m 'version {0}'".format( - get_version('rest_framework_json_api'))) + print( + " git tag -a {0} -m 'version {0}'".format( + get_version("rest_framework_json_api") + ) + ) print(" git push --tags") sys.exit() setup( - name='djangorestframework-jsonapi', - version=get_version('rest_framework_json_api'), - url='https://github.com/django-json-api/django-rest-framework-json-api', - license='BSD', - description='A Django REST framework API adapter for the JSON API spec.', - long_description=read('README.rst'), - author='Jerel Unruh', - author_email='', - packages=get_packages('rest_framework_json_api'), - package_data=get_package_data('rest_framework_json_api'), + name="djangorestframework-jsonapi", + version=get_version("rest_framework_json_api"), + url="https://github.com/django-json-api/django-rest-framework-json-api", + license="BSD", + description="A Django REST framework API adapter for the JSON API spec.", + long_description=read("README.rst"), + author="Jerel Unruh", + author_email="", + packages=get_packages("rest_framework_json_api"), + package_data=get_package_data("rest_framework_json_api"), classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Environment :: Web Environment', - 'Framework :: Django', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: BSD License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Topic :: Internet :: WWW/HTTP', - 'Topic :: Software Development :: Libraries :: Application Frameworks', - 'Topic :: Software Development :: Libraries :: Python Modules', + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Framework :: Django", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Software Development :: Libraries :: Application Frameworks", + "Topic :: Software Development :: Libraries :: Python Modules", ], install_requires=[ - 'inflection>=0.3.0', - 'djangorestframework>=3.12,<3.13', - 'django>=2.2,<3.2', + "inflection>=0.3.0", + "djangorestframework>=3.12,<3.13", + "django>=2.2,<3.2", ], extras_require={ - 'django-polymorphic': ['django-polymorphic>=2.0'], - 'django-filter': ['django-filter>=2.0'], - 'openapi': ['pyyaml>=5.3', 'uritemplate>=3.0.1'] + "django-polymorphic": ["django-polymorphic>=2.0"], + "django-filter": ["django-filter>=2.0"], + "openapi": ["pyyaml>=5.3", "uritemplate>=3.0.1"], }, setup_requires=wheel, python_requires=">=3.6", diff --git a/tests/models.py b/tests/models.py index e91911ce..3c3e6146 100644 --- a/tests/models.py +++ b/tests/models.py @@ -7,7 +7,7 @@ class DJAModel(models.Model): """ class Meta: - app_label = 'tests' + app_label = "tests" abstract = True @@ -23,7 +23,7 @@ class ManyToManyTarget(DJAModel): class ManyToManySource(DJAModel): name = models.CharField(max_length=100) - targets = models.ManyToManyField(ManyToManyTarget, related_name='sources') + targets = models.ManyToManyField(ManyToManyTarget, related_name="sources") # ForeignKey @@ -33,5 +33,6 @@ class ForeignKeyTarget(DJAModel): class ForeignKeySource(DJAModel): name = models.CharField(max_length=100) - target = models.ForeignKey(ForeignKeyTarget, related_name='sources', - on_delete=models.CASCADE) + target = models.ForeignKey( + ForeignKeyTarget, related_name="sources", on_delete=models.CASCADE + ) diff --git a/tests/test_utils.py b/tests/test_utils.py index 8f272e1a..657f8160 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -12,7 +12,7 @@ format_value, get_included_serializers, get_related_resource_type, - get_resource_name + get_resource_name, ) from tests.models import ( BasicModel, @@ -20,7 +20,7 @@ ForeignKeySource, ForeignKeyTarget, ManyToManySource, - ManyToManyTarget + ManyToManyTarget, ) @@ -28,64 +28,79 @@ def test_get_resource_name_no_view(): assert get_resource_name({}) is None -@pytest.mark.parametrize("format_type,pluralize_type,output", [ - (False, False, 'APIView'), - (False, True, 'APIViews'), - ('dasherize', False, 'api-view'), - ('dasherize', True, 'api-views'), -]) +@pytest.mark.parametrize( + "format_type,pluralize_type,output", + [ + (False, False, "APIView"), + (False, True, "APIViews"), + ("dasherize", False, "api-view"), + ("dasherize", True, "api-views"), + ], +) def test_get_resource_name_from_view(settings, format_type, pluralize_type, output): settings.JSON_API_FORMAT_TYPES = format_type settings.JSON_API_PLURALIZE_TYPES = pluralize_type view = APIView() - context = {'view': view} + context = {"view": view} assert output == get_resource_name(context) -@pytest.mark.parametrize("format_type,pluralize_type", [ - (False, False), - (False, True), - ('dasherize', False), - ('dasherize', True), -]) -def test_get_resource_name_from_view_custom_resource_name(settings, format_type, pluralize_type): +@pytest.mark.parametrize( + "format_type,pluralize_type", + [ + (False, False), + (False, True), + ("dasherize", False), + ("dasherize", True), + ], +) +def test_get_resource_name_from_view_custom_resource_name( + settings, format_type, pluralize_type +): settings.JSON_API_FORMAT_TYPES = format_type settings.JSON_API_PLURALIZE_TYPES = pluralize_type view = APIView() - view.resource_name = 'custom' - context = {'view': view} - assert 'custom' == get_resource_name(context) - - -@pytest.mark.parametrize("format_type,pluralize_type,output", [ - (False, False, 'BasicModel'), - (False, True, 'BasicModels'), - ('dasherize', False, 'basic-model'), - ('dasherize', True, 'basic-models'), -]) + view.resource_name = "custom" + context = {"view": view} + assert "custom" == get_resource_name(context) + + +@pytest.mark.parametrize( + "format_type,pluralize_type,output", + [ + (False, False, "BasicModel"), + (False, True, "BasicModels"), + ("dasherize", False, "basic-model"), + ("dasherize", True, "basic-models"), + ], +) def test_get_resource_name_from_model(settings, format_type, pluralize_type, output): settings.JSON_API_FORMAT_TYPES = format_type settings.JSON_API_PLURALIZE_TYPES = pluralize_type view = APIView() view.model = BasicModel - context = {'view': view} + context = {"view": view} assert output == get_resource_name(context) -@pytest.mark.parametrize("format_type,pluralize_type,output", [ - (False, False, 'BasicModel'), - (False, True, 'BasicModels'), - ('dasherize', False, 'basic-model'), - ('dasherize', True, 'basic-models'), -]) -def test_get_resource_name_from_model_serializer_class(settings, format_type, - pluralize_type, output): +@pytest.mark.parametrize( + "format_type,pluralize_type,output", + [ + (False, False, "BasicModel"), + (False, True, "BasicModels"), + ("dasherize", False, "basic-model"), + ("dasherize", True, "basic-models"), + ], +) +def test_get_resource_name_from_model_serializer_class( + settings, format_type, pluralize_type, output +): class BasicModelSerializer(serializers.ModelSerializer): class Meta: - fields = ('text',) + fields = ("text",) model = BasicModel settings.JSON_API_FORMAT_TYPES = format_type @@ -93,22 +108,25 @@ class Meta: view = GenericAPIView() view.serializer_class = BasicModelSerializer - context = {'view': view} + context = {"view": view} assert output == get_resource_name(context) -@pytest.mark.parametrize("format_type,pluralize_type", [ - (False, False), - (False, True), - ('dasherize', False), - ('dasherize', True), -]) -def test_get_resource_name_from_model_serializer_class_custom_resource_name(settings, - format_type, - pluralize_type): +@pytest.mark.parametrize( + "format_type,pluralize_type", + [ + (False, False), + (False, True), + ("dasherize", False), + ("dasherize", True), + ], +) +def test_get_resource_name_from_model_serializer_class_custom_resource_name( + settings, format_type, pluralize_type +): class BasicModelSerializer(serializers.ModelSerializer): class Meta: - fields = ('text',) + fields = ("text",) model = BasicModel settings.JSON_API_FORMAT_TYPES = format_type @@ -116,22 +134,27 @@ class Meta: view = GenericAPIView() view.serializer_class = BasicModelSerializer - view.serializer_class.Meta.resource_name = 'custom' + view.serializer_class.Meta.resource_name = "custom" - context = {'view': view} - assert 'custom' == get_resource_name(context) + context = {"view": view} + assert "custom" == get_resource_name(context) -@pytest.mark.parametrize("format_type,pluralize_type", [ - (False, False), - (False, True), - ('dasherize', False), - ('dasherize', True), -]) -def test_get_resource_name_from_plain_serializer_class(settings, format_type, pluralize_type): +@pytest.mark.parametrize( + "format_type,pluralize_type", + [ + (False, False), + (False, True), + ("dasherize", False), + ("dasherize", True), + ], +) +def test_get_resource_name_from_plain_serializer_class( + settings, format_type, pluralize_type +): class PlainSerializer(serializers.Serializer): class Meta: - resource_name = 'custom' + resource_name = "custom" settings.JSON_API_FORMAT_TYPES = format_type settings.JSON_API_PLURALIZE_TYPES = pluralize_type @@ -139,61 +162,76 @@ class Meta: view = GenericAPIView() view.serializer_class = PlainSerializer - context = {'view': view} - assert 'custom' == get_resource_name(context) + context = {"view": view} + assert "custom" == get_resource_name(context) -@pytest.mark.parametrize("status_code", [ - status.HTTP_400_BAD_REQUEST, - status.HTTP_403_FORBIDDEN, - status.HTTP_500_INTERNAL_SERVER_ERROR -]) +@pytest.mark.parametrize( + "status_code", + [ + status.HTTP_400_BAD_REQUEST, + status.HTTP_403_FORBIDDEN, + status.HTTP_500_INTERNAL_SERVER_ERROR, + ], +) def test_get_resource_name_with_errors(status_code): view = APIView() - context = {'view': view} + context = {"view": view} view.response = Response(status=status_code) - assert 'errors' == get_resource_name(context) + assert "errors" == get_resource_name(context) -@pytest.mark.parametrize("format_type,output", [ - ('camelize', {'fullName': {'last-name': 'a', 'first-name': 'b'}}), - ('capitalize', {'FullName': {'last-name': 'a', 'first-name': 'b'}}), - ('dasherize', {'full-name': {'last-name': 'a', 'first-name': 'b'}}), - ('underscore', {'full_name': {'last-name': 'a', 'first-name': 'b'}}), -]) +@pytest.mark.parametrize( + "format_type,output", + [ + ("camelize", {"fullName": {"last-name": "a", "first-name": "b"}}), + ("capitalize", {"FullName": {"last-name": "a", "first-name": "b"}}), + ("dasherize", {"full-name": {"last-name": "a", "first-name": "b"}}), + ("underscore", {"full_name": {"last-name": "a", "first-name": "b"}}), + ], +) def test_format_field_names(settings, format_type, output): settings.JSON_API_FORMAT_FIELD_NAMES = format_type - value = {'full_name': {'last-name': 'a', 'first-name': 'b'}} + value = {"full_name": {"last-name": "a", "first-name": "b"}} assert format_field_names(value, format_type) == output -@pytest.mark.parametrize("format_type,output", [ - (None, 'first_name'), - ('camelize', 'firstName'), - ('capitalize', 'FirstName'), - ('dasherize', 'first-name'), - ('underscore', 'first_name') -]) +@pytest.mark.parametrize( + "format_type,output", + [ + (None, "first_name"), + ("camelize", "firstName"), + ("capitalize", "FirstName"), + ("dasherize", "first-name"), + ("underscore", "first_name"), + ], +) def test_format_value(settings, format_type, output): - assert format_value('first_name', format_type) == output + assert format_value("first_name", format_type) == output -@pytest.mark.parametrize("resource_type,pluralize,output", [ - (None, None, 'ResourceType'), - ('camelize', False, 'resourceType'), - ('camelize', True, 'resourceTypes'), -]) +@pytest.mark.parametrize( + "resource_type,pluralize,output", + [ + (None, None, "ResourceType"), + ("camelize", False, "resourceType"), + ("camelize", True, "resourceTypes"), + ], +) def test_format_resource_type(settings, resource_type, pluralize, output): - assert format_resource_type('ResourceType', resource_type, pluralize) == output + assert format_resource_type("ResourceType", resource_type, pluralize) == output -@pytest.mark.parametrize('model_class,field,output', [ - (ManyToManySource, 'targets', 'ManyToManyTarget'), - (ManyToManyTarget, 'sources', 'ManyToManySource'), - (ForeignKeySource, 'target', 'ForeignKeyTarget'), - (ForeignKeyTarget, 'sources', 'ForeignKeySource'), -]) +@pytest.mark.parametrize( + "model_class,field,output", + [ + (ManyToManySource, "targets", "ManyToManyTarget"), + (ManyToManyTarget, "sources", "ManyToManySource"), + (ForeignKeySource, "target", "ForeignKeyTarget"), + (ForeignKeyTarget, "sources", "ForeignKeySource"), + ], +) def test_get_related_resource_type(model_class, field, output): class RelatedResourceTypeSerializer(serializers.ModelSerializer): class Meta: @@ -212,29 +250,29 @@ class Meta: def test_get_included_serializers(): class IncludedSerializersModel(DJAModel): - self = models.ForeignKey('self', on_delete=models.CASCADE) + self = models.ForeignKey("self", on_delete=models.CASCADE) target = models.ForeignKey(ManyToManyTarget, on_delete=models.CASCADE) other_target = models.ForeignKey(ManyToManyTarget, on_delete=models.CASCADE) class Meta: - app_label = 'tests' + app_label = "tests" class IncludedSerializersSerializer(serializers.ModelSerializer): included_serializers = { - 'self': 'self', - 'target': ManyToManyTargetSerializer, - 'other_target': 'tests.test_utils.ManyToManyTargetSerializer' + "self": "self", + "target": ManyToManyTargetSerializer, + "other_target": "tests.test_utils.ManyToManyTargetSerializer", } class Meta: model = IncludedSerializersModel - fields = ('self', 'other_target', 'target') + fields = ("self", "other_target", "target") included_serializers = get_included_serializers(IncludedSerializersSerializer) expected_included_serializers = { - 'self': IncludedSerializersSerializer, - 'target': ManyToManyTargetSerializer, - 'other_target': ManyToManyTargetSerializer + "self": IncludedSerializersSerializer, + "target": ManyToManyTargetSerializer, + "other_target": ManyToManyTargetSerializer, } assert included_serializers == expected_included_serializers From c356806d9f8ad15669a049b6b4bb041a1c9ea482 Mon Sep 17 00:00:00 2001 From: Antonio Matinata Date: Thu, 3 Dec 2020 19:14:50 +0100 Subject: [PATCH 034/252] Replace dead filter links in documentation (#867) * Updated link to DRF OrderingFilter * Updated link to DRF SearchFilter Co-authored-by: mtnntn --- docs/usage.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index afc8a7ac..415c492b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -135,7 +135,7 @@ simply don't use this filter backend. #### OrderingFilter `OrderingFilter` 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). +DRF's [ordering filter](https://www.django-rest-framework.org/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 @@ -205,7 +205,7 @@ As this feature depends on `django-filter` you need to run #### SearchFilter To comply with JSON:API query parameter naming standards, DRF's -[SearchFilter](https://django-rest-framework.readthedocs.io/en/latest/api-guide/filtering/#searchfilter) should +[SearchFilter](https://www.django-rest-framework.org/api-guide/filtering/#searchfilter) should be configured to use a `filter[_something_]` query parameter. This can be done by default by adding the SearchFilter to `REST_FRAMEWORK['DEFAULT_FILTER_BACKENDS']` and setting `REST_FRAMEWORK['SEARCH_PARAM']` or adding the `.search_param` attribute to a custom class derived from `SearchFilter`. If you do this and also From f26c6eceaa6104a95d2230d1906a236d6127c9b4 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sun, 22 Nov 2020 21:48:48 +0100 Subject: [PATCH 035/252] Add pre-commit configuration --- .pre-commit-config.yaml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..49f72fe2 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +repos: + - repo: local + hooks: + - id: black + stages: [commit] + name: black + language: system + entry: black + types: [python] + - id: isort + stages: [commit] + name: isort + language: system + entry: isort + types: [python] + - id: flake8 + stages: [commit] + name: flake8 + language: system + entry: flake8 + types: [python] From acaec9508d3dd2f034d4e4dc9fd4fdde7592f08d Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sun, 22 Nov 2020 21:49:33 +0100 Subject: [PATCH 036/252] Incooperate pre-commit usage into CONTRIBUTING.md --- README.rst | 12 -------- docs/CONTRIBUTING.md | 65 ++++++++++++++++++++++++++++++-------------- 2 files changed, 44 insertions(+), 33 deletions(-) diff --git a/README.rst b/README.rst index 7ffbcc0e..3501e8a1 100644 --- a/README.rst +++ b/README.rst @@ -142,18 +142,6 @@ Browse to * http://localhost:8000/openapi for the schema view's OpenAPI specification document. -Running Tests and linting -^^^^^^^^^^^^^^^^^^^^^^^^^ - -It is recommended to create a virtualenv for testing. Assuming it is already -installed and activated: - -:: - - $ pip install -Ur requirements.txt - $ flake8 - $ pytest - ----- Usage ----- diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 5ab82991..5e09ef32 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,30 +1,53 @@ -Contributing -============ +# Contributing -DJA should be easy to contribute to. +Django REST Framework JSON API (aka DJA) should be easy to contribute to. If anything is unclear about how to contribute, please submit an issue on GitHub so that we can fix it! -How ---- +Before writing any code, have a conversation on a GitHub issue to see +if the proposed change makes sense for the project. -Before writing any code, -have a conversation on a GitHub issue -to see if the proposed change makes sense -for the project. +## Setup development environment -Fork DJA on [GitHub](https://github.com/django-json-api/django-rest-framework-json-api) and -[submit a Pull Request](https://help.github.com/articles/creating-a-pull-request/) -when you're ready. +### Clone -For maintainers ---------------- +To start developing on Django REST Framework JSON API you need to first clone the repository: -To upload a release (using version 1.2.3 as the example): + git clone https://github.com/django-json-api/django-rest-framework-json-api.git -```bash -(venv)$ python setup.py sdist bdist_wheel -(venv)$ twine upload dist/* -(venv)$ git tag -a v1.2.3 -m 'Release 1.2.3' -(venv)$ git push --tags -``` +### Testing + +To run tests clone the repository, and then: + + # Setup the virtual environment + python3 -m venv env + source env/bin/activate + pip install -r requirements.txt + + # Format code + black . + + # Run linting + flake8 + + # Run tests + pytest + +### Setup pre-commit + +pre-commit hooks is an additional option to check linting and formatting of code independent of +an editor before you commit your changes with git. + +To setup pre-commit hooks first create a testing environment as explained above before running below commands: + + pip install pre-commit + pre-commit install + +## For maintainers + +To upload a release (using version 1.2.3 as the example) first setup testing environment as above before running below commands: + + python setup.py sdist bdist_wheel + twine upload dist/* + git tag -a v1.2.3 -m 'Release 1.2.3' + git push --tags From 80fa5f4c54179c942e499b07115493623a5d6c08 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 3 Dec 2020 22:22:03 +0400 Subject: [PATCH 037/252] Document how to use tox --- docs/CONTRIBUTING.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 5e09ef32..c3f7d0f1 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -33,6 +33,13 @@ To run tests clone the repository, and then: # Run tests pytest +### Running against multiple environments + +You can also use the excellent [tox](https://tox.readthedocs.io/en/latest/) testing tool to run the tests against all supported versions of Python and Django. Install `tox` globally, and then simply run: + + tox + + ### Setup pre-commit pre-commit hooks is an additional option to check linting and formatting of code independent of From 3263061a486e7b6bbfff066620a605383714fdf6 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 7 Dec 2020 20:51:50 +0200 Subject: [PATCH 038/252] Scheduled biweekly dependency update for week 49 (#869) * Update sphinx from 3.3.0 to 3.3.1 * Update django-debug-toolbar from 3.1.1 to 3.2 * Update faker from 4.14.0 to 5.0.0 --- requirements/requirements-documentation.txt | 2 +- requirements/requirements-testing.txt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index e8378b09..12dd5670 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.6.0 -Sphinx==3.3.0 +Sphinx==3.3.1 sphinx_rtd_theme==0.5.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 9508b0a3..66820c49 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,6 +1,6 @@ -django-debug-toolbar==3.1.1 +django-debug-toolbar==3.2 factory-boy==3.1.0 -Faker==4.14.0 +Faker==5.0.0 pytest==6.1.2 pytest-cov==2.10.1 pytest-django==4.1.0 From 3833271411c5199f782fc5c7842733bb44aadf9d Mon Sep 17 00:00:00 2001 From: Nathanael Gordon Date: Sat, 12 Dec 2020 04:44:15 +1100 Subject: [PATCH 039/252] Support many related fields on plain Serializers (#868) Support many related fields on plain Serializers Co-authored-by: Oliver Sauder --- CHANGELOG.md | 3 ++- rest_framework_json_api/utils.py | 5 +++++ tests/test_utils.py | 21 +++++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4613aa18..17a63357 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] - TBD +## [Unreleased] ### Added @@ -17,6 +17,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Fixed * Allow users to overwrite a view's `get_serializer_class()` method when using [related urls](https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#related-urls) +* Correctly resolve the resource type of `ResourceRelatedField(many=True)` fields on plain serializers ## [4.0.0] - 2020-10-31 diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 41683303..f7ddac75 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -215,6 +215,11 @@ def get_related_resource_type(relation): return get_related_resource_type(parent_model_relation) if relation_model is None: + # For ManyRelatedFields on plain Serializers the resource_type + # cannot be determined from a model, so we must get it from the + # child_relation + if hasattr(relation, "child_relation"): + return get_related_resource_type(relation.child_relation) raise APIException( _("Could not resolve resource type for relation %s" % relation) ) diff --git a/tests/test_utils.py b/tests/test_utils.py index 657f8160..a0e4773a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -243,6 +243,27 @@ class Meta: assert get_related_resource_type(field) == output +@pytest.mark.parametrize( + "related_field_kwargs,output", + [ + ({"queryset": BasicModel.objects}, "BasicModel"), + ({"queryset": BasicModel.objects, "model": BasicModel}, "BasicModel"), + ({"model": BasicModel, "read_only": True}, "BasicModel"), + ], +) +def test_get_related_resource_type_from_plain_serializer_class( + related_field_kwargs, output +): + class PlainRelatedResourceTypeSerializer(serializers.Serializer): + basic_models = serializers.ResourceRelatedField( + many=True, **related_field_kwargs + ) + + serializer = PlainRelatedResourceTypeSerializer() + field = serializer.fields["basic_models"] + assert get_related_resource_type(field) == output + + class ManyToManyTargetSerializer(serializers.ModelSerializer): class Meta: model = ManyToManyTarget From a59c96f239a299d8ea0b9a9eaf03a95e892824f8 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 11 Dec 2020 22:22:03 +0400 Subject: [PATCH 040/252] Do not ignore F405 linting error Enforces proper imports --- example/settings/test.py | 2 +- rest_framework_json_api/serializers.py | 15 ++++++++++++++- setup.cfg | 5 +---- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/example/settings/test.py b/example/settings/test.py index 5fa040d6..2b92b5b3 100644 --- a/example/settings/test.py +++ b/example/settings/test.py @@ -13,7 +13,7 @@ JSON_API_FORMAT_TYPES = "camelize" JSON_API_PLURALIZE_TYPES = True -REST_FRAMEWORK.update( +REST_FRAMEWORK.update( # noqa: F405 { # noqa "PAGE_SIZE": 1, } diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 50b546b6..56949818 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -1,9 +1,22 @@ +from collections import OrderedDict + import inflection from django.core.exceptions import ObjectDoesNotExist from django.db.models.query import QuerySet from django.utils.translation import gettext_lazy as _ from rest_framework.exceptions import ParseError -from rest_framework.serializers import * # noqa: F403 + +# star import defined so `rest_framework_json_api.serializers` can be +# a simple drop in for `rest_framework.serializers` +from rest_framework.serializers import * # noqa: F401, F403 +from rest_framework.serializers import ( + BaseSerializer, + HyperlinkedModelSerializer, + ModelSerializer, + Serializer, + SerializerMetaclass, +) +from rest_framework.settings import api_settings from rest_framework_json_api.exceptions import Conflict from rest_framework_json_api.relations import ResourceRelatedField diff --git a/setup.cfg b/setup.cfg index 07b279c8..527ddd6b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -10,10 +10,7 @@ extend-ignore = # whitespace before ':' - disabled as not PEP8 compliant E203, # line too long (managed by black) - E501, - # usage of star imports - # TODO mark star imports directly in code to ignore this error - F405 + E501 exclude = build/lib, .eggs From 5a284a09b0c703f3caf9a37fa324d04ae4bbd00a Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sat, 12 Dec 2020 23:29:52 +0400 Subject: [PATCH 041/252] Add warning to docs on permission check with related urls --- docs/usage.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/usage.md b/docs/usage.md index 415c492b..0e724713 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -703,6 +703,14 @@ class OrderSerializer(serializers.HyperlinkedModelSerializer): } ``` +
+ Note: + Even though with related urls relations are served on different urls there are still served + by the same view. This means that the object permission check is performed on the parent object. + In other words when the parent object is accessible by the user the related object will be as well. +
+ + ### RelationshipView `rest_framework_json_api.views.RelationshipView` is used to build relationship views (see the From c503748c844a49e02a9b327c0c7abd3baf764cb0 Mon Sep 17 00:00:00 2001 From: Kevin Partington Date: Sun, 27 Dec 2020 12:48:22 -0600 Subject: [PATCH 042/252] Support formatting URL segments via new FORMAT_LINKS setting (#876) Fixes #790. --- AUTHORS | 1 + CHANGELOG.md | 1 + docs/usage.md | 38 ++++++++++++++ rest_framework_json_api/relations.py | 11 ++--- rest_framework_json_api/settings.py | 1 + rest_framework_json_api/utils.py | 13 +++++ rest_framework_json_api/views.py | 4 +- tests/test_relations.py | 74 ++++++++++++++++++++++++++++ tests/test_utils.py | 16 ++++++ tests/test_views.py | 49 ++++++++++++++++++ 10 files changed, 200 insertions(+), 8 deletions(-) create mode 100644 tests/test_relations.py create mode 100644 tests/test_views.py diff --git a/AUTHORS b/AUTHORS index d4c03b2c..11d8db8c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -15,6 +15,7 @@ Jason Housley Jerel Unruh Jonathan Senecal Joseba Mendivil +Kevin Partington Kieran Evans Léo S. Luc Cary diff --git a/CHANGELOG.md b/CHANGELOG.md index 17a63357..858fd8d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Added * Ability for the user to select `included_serializers` to apply when using `BrowsableAPI`, based on available `included_serializers` defined for the current endpoint. +* Ability for the user to format serializer properties in URL segments using the `JSON_API_FORMAT_LINKS` setting. ### Fixed diff --git a/docs/usage.md b/docs/usage.md index 415c492b..663d3b7f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -477,6 +477,44 @@ When set to pluralize: } ``` +#### Related URL segments + +Serializer properties in relationship and related resource URLs may be infected using the `JSON_API_FORMAT_LINKS` setting. + +``` python +JSON_API_FORMAT_LINKS = 'dasherize' +``` + +For example, with a serializer property `created_by` and with `'dasherize'` formatting: + +```json +{ + "data": { + "type": "comments", + "id": "1", + "attributes": { + "text": "Comments are fun!" + }, + "links": { + "self": "/comments/1" + }, + "relationships": { + "created_by": { + "links": { + "self": "/comments/1/relationships/created-by", + "related": "/comments/1/created-by" + } + } + } + }, + "links": { + "self": "/comments/1" + } +} +``` + +The relationship name is formatted by the `JSON_API_FORMAT_FIELD_NAMES` setting, but the URL segments are formatted by the `JSON_API_FORMAT_LINKS` setting. + ### Related fields #### ResourceRelatedField diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index 2924cd56..c9dd765d 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -15,6 +15,7 @@ from rest_framework_json_api.exceptions import Conflict from rest_framework_json_api.utils import ( Hyperlink, + format_link_segment, get_included_serializers, get_resource_type_from_instance, get_resource_type_from_queryset, @@ -112,14 +113,10 @@ def get_links(self, obj=None, lookup_field="pk"): else view.kwargs[lookup_field] } + field_name = self.field_name if self.field_name else self.parent.field_name + self_kwargs = kwargs.copy() - self_kwargs.update( - { - "related_field": self.field_name - if self.field_name - else self.parent.field_name - } - ) + self_kwargs.update({"related_field": format_link_segment(field_name)}) self_link = self.get_url("self", self.self_link_view_name, self_kwargs, request) # Assuming RelatedField will be declared in two ways: diff --git a/rest_framework_json_api/settings.py b/rest_framework_json_api/settings.py index 0384894c..e7cc3711 100644 --- a/rest_framework_json_api/settings.py +++ b/rest_framework_json_api/settings.py @@ -12,6 +12,7 @@ DEFAULTS = { "FORMAT_FIELD_NAMES": False, "FORMAT_TYPES": False, + "FORMAT_LINKS": False, "PLURALIZE_TYPES": False, "UNIFORM_EXCEPTIONS": False, } diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index f7ddac75..7b211207 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -148,6 +148,19 @@ def format_resource_type(value, format_type=None, pluralize=None): return inflection.pluralize(value) if pluralize else value +def format_link_segment(value, format_type=None): + """ + Takes a string value and returns it with formatted keys as set in `format_type` + or `JSON_API_FORMAT_LINKS`. + + :format_type: Either 'dasherize', 'camelize', 'capitalize' or 'underscore' + """ + if format_type is None: + format_type = json_api_settings.FORMAT_LINKS + + return format_value(value, format_type) + + def get_related_resource_type(relation): from rest_framework_json_api.serializers import PolymorphicModelSerializer diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index 7a558cbf..2f061cbb 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -25,6 +25,7 @@ from rest_framework_json_api.utils import ( Hyperlink, OrderedDict, + format_value, get_included_resources, get_resource_type_from_instance, ) @@ -185,7 +186,8 @@ def get_related_serializer_class(self): return parent_serializer_class def get_related_field_name(self): - return self.kwargs["related_field"] + field_name = self.kwargs["related_field"] + return format_value(field_name, "underscore") def get_related_instance(self): parent_obj = self.get_object() diff --git a/tests/test_relations.py b/tests/test_relations.py new file mode 100644 index 00000000..e9f3800b --- /dev/null +++ b/tests/test_relations.py @@ -0,0 +1,74 @@ +import pytest +from django.conf.urls import re_path +from rest_framework.routers import SimpleRouter + +from rest_framework_json_api.relations import HyperlinkedRelatedField +from rest_framework_json_api.views import ModelViewSet, RelationshipView + +from .models import BasicModel + + +@pytest.mark.urls(__name__) +@pytest.mark.parametrize( + "format_links,expected_url_segment", + [ + (None, "relatedField_name"), + ("dasherize", "related-field-name"), + ("camelize", "relatedFieldName"), + ("capitalize", "RelatedFieldName"), + ("underscore", "related_field_name"), + ], +) +def test_relationship_urls_respect_format_links( + settings, format_links, expected_url_segment +): + settings.JSON_API_FORMAT_LINKS = format_links + + model = BasicModel(text="Some text") + + field = HyperlinkedRelatedField( + self_link_view_name="basic-model-relationships", + related_link_view_name="basic-model-related", + read_only=True, + ) + field.field_name = "relatedField_name" + + expected = { + "self": f"/basic_models/{model.pk}/relationships/{expected_url_segment}/", + "related": f"/basic_models/{model.pk}/{expected_url_segment}/", + } + + actual = field.get_links(model) + + assert expected == actual + + +# Routing setup + + +class BasicModelViewSet(ModelViewSet): + class Meta: + model = BasicModel + + +class BasicModelRelationshipView(RelationshipView): + queryset = BasicModel.objects + + +router = SimpleRouter() +router.register(r"basic_models", BasicModelViewSet, basename="basic-model") + +urlpatterns = [ + re_path( + r"^basic_models/(?P[^/.]+)/(?P[^/.]+)/$", + BasicModelViewSet.as_view({"get": "retrieve_related"}), + name="basic-model-related", + ), + re_path( + r"^basic_models/(?P[^/.]+)/relationships/(?P[^/.]+)/$", + BasicModelRelationshipView.as_view(), + name="basic-model-relationships", + ), +] + +urlpatterns += router.urls diff --git a/tests/test_utils.py b/tests/test_utils.py index a0e4773a..449f515e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -8,6 +8,7 @@ from rest_framework_json_api import serializers from rest_framework_json_api.utils import ( format_field_names, + format_link_segment, format_resource_type, format_value, get_included_serializers, @@ -197,6 +198,21 @@ def test_format_field_names(settings, format_type, output): assert format_field_names(value, format_type) == output +@pytest.mark.parametrize( + "format_type,output", + [ + (None, "first_Name"), + ("camelize", "firstName"), + ("capitalize", "FirstName"), + ("dasherize", "first-name"), + ("underscore", "first_name"), + ], +) +def test_format_field_segment(settings, format_type, output): + settings.JSON_API_FORMAT_LINKS = format_type + assert format_link_segment("first_Name") == output + + @pytest.mark.parametrize( "format_type,output", [ diff --git a/tests/test_views.py b/tests/test_views.py new file mode 100644 index 00000000..8241a10e --- /dev/null +++ b/tests/test_views.py @@ -0,0 +1,49 @@ +import pytest + +from rest_framework_json_api import serializers, views +from rest_framework_json_api.relations import ResourceRelatedField +from rest_framework_json_api.utils import format_value + +from .models import BasicModel + +related_model_field_name = "related_field_model" + + +@pytest.mark.parametrize( + "format_links", + [ + None, + "dasherize", + "camelize", + "capitalize", + "underscore", + ], +) +def test_get_related_field_name_handles_formatted_link_segments(format_links, rf): + url_segment = format_value(related_model_field_name, format_links) + + request = rf.get(f"/basic_models/1/{url_segment}") + + view = BasicModelFakeViewSet() + view.setup(request, related_field=url_segment) + + assert view.get_related_field_name() == related_model_field_name + + +class BasicModelSerializer(serializers.ModelSerializer): + related_model_field = ResourceRelatedField(queryset=BasicModel.objects) + + def __init__(self, *args, **kwargs): + # Intentionally setting field_name property to something that matches no format + self.related_model_field.field_name = related_model_field_name + super(BasicModelSerializer, self).__init(*args, **kwargs) + + class Meta: + model = BasicModel + + +class BasicModelFakeViewSet(views.ModelViewSet): + serializer_class = BasicModelSerializer + + def retrieve(self, request, *args, **kwargs): + pass From 5019b72a3bf555f05f1155049be71ca4244688c8 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 28 Dec 2020 22:28:28 +0400 Subject: [PATCH 043/252] Use GitHub actions for CI instead of Travis --- .github/workflows/tests.yml | 50 +++++++++++++++++++++ .travis.yml | 88 ------------------------------------- README.rst | 5 ++- tox.ini | 17 +++++++ 4 files changed, 70 insertions(+), 90 deletions(-) create mode 100644 .github/workflows/tests.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..7e73beae --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,50 @@ +name: Tests +on: [push, pull_request] + +jobs: + test: + name: Run test + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.django-rest-framework == 'master' }} + strategy: + fail-fast: false + matrix: + python-version: ["3.6", "3.7", "3.8", "3.9"] + django: ["2.2", "3.0", "3.1"] + django-rest-framework: ["3.12", "master"] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions + - name: Test with tox + run: tox + env: + DJANGO: ${{ matrix.django }} + DJANGO_REST_FRAMEWORK: ${{ matrix.django-rest-framework }} + check: + name: Run check + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + tox-env: ["black", "lint", "docs"] + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.6 + uses: actions/setup-python@v2 + with: + python-version: 3.6 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox + - name: Run lint + run: tox + env: + TOXENV: ${{ matrix.tox-env }} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 65ea1cc8..00000000 --- a/.travis.yml +++ /dev/null @@ -1,88 +0,0 @@ -language: python -dist: xenial -sudo: required -cache: pip -# Favor explicit over implicit and use an explicit build matrix. -matrix: - allow_failures: - - env: TOXENV=py36-django22-drfmaster - - env: TOXENV=py37-django22-drfmaster - - env: TOXENV=py38-django22-drfmaster - - env: TOXENV=py39-django22-drfmaster - - env: TOXENV=py36-django30-drfmaster - - env: TOXENV=py37-django30-drfmaster - - env: TOXENV=py38-django30-drfmaster - - env: TOXENV=py39-django30-drfmaster - - env: TOXENV=py36-django31-drfmaster - - env: TOXENV=py37-django31-drfmaster - - env: TOXENV=py38-django31-drfmaster - - env: TOXENV=py39-django31-drfmaster - - include: - - python: 3.6 - env: TOXENV=black - - python: 3.6 - env: TOXENV=lint - - python: 3.6 - env: TOXENV=docs - - - python: 3.6 - env: TOXENV=py36-django22-drf312 - - python: 3.6 - env: TOXENV=py36-django22-drfmaster - - python: 3.6 - env: TOXENV=py36-django30-drf312 - - python: 3.6 - env: TOXENV=py36-django30-drfmaster - - python: 3.6 - env: TOXENV=py36-django31-drf312 - - python: 3.6 - env: TOXENV=py36-django31-drfmaster - - - python: 3.7 - env: TOXENV=py37-django22-drf312 - - python: 3.7 - env: TOXENV=py37-django22-drfmaster - - python: 3.7 - env: TOXENV=py37-django30-drf312 - - python: 3.7 - env: TOXENV=py37-django30-drfmaster - - python: 3.7 - env: TOXENV=py37-django31-drf312 - - python: 3.7 - env: TOXENV=py37-django31-drfmaster - - - python: 3.8 - env: TOXENV=py38-django22-drf312 - - python: 3.8 - env: TOXENV=py38-django22-drfmaster - - python: 3.8 - env: TOXENV=py38-django30-drf312 - - python: 3.8 - env: TOXENV=py38-django30-drfmaster - - python: 3.8 - env: TOXENV=py38-django31-drf312 - - python: 3.8 - env: TOXENV=py38-django31-drfmaster - - - python: 3.9 - env: TOXENV=py39-django22-drf312 - - python: 3.9 - env: TOXENV=py39-django22-drfmaster - - python: 3.9 - env: TOXENV=py39-django30-drf312 - - python: 3.9 - env: TOXENV=py39-django30-drfmaster - - python: 3.9 - env: TOXENV=py39-django31-drf312 - - python: 3.9 - env: TOXENV=py39-django31-drfmaster - - -install: - - pip install tox -script: - - tox -after_success: - - pip install codecov - - codecov -e TOXENV --required diff --git a/README.rst b/README.rst index 3501e8a1..86e20fd3 100644 --- a/README.rst +++ b/README.rst @@ -2,8 +2,9 @@ JSON API and Django Rest Framework ================================== -.. image:: https://travis-ci.com/django-json-api/django-rest-framework-json-api.svg?branch=master - :target: https://travis-ci.com/django-json-api/django-rest-framework-json-api +.. image:: hhttps://github.com/django-json-api/django-rest-framework-json-api/workflows/Tests/badge.svg + :alt: Tests + :target: https://github.com/django-json-api/django-rest-framework-json-api/actions .. image:: https://readthedocs.org/projects/django-rest-framework-json-api/badge/?version=latest :alt: Read the docs diff --git a/tox.ini b/tox.ini index dc808c01..dab1676c 100644 --- a/tox.ini +++ b/tox.ini @@ -3,6 +3,23 @@ envlist = py{36,37,38,39}-django{22,30,31}-drf{312,master}, lint,docs +[gh-actions] +python = + 3.6: py36 + 3.7: py37 + 3.8: py38 + 3.9: py39 + +[gh-actions:env] +DJANGO = + 2.2: django22 + 3.0: django30 + 3.1: django31 + +DJANGO_REST_FRAMEWORK = + 3.12: drf312 + master: drfmaster + [testenv] deps = django22: Django>=2.2,<2.3 From d9cd0d362919b983d493964b6ee6e5d526c49d14 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 28 Dec 2020 23:25:36 +0400 Subject: [PATCH 044/252] Add codecov support --- .github/workflows/tests.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7e73beae..e3e786ae 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,6 +12,10 @@ jobs: python-version: ["3.6", "3.7", "3.8", "3.9"] django: ["2.2", "3.0", "3.1"] django-rest-framework: ["3.12", "master"] + env: + PYTHON: ${{ matrix.python-version }} + DJANGO: ${{ matrix.django }} + DJANGO_REST_FRAMEWORK: ${{ matrix.django-rest-framework }} steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} @@ -24,9 +28,10 @@ jobs: pip install tox tox-gh-actions - name: Test with tox run: tox - env: - DJANGO: ${{ matrix.django }} - DJANGO_REST_FRAMEWORK: ${{ matrix.django-rest-framework }} + - name: Upload coverage report + uses: codecov/codecov-action@v1 + with: + env_vars: PYTHON,DJANGO,DJANGO_REST_FRAMEWORK check: name: Run check runs-on: ubuntu-latest From cfca644480adccabaf6eda7092b2397b5438c3eb Mon Sep 17 00:00:00 2001 From: Kevin Partington Date: Tue, 29 Dec 2020 04:01:58 -0600 Subject: [PATCH 045/252] Rename FORMAT_LINKS setting to FORMAT_RELATED_LINKS (#878) --- CHANGELOG.md | 2 +- docs/usage.md | 6 +++--- rest_framework_json_api/settings.py | 2 +- rest_framework_json_api/utils.py | 4 ++-- tests/test_relations.py | 8 ++++---- tests/test_utils.py | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 858fd8d9..13be06bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Added * Ability for the user to select `included_serializers` to apply when using `BrowsableAPI`, based on available `included_serializers` defined for the current endpoint. -* Ability for the user to format serializer properties in URL segments using the `JSON_API_FORMAT_LINKS` setting. +* Ability for the user to format serializer properties in URL segments using the `JSON_API_FORMAT_RELATED_LINKS` setting. ### Fixed diff --git a/docs/usage.md b/docs/usage.md index ca0ccd22..81e52989 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -479,10 +479,10 @@ When set to pluralize: #### Related URL segments -Serializer properties in relationship and related resource URLs may be infected using the `JSON_API_FORMAT_LINKS` setting. +Serializer properties in relationship and related resource URLs may be infected using the `JSON_API_FORMAT_RELATED_LINKS` setting. ``` python -JSON_API_FORMAT_LINKS = 'dasherize' +JSON_API_FORMAT_RELATED_LINKS = 'dasherize' ``` For example, with a serializer property `created_by` and with `'dasherize'` formatting: @@ -513,7 +513,7 @@ For example, with a serializer property `created_by` and with `'dasherize'` form } ``` -The relationship name is formatted by the `JSON_API_FORMAT_FIELD_NAMES` setting, but the URL segments are formatted by the `JSON_API_FORMAT_LINKS` setting. +The relationship name is formatted by the `JSON_API_FORMAT_FIELD_NAMES` setting, but the URL segments are formatted by the `JSON_API_FORMAT_RELATED_LINKS` setting. ### Related fields diff --git a/rest_framework_json_api/settings.py b/rest_framework_json_api/settings.py index e7cc3711..0e790847 100644 --- a/rest_framework_json_api/settings.py +++ b/rest_framework_json_api/settings.py @@ -12,7 +12,7 @@ DEFAULTS = { "FORMAT_FIELD_NAMES": False, "FORMAT_TYPES": False, - "FORMAT_LINKS": False, + "FORMAT_RELATED_LINKS": False, "PLURALIZE_TYPES": False, "UNIFORM_EXCEPTIONS": False, } diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 7b211207..c87fa000 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -151,12 +151,12 @@ def format_resource_type(value, format_type=None, pluralize=None): def format_link_segment(value, format_type=None): """ Takes a string value and returns it with formatted keys as set in `format_type` - or `JSON_API_FORMAT_LINKS`. + or `JSON_API_FORMAT_RELATED_LINKS`. :format_type: Either 'dasherize', 'camelize', 'capitalize' or 'underscore' """ if format_type is None: - format_type = json_api_settings.FORMAT_LINKS + format_type = json_api_settings.FORMAT_RELATED_LINKS return format_value(value, format_type) diff --git a/tests/test_relations.py b/tests/test_relations.py index e9f3800b..2565542f 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -10,7 +10,7 @@ @pytest.mark.urls(__name__) @pytest.mark.parametrize( - "format_links,expected_url_segment", + "format_related_links,expected_url_segment", [ (None, "relatedField_name"), ("dasherize", "related-field-name"), @@ -19,10 +19,10 @@ ("underscore", "related_field_name"), ], ) -def test_relationship_urls_respect_format_links( - settings, format_links, expected_url_segment +def test_relationship_urls_respect_format_related_links_setting( + settings, format_related_links, expected_url_segment ): - settings.JSON_API_FORMAT_LINKS = format_links + settings.JSON_API_FORMAT_RELATED_LINKS = format_related_links model = BasicModel(text="Some text") diff --git a/tests/test_utils.py b/tests/test_utils.py index 449f515e..f542ad7d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -209,7 +209,7 @@ def test_format_field_names(settings, format_type, output): ], ) def test_format_field_segment(settings, format_type, output): - settings.JSON_API_FORMAT_LINKS = format_type + settings.JSON_API_FORMAT_RELATED_LINKS = format_type assert format_link_segment("first_Name") == output From 6ea9e6a56b41a879557285884772e4899523740b Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sat, 2 Jan 2021 20:35:46 +0400 Subject: [PATCH 046/252] Calculate copyright date as it changes (#881) --- example/tests/test_serializers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/example/tests/test_serializers.py b/example/tests/test_serializers.py index b3b1dc2d..ebadbedd 100644 --- a/example/tests/test_serializers.py +++ b/example/tests/test_serializers.py @@ -1,3 +1,4 @@ +from datetime import datetime from unittest import mock import pytest @@ -88,7 +89,7 @@ class Meta: [ ("name", "Some Blog"), ("tags", []), - ("copyright", 2020), + ("copyright", datetime.now().year), ("url", "http://testserver/blogs/1"), ] ), From 11368cafd374c3e5e13af5e39928051cb902fe49 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sat, 2 Jan 2021 20:44:32 +0400 Subject: [PATCH 047/252] Adjust action badge image link to use correct svg (#880) Co-authored-by: Alan Crosswell --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 86e20fd3..2ab48598 100644 --- a/README.rst +++ b/README.rst @@ -2,7 +2,7 @@ JSON API and Django Rest Framework ================================== -.. image:: hhttps://github.com/django-json-api/django-rest-framework-json-api/workflows/Tests/badge.svg +.. image:: https://github.com/django-json-api/django-rest-framework-json-api/workflows/Tests/badge.svg :alt: Tests :target: https://github.com/django-json-api/django-rest-framework-json-api/actions From 236f0b016f12057eae1b4eff7fe5b61b6ae3b23e Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sat, 2 Jan 2021 18:59:02 +0200 Subject: [PATCH 048/252] Scheduled biweekly dependency update for week 51 (#875) * Update recommonmark from 0.6.0 to 0.7.1 * Update sphinx from 3.3.1 to 3.4.0 * Update faker from 5.0.0 to 5.0.2 * Update pytest from 6.1.2 to 6.2.1 Co-authored-by: Oliver Sauder Co-authored-by: Alan Crosswell --- requirements/requirements-documentation.txt | 4 ++-- requirements/requirements-testing.txt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 12dd5670..4099fc5f 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ -recommonmark==0.6.0 -Sphinx==3.3.1 +recommonmark==0.7.1 +Sphinx==3.4.0 sphinx_rtd_theme==0.5.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 66820c49..4c1bc8c4 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ django-debug-toolbar==3.2 factory-boy==3.1.0 -Faker==5.0.0 -pytest==6.1.2 +Faker==5.0.2 +pytest==6.2.1 pytest-cov==2.10.1 pytest-django==4.1.0 pytest-factoryboy==2.0.3 From 952e26b4d65ab13d0dcdd550d7d1aec8902e18ba Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 18 Jan 2021 18:11:05 +0200 Subject: [PATCH 049/252] Scheduled biweekly dependency update for week 03 (#884) * Update isort from 5.6.4 to 5.7.0 * Update sphinx from 3.4.0 to 3.4.3 * Update sphinx_rtd_theme from 0.5.0 to 0.5.1 * Update twine from 3.2.0 to 3.3.0 * Update factory-boy from 3.1.0 to 3.2.0 * Update faker from 5.0.2 to 5.6.1 * Update pytest-cov from 2.10.1 to 2.11.0 * Update pytest-factoryboy from 2.0.3 to 2.1.0 --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-documentation.txt | 4 ++-- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index cdb8b514..db8fae19 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ black==20.8b1 flake8==3.8.4 flake8-isort==4.0.0 -isort==5.6.4 +isort==5.7.0 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 4099fc5f..607dfc7c 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==3.4.0 -sphinx_rtd_theme==0.5.0 +Sphinx==3.4.3 +sphinx_rtd_theme==0.5.1 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index 7bc36909..889f179f 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==3.2.0 +twine==3.3.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 4c1bc8c4..5b0fc490 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,8 +1,8 @@ django-debug-toolbar==3.2 -factory-boy==3.1.0 -Faker==5.0.2 +factory-boy==3.2.0 +Faker==5.6.1 pytest==6.2.1 -pytest-cov==2.10.1 +pytest-cov==2.11.0 pytest-django==4.1.0 -pytest-factoryboy==2.0.3 +pytest-factoryboy==2.1.0 snapshottest==0.6.0 From 5488b181a91554853c7e0aac8eea0f81a20ba761 Mon Sep 17 00:00:00 2001 From: Nikolai Date: Wed, 10 Feb 2021 21:40:23 +0300 Subject: [PATCH 050/252] Render meta_fields in included resources (#883) For consistency reasons the meta fields should also be rendered in included resources. --- AUTHORS | 1 + CHANGELOG.md | 1 + example/serializers.py | 11 +++++++ example/tests/integration/test_includes.py | 14 +++++++++ example/tests/test_format_keys.py | 1 + example/tests/test_serializers.py | 3 ++ .../tests/unit/test_renderer_class_methods.py | 25 +++++++++------- rest_framework_json_api/renderers.py | 29 +++++++++---------- 8 files changed, 60 insertions(+), 25 deletions(-) diff --git a/AUTHORS b/AUTHORS index 11d8db8c..c1a273c4 100644 --- a/AUTHORS +++ b/AUTHORS @@ -23,6 +23,7 @@ Matt Layman Michael Haselton Mohammed Ali Zubair Nathanael Gordon +Nick Kozhenin Ola Tarkowska Oliver Sauder Raphael Cohen diff --git a/CHANGELOG.md b/CHANGELOG.md index 13be06bd..4f2a1a6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ any parts of the framework not mentioned in the documentation should generally b * Allow users to overwrite a view's `get_serializer_class()` method when using [related urls](https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#related-urls) * Correctly resolve the resource type of `ResourceRelatedField(many=True)` fields on plain serializers +* Render `meta_fields` in included resources ## [4.0.0] - 2020-10-31 diff --git a/example/serializers.py b/example/serializers.py index 7a923d4e..4d80c87c 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -251,6 +251,7 @@ class AuthorSerializer(serializers.ModelSerializer): write_only=True, help_text="help for defaults", ) + initials = serializers.SerializerMethodField() included_serializers = {"bio": AuthorBioSerializer, "type": AuthorTypeSerializer} related_serializers = { "bio": "example.serializers.AuthorBioSerializer", @@ -272,11 +273,16 @@ class Meta: "type", "secrets", "defaults", + "initials", ) + meta_fields = ("initials",) def get_first_entry(self, obj): return obj.entries.first() + def get_initials(self, obj): + return "".join([word[0] for word in obj.name.split(" ")]) + class AuthorListSerializer(AuthorSerializer): pass @@ -298,6 +304,7 @@ class Meta: class CommentSerializer(serializers.ModelSerializer): # testing remapping of related name writer = relations.ResourceRelatedField(source="author", read_only=True) + modified_days_ago = serializers.SerializerMethodField() included_serializers = { "entry": EntrySerializer, @@ -312,6 +319,10 @@ class Meta: "modified_at", ) # fields = ('entry', 'body', 'author',) + meta_fields = ("modified_days_ago",) + + def get_modified_days_ago(self, obj): + return (datetime.now() - obj.modified_at).days class ProjectTypeSerializer(serializers.ModelSerializer): diff --git a/example/tests/integration/test_includes.py b/example/tests/integration/test_includes.py index 0ee0d4fd..223645d2 100644 --- a/example/tests/integration/test_includes.py +++ b/example/tests/integration/test_includes.py @@ -261,3 +261,17 @@ def test_data_resource_not_included_again(single_comment, client): # The comment in the data attribute must not be included again. expected_comment_count -= 1 assert comment_count == expected_comment_count, "Comment count incorrect" + + +def test_meta_object_added_to_included_resources(single_entry, client): + response = client.get( + reverse("entry-detail", kwargs={"pk": single_entry.pk}) + "?include=comments" + ) + assert response.json()["included"][0].get("meta") + + response = client.get( + reverse("entry-detail", kwargs={"pk": single_entry.pk}) + + "?include=comments.author" + ) + assert response.json()["included"][0].get("meta") + assert response.json()["included"][1].get("meta") diff --git a/example/tests/test_format_keys.py b/example/tests/test_format_keys.py index 23a8a325..698a8bb1 100644 --- a/example/tests/test_format_keys.py +++ b/example/tests/test_format_keys.py @@ -63,5 +63,6 @@ def test_options_format_field_names(db, client): "comments", "secrets", "defaults", + "initials", } assert expected_keys == data["actions"]["POST"].keys() diff --git a/example/tests/test_serializers.py b/example/tests/test_serializers.py index ebadbedd..7550fd2c 100644 --- a/example/tests/test_serializers.py +++ b/example/tests/test_serializers.py @@ -195,6 +195,9 @@ def test_model_serializer_with_implicit_fields(self, comment, client): "data": {"type": "writers", "id": str(comment.author.pk)} }, }, + "meta": { + "modifiedDaysAgo": (datetime.now() - comment.modified_at).days + }, } } diff --git a/example/tests/unit/test_renderer_class_methods.py b/example/tests/unit/test_renderer_class_methods.py index c599abbd..51cc2481 100644 --- a/example/tests/unit/test_renderer_class_methods.py +++ b/example/tests/unit/test_renderer_class_methods.py @@ -3,6 +3,7 @@ from rest_framework_json_api import serializers from rest_framework_json_api.renderers import JSONRenderer +from rest_framework_json_api.utils import get_serializer_fields pytestmark = pytest.mark.django_db @@ -20,10 +21,7 @@ class Meta: def test_build_json_resource_obj(): - resource = { - "pk": 1, - "username": "Alice", - } + resource = {"username": "Alice", "version": "1.0.0"} serializer = ResourceSerializer(data={"username": "Alice"}) serializer.is_valid() @@ -33,11 +31,16 @@ def test_build_json_resource_obj(): "type": "user", "id": "1", "attributes": {"username": "Alice"}, + "meta": {"version": "1.0.0"}, } assert ( JSONRenderer.build_json_resource_obj( - serializer.fields, resource, resource_instance, "user" + get_serializer_fields(serializer), + resource, + resource_instance, + "user", + serializer, ) == output ) @@ -47,11 +50,8 @@ def test_can_override_methods(): """ Make sure extract_attributes and extract_relationships can be overriden. """ - resource = { - "pk": 1, - "username": "Alice", - } + resource = {"username": "Alice", "version": "1.0.0"} serializer = ResourceSerializer(data={"username": "Alice"}) serializer.is_valid() resource_instance = serializer.save() @@ -60,6 +60,7 @@ def test_can_override_methods(): "type": "user", "id": "1", "attributes": {"username": "Alice"}, + "meta": {"version": "1.0.0"}, } class CustomRenderer(JSONRenderer): @@ -80,7 +81,11 @@ def extract_relationships(cls, fields, resource, resource_instance): assert ( CustomRenderer.build_json_resource_obj( - serializer.fields, resource, resource_instance, "user" + get_serializer_fields(serializer), + resource, + resource_instance, + "user", + serializer, ) == output ) diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 4733288f..e84c562b 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -373,11 +373,11 @@ def extract_included( serializer_resource, nested_resource_instance, resource_type, + serializer, getattr(serializer, "_poly_force_type_resolution", False), ) - included_cache[new_item["type"]][ - new_item["id"] - ] = utils.format_field_names(new_item) + included_cache[new_item["type"]][new_item["id"]] = new_item + cls.extract_included( serializer_fields, serializer_resource, @@ -397,11 +397,11 @@ def extract_included( serializer_data, relation_instance, relation_type, + field, getattr(field, "_poly_force_type_resolution", False), ) - included_cache[new_item["type"]][ - new_item["id"] - ] = utils.format_field_names(new_item) + included_cache[new_item["type"]][new_item["id"]] = new_item + cls.extract_included( serializer_fields, serializer_data, @@ -450,6 +450,7 @@ def build_json_resource_obj( resource, resource_instance, resource_name, + serializer, force_type_resolution=False, ): """ @@ -476,6 +477,11 @@ def build_json_resource_obj( resource_data.append( ("links", {"self": resource[api_settings.URL_FIELD_NAME]}) ) + + meta = cls.extract_meta(serializer, resource) + if meta: + resource_data.append(("meta", utils.format_field_names(meta))) + return OrderedDict(resource_data) def render_relationship_view( @@ -582,13 +588,9 @@ def render(self, data, accepted_media_type=None, renderer_context=None): resource, resource_instance, resource_name, + serializer, force_type_resolution, ) - meta = self.extract_meta(serializer, resource) - if meta: - json_resource_obj.update( - {"meta": utils.format_field_names(meta)} - ) json_api_data.append(json_resource_obj) self.extract_included( @@ -610,13 +612,10 @@ def render(self, data, accepted_media_type=None, renderer_context=None): serializer_data, resource_instance, resource_name, + serializer, force_type_resolution, ) - meta = self.extract_meta(serializer, serializer_data) - if meta: - json_api_data.update({"meta": utils.format_field_names(meta)}) - self.extract_included( fields, serializer_data, From aedc5d9d24a4699a30d9233e5701f7a82c170dcc Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 15 Feb 2021 22:30:50 +0400 Subject: [PATCH 051/252] Convert `ResourceRelatedField` test to pytest styled tests (#887) --- example/settings/dev.py | 1 + example/tests/test_relations.py | 113 ---------------------- tests/conftest.py | 22 +++++ tests/serializers.py | 44 +++++++++ tests/test_relations.py | 162 +++++++++++++++++++++++++++++++- tests/test_utils.py | 18 +--- 6 files changed, 229 insertions(+), 131 deletions(-) create mode 100644 tests/serializers.py diff --git a/example/settings/dev.py b/example/settings/dev.py index 2db2c259..12bed35c 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -27,6 +27,7 @@ "example", "debug_toolbar", "django_filters", + "tests", ] TEMPLATES = [ diff --git a/example/tests/test_relations.py b/example/tests/test_relations.py index b83bbef4..6d1cf83b 100644 --- a/example/tests/test_relations.py +++ b/example/tests/test_relations.py @@ -6,130 +6,17 @@ from rest_framework.fields import SkipField from rest_framework.reverse import reverse -from rest_framework_json_api.exceptions import Conflict from rest_framework_json_api.relations import ( HyperlinkedRelatedField, ResourceRelatedField, SerializerMethodHyperlinkedRelatedField, ) -from rest_framework_json_api.utils import format_resource_type from . import TestBase from example.models import Author, Blog, Comment, Entry -from example.serializers import CommentSerializer from example.views import EntryViewSet -class TestResourceRelatedField(TestBase): - def setUp(self): - super(TestResourceRelatedField, self).setUp() - self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") - self.entry = Entry.objects.create( - blog=self.blog, - headline="headline", - body_text="body_text", - pub_date=timezone.now(), - mod_date=timezone.now(), - n_comments=0, - n_pingbacks=0, - rating=3, - ) - for i in range(1, 6): - name = "some_author{}".format(i) - self.entry.authors.add( - Author.objects.create(name=name, email="{}@example.org".format(name)) - ) - - self.comment = Comment.objects.create( - entry=self.entry, - body="testing one two three", - author=Author.objects.first(), - ) - - def test_data_in_correct_format_when_instantiated_with_blog_object(self): - serializer = BlogFKSerializer(instance={"blog": self.blog}) - - expected_data = {"type": format_resource_type("Blog"), "id": str(self.blog.id)} - - actual_data = serializer.data["blog"] - - self.assertEqual(actual_data, expected_data) - - def test_data_in_correct_format_when_instantiated_with_entry_object(self): - serializer = EntryFKSerializer(instance={"entry": self.entry}) - - expected_data = { - "type": format_resource_type("Entry"), - "id": str(self.entry.id), - } - - actual_data = serializer.data["entry"] - - self.assertEqual(actual_data, expected_data) - - def test_deserialize_primitive_data_blog(self): - serializer = BlogFKSerializer( - data={ - "blog": {"type": format_resource_type("Blog"), "id": str(self.blog.id)} - } - ) - - self.assertTrue(serializer.is_valid()) - self.assertEqual(serializer.validated_data["blog"], self.blog) - - def test_validation_fails_for_wrong_type(self): - with self.assertRaises(Conflict) as cm: - serializer = BlogFKSerializer( - data={"blog": {"type": "Entries", "id": str(self.blog.id)}} - ) - serializer.is_valid() - the_exception = cm.exception - self.assertEqual(the_exception.status_code, 409) - - def test_serialize_many_to_many_relation(self): - serializer = EntryModelSerializer(instance=self.entry) - - type_string = format_resource_type("Author") - author_pks = Author.objects.values_list("pk", flat=True) - expected_data = [{"type": type_string, "id": str(pk)} for pk in author_pks] - - self.assertEqual(serializer.data["authors"], expected_data) - - def test_deserialize_many_to_many_relation(self): - type_string = format_resource_type("Author") - author_pks = Author.objects.values_list("pk", flat=True) - authors = [{"type": type_string, "id": pk} for pk in author_pks] - - serializer = EntryModelSerializer(data={"authors": authors, "comments": []}) - - self.assertTrue(serializer.is_valid()) - self.assertEqual( - len(serializer.validated_data["authors"]), Author.objects.count() - ) - for author in serializer.validated_data["authors"]: - self.assertIsInstance(author, Author) - - def test_read_only(self): - serializer = EntryModelSerializer( - data={"authors": [], "comments": [{"type": "Comments", "id": 2}]} - ) - serializer.is_valid(raise_exception=True) - self.assertNotIn("comments", serializer.validated_data) - - def test_invalid_resource_id_object(self): - comment = { - "body": "testing 123", - "entry": {"type": "entry"}, - "author": {"id": "5"}, - } - serializer = CommentSerializer(data=comment) - assert not serializer.is_valid() - assert serializer.errors == { - "author": ["Invalid resource identifier object: missing 'type' attribute"], - "entry": ["Invalid resource identifier object: missing 'id' attribute"], - } - - class TestHyperlinkedFieldBase(TestBase): def setUp(self): super(TestHyperlinkedFieldBase, self).setUp() diff --git a/tests/conftest.py b/tests/conftest.py index 4942bf63..fbd3f453 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,7 @@ import pytest +from tests.models import ForeignKeyTarget, ManyToManySource, ManyToManyTarget + @pytest.fixture(autouse=True) def use_rest_framework_json_api_defaults(settings): @@ -19,3 +21,23 @@ def use_rest_framework_json_api_defaults(settings): settings.JSON_API_FORMAT_FIELD_NAMES = False settings.JSON_API_FORMAT_TYPES = False settings.JSON_API_PLURALIZE_TYPES = False + + +@pytest.fixture +def foreign_key_target(db): + return ForeignKeyTarget.objects.create(name="Target") + + +@pytest.fixture +def many_to_many_source(db, many_to_many_targets): + source = ManyToManySource.objects.create(name="Source") + source.targets.add(*many_to_many_targets) + return source + + +@pytest.fixture +def many_to_many_targets(db): + return [ + ManyToManyTarget.objects.create(name="Target1"), + ManyToManyTarget.objects.create(name="Target2"), + ] diff --git a/tests/serializers.py b/tests/serializers.py new file mode 100644 index 00000000..8894a211 --- /dev/null +++ b/tests/serializers.py @@ -0,0 +1,44 @@ +from rest_framework_json_api.relations import ResourceRelatedField +from rest_framework_json_api.serializers import ModelSerializer +from tests.models import ( + BasicModel, + ForeignKeySource, + ForeignKeyTarget, + ManyToManySource, + ManyToManyTarget, +) + + +class BasicModelSerializer(ModelSerializer): + class Meta: + fields = ("text",) + model = BasicModel + + +class ForeignKeySourceSerializer(ModelSerializer): + target = ResourceRelatedField(queryset=ForeignKeyTarget.objects) + + class Meta: + model = ForeignKeySource + fields = ("target",) + + +class ManyToManySourceSerializer(ModelSerializer): + targets = ResourceRelatedField(many=True, queryset=ManyToManyTarget.objects) + + class Meta: + model = ManyToManySource + fields = ("targets",) + + +class ManyToManyTargetSerializer(ModelSerializer): + class Meta: + model = ManyToManyTarget + + +class ManyToManySourceReadOnlySerializer(ModelSerializer): + targets = ResourceRelatedField(many=True, read_only=True) + + class Meta: + model = ManyToManySource + fields = ("targets",) diff --git a/tests/test_relations.py b/tests/test_relations.py index 2565542f..4cc9833d 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -1,11 +1,17 @@ import pytest from django.conf.urls import re_path +from rest_framework import status from rest_framework.routers import SimpleRouter +from rest_framework_json_api.exceptions import Conflict from rest_framework_json_api.relations import HyperlinkedRelatedField from rest_framework_json_api.views import ModelViewSet, RelationshipView - -from .models import BasicModel +from tests.models import BasicModel +from tests.serializers import ( + ForeignKeySourceSerializer, + ManyToManySourceReadOnlySerializer, + ManyToManySourceSerializer, +) @pytest.mark.urls(__name__) @@ -43,6 +49,158 @@ def test_relationship_urls_respect_format_related_links_setting( assert expected == actual +@pytest.mark.django_db +class TestResourceRelatedField: + @pytest.mark.parametrize( + "format_type,pluralize_type,resource_type", + [ + (False, False, "ForeignKeyTarget"), + (False, True, "ForeignKeyTargets"), + ("dasherize", False, "foreign-key-target"), + ("dasherize", True, "foreign-key-targets"), + ], + ) + def test_serialize( + self, format_type, pluralize_type, resource_type, foreign_key_target, settings + ): + settings.JSON_API_FORMAT_TYPES = format_type + settings.JSON_API_PLURALIZE_TYPES = pluralize_type + + serializer = ForeignKeySourceSerializer(instance={"target": foreign_key_target}) + expected = { + "type": resource_type, + "id": str(foreign_key_target.pk), + } + + assert serializer.data["target"] == expected + + @pytest.mark.parametrize( + "format_type,pluralize_type,resource_type", + [ + (False, False, "ForeignKeyTarget"), + (False, True, "ForeignKeyTargets"), + ("dasherize", False, "foreign-key-target"), + ("dasherize", True, "foreign-key-targets"), + ], + ) + def test_deserialize( + self, format_type, pluralize_type, resource_type, foreign_key_target, settings + ): + settings.JSON_API_FORMAT_TYPES = format_type + settings.JSON_API_PLURALIZE_TYPES = pluralize_type + + serializer = ForeignKeySourceSerializer( + data={"target": {"type": resource_type, "id": str(foreign_key_target.pk)}} + ) + + assert serializer.is_valid() + assert serializer.validated_data["target"] == foreign_key_target + + @pytest.mark.parametrize( + "format_type,pluralize_type,resource_type", + [ + (False, False, "ForeignKeyTargets"), + (False, False, "Invalid"), + (False, False, "foreign-key-target"), + (False, True, "ForeignKeyTarget"), + ("dasherize", False, "ForeignKeyTarget"), + ("dasherize", True, "ForeignKeyTargets"), + ], + ) + def test_validation_fails_on_invalid_type( + self, format_type, pluralize_type, resource_type, foreign_key_target, settings + ): + settings.JSON_API_FORMAT_TYPES = format_type + settings.JSON_API_PLURALIZE_TYPES = pluralize_type + + with pytest.raises(Conflict) as e: + serializer = ForeignKeySourceSerializer( + data={ + "target": {"type": resource_type, "id": str(foreign_key_target.pk)} + } + ) + serializer.is_valid() + assert e.value.status_code == status.HTTP_409_CONFLICT + + @pytest.mark.parametrize( + "format_type,pluralize_type,resource_type", + [ + (False, False, "ManyToManyTarget"), + (False, True, "ManyToManyTargets"), + ("dasherize", False, "many-to-many-target"), + ("dasherize", True, "many-to-many-targets"), + ], + ) + def test_serialize_many_to_many_relation( + self, + format_type, + pluralize_type, + resource_type, + many_to_many_source, + many_to_many_targets, + settings, + ): + settings.JSON_API_FORMAT_TYPES = format_type + settings.JSON_API_PLURALIZE_TYPES = pluralize_type + + serializer = ManyToManySourceSerializer(instance=many_to_many_source) + expected = [ + {"type": resource_type, "id": str(target.pk)} + for target in many_to_many_targets + ] + assert serializer.data["targets"] == expected + + @pytest.mark.parametrize( + "format_type,pluralize_type,resource_type", + [ + (False, False, "ManyToManyTarget"), + (False, True, "ManyToManyTargets"), + ("dasherize", False, "many-to-many-target"), + ("dasherize", True, "many-to-many-targets"), + ], + ) + @pytest.mark.parametrize( + "serializer_class", + [ManyToManySourceSerializer, ManyToManySourceReadOnlySerializer], + ) + def test_deserialize_many_to_many_relation( + self, + format_type, + pluralize_type, + resource_type, + serializer_class, + many_to_many_targets, + settings, + ): + settings.JSON_API_FORMAT_TYPES = format_type + settings.JSON_API_PLURALIZE_TYPES = pluralize_type + + targets = [ + {"type": resource_type, "id": target.pk} for target in many_to_many_targets + ] + serializer = ManyToManySourceSerializer(data={"targets": targets}) + assert serializer.is_valid() + assert serializer.validated_data["targets"] == many_to_many_targets + + @pytest.mark.parametrize( + "resource_identifier,error", + [ + ( + {"type": "ForeignKeyTarget"}, + "Invalid resource identifier object: missing 'id' attribute", + ), + ( + {"id": "1234"}, + "Invalid resource identifier object: missing 'type' attribute", + ), + ], + ) + def test_invalid_resource_id_object(self, resource_identifier, error): + serializer = ForeignKeySourceSerializer(data={"target": resource_identifier}) + assert not serializer.is_valid() + assert serializer.errors == {"target": [error]} + + # Routing setup diff --git a/tests/test_utils.py b/tests/test_utils.py index f542ad7d..40f9d391 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -23,6 +23,7 @@ ManyToManySource, ManyToManyTarget, ) +from tests.serializers import BasicModelSerializer, ManyToManyTargetSerializer def test_get_resource_name_no_view(): @@ -99,11 +100,6 @@ def test_get_resource_name_from_model(settings, format_type, pluralize_type, out def test_get_resource_name_from_model_serializer_class( settings, format_type, pluralize_type, output ): - class BasicModelSerializer(serializers.ModelSerializer): - class Meta: - fields = ("text",) - model = BasicModel - settings.JSON_API_FORMAT_TYPES = format_type settings.JSON_API_PLURALIZE_TYPES = pluralize_type @@ -125,11 +121,6 @@ class Meta: def test_get_resource_name_from_model_serializer_class_custom_resource_name( settings, format_type, pluralize_type ): - class BasicModelSerializer(serializers.ModelSerializer): - class Meta: - fields = ("text",) - model = BasicModel - settings.JSON_API_FORMAT_TYPES = format_type settings.JSON_API_PLURALIZE_TYPES = pluralize_type @@ -280,11 +271,6 @@ class PlainRelatedResourceTypeSerializer(serializers.Serializer): assert get_related_resource_type(field) == output -class ManyToManyTargetSerializer(serializers.ModelSerializer): - class Meta: - model = ManyToManyTarget - - def test_get_included_serializers(): class IncludedSerializersModel(DJAModel): self = models.ForeignKey("self", on_delete=models.CASCADE) @@ -298,7 +284,7 @@ class IncludedSerializersSerializer(serializers.ModelSerializer): included_serializers = { "self": "self", "target": ManyToManyTargetSerializer, - "other_target": "tests.test_utils.ManyToManyTargetSerializer", + "other_target": "tests.serializers.ManyToManyTargetSerializer", } class Meta: From 843e1fb471ed19befa6957b5e3573144e7119c11 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 8 Mar 2021 22:06:11 +0400 Subject: [PATCH 052/252] Release 4.1.0 (#892) --- CHANGELOG.md | 2 +- rest_framework_json_api/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f2a1a6e..aedc3597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [4.1.0] = 2021-03-10 ### Added diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index 4c4f115d..3ce910ef 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- __title__ = "djangorestframework-jsonapi" -__version__ = "4.0.0" +__version__ = "4.1.0" __author__ = "" __license__ = "BSD" __copyright__ = "" From 1de9d1875d5a9c6232d841eb17fb6e0c0ebe9f6c Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 8 Mar 2021 22:13:05 +0400 Subject: [PATCH 053/252] Adjust date of version 4.1.0 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aedc3597..c64e4a4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [4.1.0] = 2021-03-10 +## [4.1.0] - 2021-03-08 ### Added From 3224db1da59e502ced70a81df0fe95a860d8fe92 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 8 Mar 2021 22:52:03 +0400 Subject: [PATCH 054/252] Convert HyperlinkedRelatedField tests to pytest style (#889) --- example/tests/test_relations.py | 195 -------------------------------- tests/conftest.py | 12 +- tests/test_relations.py | 134 ++++++++++++++++------ 3 files changed, 109 insertions(+), 232 deletions(-) delete mode 100644 example/tests/test_relations.py diff --git a/example/tests/test_relations.py b/example/tests/test_relations.py deleted file mode 100644 index 6d1cf83b..00000000 --- a/example/tests/test_relations.py +++ /dev/null @@ -1,195 +0,0 @@ -from __future__ import absolute_import - -from django.test.client import RequestFactory -from django.utils import timezone -from rest_framework import serializers -from rest_framework.fields import SkipField -from rest_framework.reverse import reverse - -from rest_framework_json_api.relations import ( - HyperlinkedRelatedField, - ResourceRelatedField, - SerializerMethodHyperlinkedRelatedField, -) - -from . import TestBase -from example.models import Author, Blog, Comment, Entry -from example.views import EntryViewSet - - -class TestHyperlinkedFieldBase(TestBase): - def setUp(self): - super(TestHyperlinkedFieldBase, self).setUp() - self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") - self.entry = Entry.objects.create( - blog=self.blog, - headline="headline", - body_text="body_text", - pub_date=timezone.now(), - mod_date=timezone.now(), - n_comments=0, - n_pingbacks=0, - rating=3, - ) - self.comment = Comment.objects.create( - entry=self.entry, - body="testing one two three", - ) - - self.request = RequestFactory().get( - reverse("entry-detail", kwargs={"pk": self.entry.pk}) - ) - self.view = EntryViewSet( - request=self.request, kwargs={"entry_pk": self.entry.id} - ) - - -class TestHyperlinkedRelatedField(TestHyperlinkedFieldBase): - def test_single_hyperlinked_related_field(self): - field = HyperlinkedRelatedField( - related_link_view_name="entry-blog", - related_link_url_kwarg="entry_pk", - self_link_view_name="entry-relationships", - read_only=True, - ) - field._context = {"request": self.request, "view": self.view} - field.field_name = "blog" - - self.assertRaises(NotImplementedError, field.to_representation, self.entry) - self.assertRaises(SkipField, field.get_attribute, self.entry) - - links_expected = { - "self": "http://testserver/entries/{}/relationships/blog".format( - self.entry.pk - ), - "related": "http://testserver/entries/{}/blog".format(self.entry.pk), - } - got = field.get_links(self.entry) - self.assertEqual(got, links_expected) - - def test_many_hyperlinked_related_field(self): - field = HyperlinkedRelatedField( - related_link_view_name="entry-comments", - related_link_url_kwarg="entry_pk", - self_link_view_name="entry-relationships", - read_only=True, - many=True, - ) - field._context = {"request": self.request, "view": self.view} - field.field_name = "comments" - - self.assertRaises( - NotImplementedError, field.to_representation, self.entry.comments.all() - ) - self.assertRaises(SkipField, field.get_attribute, self.entry) - - links_expected = { - "self": "http://testserver/entries/{}/relationships/comments".format( - self.entry.pk - ), - "related": "http://testserver/entries/{}/comments".format(self.entry.pk), - } - got = field.child_relation.get_links(self.entry) - self.assertEqual(got, links_expected) - - -class TestSerializerMethodHyperlinkedRelatedField(TestHyperlinkedFieldBase): - def test_single_serializer_method_hyperlinked_related_field(self): - serializer = EntryModelSerializerWithHyperLinks( - instance=self.entry, context={"request": self.request, "view": self.view} - ) - field = serializer.fields["blog"] - - self.assertRaises(NotImplementedError, field.to_representation, self.entry) - self.assertRaises(SkipField, field.get_attribute, self.entry) - - expected = { - "self": "http://testserver/entries/{}/relationships/blog".format( - self.entry.pk - ), - "related": "http://testserver/entries/{}/blog".format(self.entry.pk), - } - got = field.get_links(self.entry) - self.assertEqual(got, expected) - - def test_many_serializer_method_hyperlinked_related_field(self): - serializer = EntryModelSerializerWithHyperLinks( - instance=self.entry, context={"request": self.request, "view": self.view} - ) - field = serializer.fields["comments"] - - self.assertRaises(NotImplementedError, field.to_representation, self.entry) - self.assertRaises(SkipField, field.get_attribute, self.entry) - - expected = { - "self": "http://testserver/entries/{}/relationships/comments".format( - self.entry.pk - ), - "related": "http://testserver/entries/{}/comments".format(self.entry.pk), - } - got = field.get_links(self.entry) - self.assertEqual(got, expected) - - def test_get_blog(self): - serializer = EntryModelSerializerWithHyperLinks(instance=self.entry) - got = serializer.get_blog(self.entry) - expected = self.entry.blog - - self.assertEqual(got, expected) - - def test_get_comments(self): - serializer = EntryModelSerializerWithHyperLinks(instance=self.entry) - got = serializer.get_comments(self.entry) - expected = self.entry.comments.all() - - self.assertListEqual(list(got), list(expected)) - - -class BlogResourceRelatedField(ResourceRelatedField): - def get_queryset(self): - return Blog.objects - - -class BlogFKSerializer(serializers.Serializer): - blog = BlogResourceRelatedField() - - -class EntryFKSerializer(serializers.Serializer): - entry = ResourceRelatedField(queryset=Entry.objects) - - -class EntryModelSerializer(serializers.ModelSerializer): - authors = ResourceRelatedField(many=True, queryset=Author.objects) - comments = ResourceRelatedField(many=True, read_only=True) - - class Meta: - model = Entry - fields = ("authors", "comments") - - -class EntryModelSerializerWithHyperLinks(serializers.ModelSerializer): - blog = SerializerMethodHyperlinkedRelatedField( - related_link_view_name="entry-blog", - related_link_url_kwarg="entry_pk", - self_link_view_name="entry-relationships", - many=True, - ) - comments = SerializerMethodHyperlinkedRelatedField( - related_link_view_name="entry-comments", - related_link_url_kwarg="entry_pk", - self_link_view_name="entry-relationships", - many=True, - ) - - class Meta: - model = Entry - fields = ( - "blog", - "comments", - ) - - def get_blog(self, obj): - return obj.blog - - def get_comments(self, obj): - return obj.comments.all() diff --git a/tests/conftest.py b/tests/conftest.py index fbd3f453..22be93a1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,11 @@ import pytest -from tests.models import ForeignKeyTarget, ManyToManySource, ManyToManyTarget +from tests.models import ( + BasicModel, + ForeignKeyTarget, + ManyToManySource, + ManyToManyTarget, +) @pytest.fixture(autouse=True) @@ -23,6 +28,11 @@ def use_rest_framework_json_api_defaults(settings): settings.JSON_API_PLURALIZE_TYPES = False +@pytest.fixture +def model(db): + return BasicModel.objects.create(text="Model") + + @pytest.fixture def foreign_key_target(db): return ForeignKeyTarget.objects.create(name="Target") diff --git a/tests/test_relations.py b/tests/test_relations.py index 4cc9833d..d66c602f 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -1,10 +1,16 @@ import pytest from django.conf.urls import re_path from rest_framework import status +from rest_framework.fields import SkipField from rest_framework.routers import SimpleRouter +from rest_framework.serializers import Serializer from rest_framework_json_api.exceptions import Conflict -from rest_framework_json_api.relations import HyperlinkedRelatedField +from rest_framework_json_api.relations import ( + HyperlinkedRelatedField, + SerializerMethodHyperlinkedRelatedField, +) +from rest_framework_json_api.utils import format_link_segment from rest_framework_json_api.views import ModelViewSet, RelationshipView from tests.models import BasicModel from tests.serializers import ( @@ -14,41 +20,6 @@ ) -@pytest.mark.urls(__name__) -@pytest.mark.parametrize( - "format_related_links,expected_url_segment", - [ - (None, "relatedField_name"), - ("dasherize", "related-field-name"), - ("camelize", "relatedFieldName"), - ("capitalize", "RelatedFieldName"), - ("underscore", "related_field_name"), - ], -) -def test_relationship_urls_respect_format_related_links_setting( - settings, format_related_links, expected_url_segment -): - settings.JSON_API_FORMAT_RELATED_LINKS = format_related_links - - model = BasicModel(text="Some text") - - field = HyperlinkedRelatedField( - self_link_view_name="basic-model-relationships", - related_link_view_name="basic-model-related", - read_only=True, - ) - field.field_name = "relatedField_name" - - expected = { - "self": f"/basic_models/{model.pk}/relationships/{expected_url_segment}/", - "related": f"/basic_models/{model.pk}/{expected_url_segment}/", - } - - actual = field.get_links(model) - - assert expected == actual - - @pytest.mark.django_db class TestResourceRelatedField: @pytest.mark.parametrize( @@ -201,6 +172,97 @@ def test_invalid_resource_id_object(self, resource_identifier, error): assert serializer.errors == {"target": [error]} +class TestHyperlinkedRelatedField: + @pytest.fixture + def instance(self): + # dummy instance + return object() + + @pytest.fixture + def serializer(self): + class HyperlinkedRelatedFieldSerializer(Serializer): + single = HyperlinkedRelatedField( + self_link_view_name="basic-model-relationships", + related_link_view_name="basic-model-related", + read_only=True, + ) + many = HyperlinkedRelatedField( + self_link_view_name="basic-model-relationships", + related_link_view_name="basic-model-related", + read_only=True, + many=True, + ) + single_serializer_method = SerializerMethodHyperlinkedRelatedField( + self_link_view_name="basic-model-relationships", + related_link_view_name="basic-model-related", + read_only=True, + ) + many_serializer_method = SerializerMethodHyperlinkedRelatedField( + self_link_view_name="basic-model-relationships", + related_link_view_name="basic-model-related", + read_only=True, + many=True, + ) + + def get_single_serializer_method(self, obj): # pragma: no cover + raise NotImplementedError + + def get_many_serializer_method(self, obj): # pragma: no cover + raise NotImplementedError + + return HyperlinkedRelatedFieldSerializer() + + @pytest.fixture( + params=["single", "many", "single_serializer_method", "many_serializer_method"] + ) + def field(self, serializer, request): + field = serializer.fields[request.param] + field.field_name = request.param + return field + + def test_get_attribute(self, model, field): + with pytest.raises(SkipField): + field.get_attribute(model) + + def test_to_representation(self, model, field): + with pytest.raises(NotImplementedError): + field.to_representation(model) + + @pytest.mark.urls(__name__) + @pytest.mark.parametrize( + "format_related_links", + [ + None, + "dasherize", + "camelize", + "capitalize", + "underscore", + ], + ) + def test_get_links( + self, + format_related_links, + field, + settings, + model, + ): + settings.JSON_API_FORMAT_RELATED_LINKS = format_related_links + + link_segment = format_link_segment(field.field_name) + + expected = { + "self": f"/basic_models/{model.pk}/relationships/{link_segment}/", + "related": f"/basic_models/{model.pk}/{link_segment}/", + } + + if hasattr(field, "child_relation"): + # many case + field = field.child_relation + + actual = field.get_links(model) + assert expected == actual + + # Routing setup From 1444a67ba4c268cde477a7a04c72e553f475f309 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 10 Mar 2021 22:38:14 +0400 Subject: [PATCH 055/252] Add basis code quality analysis (#895) Adding this for a test run whether it gives any helpful information. If not we can also remove it again. --- .github/workflows/codeql-analysis.yml | 67 +++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .github/workflows/codeql-analysis.yml diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 00000000..89e9f974 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,67 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ master ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ master ] + schedule: + - cron: '33 5 * * 6' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] + # Learn more: + # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 From e32ff43c05b4990b7c72c3862e3b7cc72bb0e3f8 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 17 Mar 2021 00:24:44 +0400 Subject: [PATCH 056/252] Create security policy (#893) * Add security policy * Update SECURITY.md Replace invalid email address * Update SECURITY.md Add missing space * Update CONTRIBUTING.md Clarify add maintainer description. --- SECURITY.md | 9 +++++++++ docs/CONTRIBUTING.md | 12 ++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..ef73aad3 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,9 @@ +# Security Policy + +## Reporting a Vulnerability + +If you believe you've found something in Django REST Framework JSON API which has security implications, please **do not raise the issue in a public forum**. + +Send a description of the issue via email to [rest-framework-security@googlegroups.com][security-mail]. The project maintainers will then work with you to resolve any issues where required, prior to any public disclosure. + +[security-mail]: mailto:rest-framework-security@googlegroups.com diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index c3f7d0f1..be1d0499 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -52,9 +52,21 @@ To setup pre-commit hooks first create a testing environment as explained above ## For maintainers +### Create release + To upload a release (using version 1.2.3 as the example) first setup testing environment as above before running below commands: python setup.py sdist bdist_wheel twine upload dist/* git tag -a v1.2.3 -m 'Release 1.2.3' git push --tags + + +### Add maintainer + +In case a new maintainer joins our team we need to consider to what of following services we want to add them too: + +* [Github organization](https://github.com/django-json-api) +* [Read the Docs project](https://django-rest-framework-json-api.readthedocs.io/) +* [PyPi project](https://pypi.org/project/djangorestframework-jsonapi/) +* [Google Groups security mailing list](https://groups.google.com/g/rest-framework-jsonapi-security) From 0cc77508784d970cc9a4c55e73959fdf8e052073 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 16 Mar 2021 22:37:46 +0200 Subject: [PATCH 057/252] Scheduled biweekly dependency update for week 11 (#896) * Update flake8 from 3.8.4 to 3.9.0 * Update sphinx from 3.4.3 to 3.5.2 * Update pyyaml from 5.3.1 to 5.4.1 * Update faker from 5.6.1 to 6.6.0 * Update pytest from 6.2.1 to 6.2.2 * Update pytest-cov from 2.11.0 to 2.11.1 Co-authored-by: Alan Crosswell --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-documentation.txt | 2 +- requirements/requirements-optionals.txt | 2 +- requirements/requirements-testing.txt | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index db8fae19..dced259a 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ black==20.8b1 -flake8==3.8.4 +flake8==3.9.0 flake8-isort==4.0.0 isort==5.7.0 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 607dfc7c..e904918e 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==3.4.3 +Sphinx==3.5.2 sphinx_rtd_theme==0.5.1 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index e5ceb15f..15e87ee5 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,4 +1,4 @@ django-filter==2.4.0 django-polymorphic==3.0.0 -pyyaml==5.3.1 +pyyaml==5.4.1 uritemplate==3.0.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 5b0fc490..f76c6686 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,8 +1,8 @@ django-debug-toolbar==3.2 factory-boy==3.2.0 -Faker==5.6.1 -pytest==6.2.1 -pytest-cov==2.11.0 +Faker==6.6.0 +pytest==6.2.2 +pytest-cov==2.11.1 pytest-django==4.1.0 pytest-factoryboy==2.1.0 snapshottest==0.6.0 From 0892e3a8a4dbad9630d70e2b78e18b242a8b057d Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sat, 20 Mar 2021 19:49:52 +0400 Subject: [PATCH 058/252] Convert parsers test to pytest style (#898) --- example/tests/test_parsers.py | 145 ---------------------------------- tests/conftest.py | 6 ++ tests/test_parsers.py | 130 ++++++++++++++++++++++++++++++ tests/test_relations.py | 8 +- tests/test_views.py | 117 +++++++++++++++++++-------- tests/views.py | 10 +++ 6 files changed, 234 insertions(+), 182 deletions(-) delete mode 100644 example/tests/test_parsers.py create mode 100644 tests/test_parsers.py create mode 100644 tests/views.py diff --git a/example/tests/test_parsers.py b/example/tests/test_parsers.py deleted file mode 100644 index b83f70a7..00000000 --- a/example/tests/test_parsers.py +++ /dev/null @@ -1,145 +0,0 @@ -import json -from io import BytesIO - -from django.test import TestCase, override_settings -from django.urls import path, reverse -from rest_framework import status, views -from rest_framework.exceptions import ParseError -from rest_framework.response import Response -from rest_framework.test import APITestCase - -from rest_framework_json_api import serializers -from rest_framework_json_api.parsers import JSONParser -from rest_framework_json_api.renderers import JSONRenderer - - -class TestJSONParser(TestCase): - def setUp(self): - class MockRequest(object): - def __init__(self): - self.method = "GET" - - request = MockRequest() - - self.parser_context = {"request": request, "kwargs": {}, "view": "BlogViewSet"} - - data = { - "data": { - "id": 123, - "type": "Blog", - "attributes": {"json-value": {"JsonKey": "JsonValue"}}, - }, - "meta": {"random_key": "random_value"}, - } - - self.string = json.dumps(data) - - @override_settings(JSON_API_FORMAT_FIELD_NAMES="dasherize") - def test_parse_include_metadata_format_field_names(self): - parser = JSONParser() - - stream = BytesIO(self.string.encode("utf-8")) - data = parser.parse(stream, None, self.parser_context) - - self.assertEqual(data["_meta"], {"random_key": "random_value"}) - self.assertEqual(data["json_value"], {"JsonKey": "JsonValue"}) - - def test_parse_invalid_data(self): - parser = JSONParser() - - string = json.dumps([]) - stream = BytesIO(string.encode("utf-8")) - - with self.assertRaises(ParseError): - parser.parse(stream, None, self.parser_context) - - def test_parse_invalid_data_key(self): - parser = JSONParser() - - string = json.dumps( - { - "data": [ - { - "id": 123, - "type": "Blog", - "attributes": {"json-value": {"JsonKey": "JsonValue"}}, - } - ] - } - ) - stream = BytesIO(string.encode("utf-8")) - - with self.assertRaises(ParseError): - parser.parse(stream, None, self.parser_context) - - -class DummyDTO: - def __init__(self, response_dict): - for k, v in response_dict.items(): - setattr(self, k, v) - - @property - def pk(self): - return self.id if hasattr(self, "id") else None - - -class DummySerializer(serializers.Serializer): - body = serializers.CharField() - id = serializers.IntegerField() - - -class DummyAPIView(views.APIView): - parser_classes = [JSONParser] - renderer_classes = [JSONRenderer] - resource_name = "dummy" - - def patch(self, request, *args, **kwargs): - serializer = DummySerializer(DummyDTO(request.data)) - return Response(status=status.HTTP_200_OK, data=serializer.data) - - -urlpatterns = [ - path("repeater", DummyAPIView.as_view(), name="repeater"), -] - - -class TestParserOnAPIView(APITestCase): - def setUp(self): - class MockRequest(object): - def __init__(self): - self.method = "PATCH" - - request = MockRequest() - # To be honest view string isn't resolved into actual view - self.parser_context = {"request": request, "kwargs": {}, "view": "DummyAPIView"} - - self.data = { - "data": { - "id": 123, - "type": "strs", - "attributes": {"body": "hello"}, - } - } - - self.string = json.dumps(self.data) - - def test_patch_doesnt_raise_attribute_error(self): - parser = JSONParser() - - stream = BytesIO(self.string.encode("utf-8")) - - data = parser.parse(stream, None, self.parser_context) - - assert data["id"] == 123 - assert data["body"] == "hello" - - @override_settings(ROOT_URLCONF=__name__) - def test_patch_request(self): - url = reverse("repeater") - data = self.data - data["data"]["type"] = "dummy" - response = self.client.patch(url, data=data) - data = response.json() - - assert data["data"]["id"] == str(123) - assert data["data"]["attributes"]["body"] == "hello" diff --git a/tests/conftest.py b/tests/conftest.py index 22be93a1..ebdf5348 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ import pytest +from rest_framework.test import APIClient from tests.models import ( BasicModel, @@ -51,3 +52,8 @@ def many_to_many_targets(db): ManyToManyTarget.objects.create(name="Target1"), ManyToManyTarget.objects.create(name="Target2"), ] + + +@pytest.fixture +def client(): + return APIClient() diff --git a/tests/test_parsers.py b/tests/test_parsers.py new file mode 100644 index 00000000..907d1eb6 --- /dev/null +++ b/tests/test_parsers.py @@ -0,0 +1,130 @@ +import json +from io import BytesIO + +import pytest +from rest_framework.exceptions import ParseError + +from rest_framework_json_api.parsers import JSONParser +from rest_framework_json_api.utils import format_value +from tests.views import BasicModelViewSet + + +class TestJSONParser: + @pytest.fixture + def parser(self): + return JSONParser() + + @pytest.fixture + def parse(self, parser, parser_context): + def parse_wrapper(data): + stream = BytesIO(json.dumps(data).encode("utf-8")) + return parser.parse(stream, None, parser_context) + + return parse_wrapper + + @pytest.fixture + def parser_context(self, rf): + return {"request": rf.post("/"), "kwargs": {}, "view": BasicModelViewSet()} + + @pytest.mark.parametrize( + "format_field_names", + [ + None, + "dasherize", + "camelize", + "capitalize", + "underscore", + ], + ) + def test_parse_formats_field_names( + self, + settings, + format_field_names, + parse, + ): + settings.JSON_API_FORMAT_FIELD_NAMES = format_field_names + + data = { + "data": { + "id": "123", + "type": "BasicModel", + "attributes": { + format_value("test_attribute", format_field_names): "test-value" + }, + "relationships": { + format_value("test_relationship", format_field_names): { + "data": {"type": "TestRelationship", "id": "123"} + } + }, + } + } + + result = parse(data) + assert result == { + "id": "123", + "test_attribute": "test-value", + "test_relationship": {"id": "123", "type": "TestRelationship"}, + } + + def test_parse_extracts_meta(self, parse): + data = { + "data": { + "type": "BasicModel", + }, + "meta": {"random_key": "random_value"}, + } + + result = parse(data) + assert result["_meta"] == data["meta"] + + def test_parse_preserves_json_value_field_names(self, settings, parse): + settings.JSON_API_FORMAT_FIELD_NAMES = "dasherize" + + data = { + "data": { + "type": "BasicModel", + "attributes": {"json-value": {"JsonKey": "JsonValue"}}, + }, + } + + result = parse(data) + assert result["json_value"] == {"JsonKey": "JsonValue"} + + def test_parse_raises_error_on_empty_data(self, parse): + data = [] + + with pytest.raises(ParseError) as excinfo: + parse(data) + assert "Received document does not contain primary data" == str(excinfo.value) + + def test_parse_fails_on_list_of_objects(self, parse): + data = { + "data": [ + { + "type": "BasicModel", + "attributes": {"json-value": {"JsonKey": "JsonValue"}}, + } + ], + } + + with pytest.raises(ParseError) as excinfo: + parse(data) + + assert "Received data is not a valid JSONAPI Resource Identifier Object" == str( + excinfo.value + ) + + def test_parse_fails_when_id_is_missing_on_patch(self, rf, parse, parser_context): + parser_context["request"] = rf.patch("/") + data = { + "data": { + "type": "BasicModel", + }, + } + + with pytest.raises(ParseError) as excinfo: + parse(data) + + assert "The resource identifier object must contain an 'id' member" == str( + excinfo.value + ) diff --git a/tests/test_relations.py b/tests/test_relations.py index d66c602f..1baafdd0 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -11,13 +11,14 @@ SerializerMethodHyperlinkedRelatedField, ) from rest_framework_json_api.utils import format_link_segment -from rest_framework_json_api.views import ModelViewSet, RelationshipView +from rest_framework_json_api.views import RelationshipView from tests.models import BasicModel from tests.serializers import ( ForeignKeySourceSerializer, ManyToManySourceReadOnlySerializer, ManyToManySourceSerializer, ) +from tests.views import BasicModelViewSet @pytest.mark.django_db @@ -266,11 +267,6 @@ def test_get_links( # Routing setup -class BasicModelViewSet(ModelViewSet): - class Meta: - model = BasicModel - - class BasicModelRelationshipView(RelationshipView): queryset = BasicModel.objects diff --git a/tests/test_views.py b/tests/test_views.py index 8241a10e..e419ea0f 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,49 +1,104 @@ import pytest +from django.urls import path, reverse +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView -from rest_framework_json_api import serializers, views +from rest_framework_json_api import serializers +from rest_framework_json_api.parsers import JSONParser from rest_framework_json_api.relations import ResourceRelatedField +from rest_framework_json_api.renderers import JSONRenderer from rest_framework_json_api.utils import format_value +from rest_framework_json_api.views import ModelViewSet +from tests.models import BasicModel -from .models import BasicModel -related_model_field_name = "related_field_model" +class TestModelViewSet: + @pytest.mark.parametrize( + "format_links", + [ + None, + "dasherize", + "camelize", + "capitalize", + "underscore", + ], + ) + def test_get_related_field_name_handles_formatted_link_segments( + self, format_links, rf + ): + # use field name which actually gets formatted + related_model_field_name = "related_field_model" + class RelatedFieldNameSerializer(serializers.ModelSerializer): + related_model_field = ResourceRelatedField(queryset=BasicModel.objects) -@pytest.mark.parametrize( - "format_links", - [ - None, - "dasherize", - "camelize", - "capitalize", - "underscore", - ], -) -def test_get_related_field_name_handles_formatted_link_segments(format_links, rf): - url_segment = format_value(related_model_field_name, format_links) + def __init__(self, *args, **kwargs): + self.related_model_field.field_name = related_model_field_name + super().__init(*args, **kwargs) - request = rf.get(f"/basic_models/1/{url_segment}") + class Meta: + model = BasicModel - view = BasicModelFakeViewSet() - view.setup(request, related_field=url_segment) + class RelatedFieldNameView(ModelViewSet): + serializer_class = RelatedFieldNameSerializer - assert view.get_related_field_name() == related_model_field_name + url_segment = format_value(related_model_field_name, format_links) + request = rf.get(f"/basic_models/1/{url_segment}") -class BasicModelSerializer(serializers.ModelSerializer): - related_model_field = ResourceRelatedField(queryset=BasicModel.objects) + view = RelatedFieldNameView() + view.setup(request, related_field=url_segment) - def __init__(self, *args, **kwargs): - # Intentionally setting field_name property to something that matches no format - self.related_model_field.field_name = related_model_field_name - super(BasicModelSerializer, self).__init(*args, **kwargs) + assert view.get_related_field_name() == related_model_field_name - class Meta: - model = BasicModel +class TestAPIView: + @pytest.mark.urls(__name__) + def test_patch(self, client): + data = { + "data": { + "id": 123, + "type": "custom", + "attributes": {"body": "hello"}, + } + } -class BasicModelFakeViewSet(views.ModelViewSet): - serializer_class = BasicModelSerializer + url = reverse("custom") - def retrieve(self, request, *args, **kwargs): - pass + response = client.patch(url, data=data) + result = response.json() + + assert result["data"]["id"] == str(123) + assert result["data"]["type"] == "custom" + assert result["data"]["attributes"]["body"] == "hello" + + +class CustomModel: + def __init__(self, response_dict): + for k, v in response_dict.items(): + setattr(self, k, v) + + @property + def pk(self): + return self.id if hasattr(self, "id") else None + + +class CustomModelSerializer(serializers.Serializer): + body = serializers.CharField() + id = serializers.IntegerField() + + +class CustomAPIView(APIView): + parser_classes = [JSONParser] + renderer_classes = [JSONRenderer] + resource_name = "custom" + + def patch(self, request, *args, **kwargs): + serializer = CustomModelSerializer(CustomModel(request.data)) + return Response(status=status.HTTP_200_OK, data=serializer.data) + + +urlpatterns = [ + path("custom", CustomAPIView.as_view(), name="custom"), +] diff --git a/tests/views.py b/tests/views.py new file mode 100644 index 00000000..e7046a52 --- /dev/null +++ b/tests/views.py @@ -0,0 +1,10 @@ +from rest_framework_json_api.views import ModelViewSet +from tests.models import BasicModel +from tests.serializers import BasicModelSerializer + + +class BasicModelViewSet(ModelViewSet): + serializer_class = BasicModelSerializer + + class Meta: + model = BasicModel From 51daed1a738aadb1536a6a3cbe787cbd88daaf05 Mon Sep 17 00:00:00 2001 From: Jeppe Fihl-Pearson Date: Wed, 7 Apr 2021 19:04:41 +0100 Subject: [PATCH 059/252] Add Django 3.2 support (#908) Co-authored-by: Oliver Sauder --- .github/workflows/tests.yml | 2 +- AUTHORS | 1 + CHANGELOG.md | 6 ++++++ README.rst | 2 +- docs/getting-started.md | 2 +- setup.cfg | 2 ++ setup.py | 2 +- tox.ini | 4 +++- 8 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e3e786ae..a48a7915 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,7 @@ jobs: fail-fast: false matrix: python-version: ["3.6", "3.7", "3.8", "3.9"] - django: ["2.2", "3.0", "3.1"] + django: ["2.2", "3.0", "3.1", "3.2"] django-rest-framework: ["3.12", "master"] env: PYTHON: ${{ matrix.python-version }} diff --git a/AUTHORS b/AUTHORS index c1a273c4..543274a8 100644 --- a/AUTHORS +++ b/AUTHORS @@ -12,6 +12,7 @@ Felix Viernickel Greg Aker Jamie Bliss Jason Housley +Jeppe Fihl-Pearson Jerel Unruh Jonathan Senecal Joseba Mendivil diff --git a/CHANGELOG.md b/CHANGELOG.md index c64e4a4c..b816f47d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## [Unreleased] + +### Added + +* Added support for Django 3.2. + ## [4.1.0] - 2021-03-08 ### Added diff --git a/README.rst b/README.rst index 2ab48598..03f9d52a 100644 --- a/README.rst +++ b/README.rst @@ -89,7 +89,7 @@ Requirements ------------ 1. Python (3.6, 3.7, 3.8, 3.9) -2. Django (2.2, 3.0, 3.1) +2. Django (2.2, 3.0, 3.1, 3.2) 3. Django REST Framework (3.12) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST Framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index c305c801..5e374b30 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.6, 3.7, 3.8, 3.9) -2. Django (2.2, 3.0, 3.1) +2. Django (2.2, 3.0, 3.1, 3.2) 3. Django REST Framework (3.12) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST Framework series. diff --git a/setup.cfg b/setup.cfg index 527ddd6b..83f6fa37 100644 --- a/setup.cfg +++ b/setup.cfg @@ -58,6 +58,8 @@ DJANGO_SETTINGS_MODULE=example.settings.test filterwarnings = error::DeprecationWarning error::PendingDeprecationWarning + # Django Debug Toolbar currently (2021-04-07) specifies default_app_config which is deprecated in Django 3.2: + ignore:'debug_toolbar' defines default_app_config = 'debug_toolbar.apps.DebugToolbarConfig'. Django now detects this configuration automatically. You can remove default_app_config.:PendingDeprecationWarning testpaths = example tests diff --git a/setup.py b/setup.py index a076a7a5..8cce9a5d 100755 --- a/setup.py +++ b/setup.py @@ -98,7 +98,7 @@ def get_package_data(package): install_requires=[ "inflection>=0.3.0", "djangorestframework>=3.12,<3.13", - "django>=2.2,<3.2", + "django>=2.2,<3.3", ], extras_require={ "django-polymorphic": ["django-polymorphic>=2.0"], diff --git a/tox.ini b/tox.ini index dab1676c..f4160e5a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{36,37,38,39}-django{22,30,31}-drf{312,master}, + py{36,37,38,39}-django{22,30,31,32}-drf{312,master}, lint,docs [gh-actions] @@ -15,6 +15,7 @@ DJANGO = 2.2: django22 3.0: django30 3.1: django31 + 3.2: django32 DJANGO_REST_FRAMEWORK = 3.12: drf312 @@ -25,6 +26,7 @@ deps = django22: Django>=2.2,<2.3 django30: Django>=3.0,<3.1 django31: Django>=3.1,<3.2 + django32: Django>=3.2,<3.3 drf312: djangorestframework>=3.12,<3.13 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip -rrequirements/requirements-testing.txt From 450aa5ce49d90e75647d3c88cebd57b30b5d1470 Mon Sep 17 00:00:00 2001 From: Safa Alfulaij Date: Fri, 16 Apr 2021 22:42:35 +0300 Subject: [PATCH 060/252] Allow get_serializer_class to be overwritten for related urls without defining serializer_class fallback (#904) Co-authored-by: Oliver Sauder --- CHANGELOG.md | 4 ++++ example/tests/test_views.py | 7 +++---- example/views.py | 16 +++++++--------- rest_framework_json_api/views.py | 2 +- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b816f47d..c78a1b12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ any parts of the framework not mentioned in the documentation should generally b * Added support for Django 3.2. +### Fixed + +* Allow `get_serializer_class` to be overwritten when using related urls without defining `serializer_class` fallback + ## [4.1.0] - 2021-03-08 ### Added diff --git a/example/tests/test_views.py b/example/tests/test_views.py index 4b494b52..16b51622 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -417,12 +417,11 @@ def test_get_related_serializer_class_many(self): def test_get_serializer_comes_from_included_serializers(self): kwargs = {"pk": self.author.id, "related_field": "type"} view = self._get_view(kwargs) - related_serializers = view.serializer_class.related_serializers - delattr(view.serializer_class, "related_serializers") + related_serializers = view.get_serializer_class().related_serializers + delattr(view.get_serializer_class(), "related_serializers") got = view.get_related_serializer_class() self.assertEqual(got, AuthorTypeSerializer) - - view.serializer_class.related_serializers = related_serializers + view.get_serializer_class().related_serializers = related_serializers def test_get_related_serializer_class_raises_error(self): kwargs = {"pk": self.author.id, "related_field": "unknown"} diff --git a/example/views.py b/example/views.py index 65bcb301..6a1b15a6 100644 --- a/example/views.py +++ b/example/views.py @@ -208,17 +208,15 @@ class NoFiltersetEntryViewSet(EntryViewSet): class AuthorViewSet(ModelViewSet): queryset = Author.objects.all() - serializer_classes = { - "list": AuthorListSerializer, - "retrieve": AuthorDetailSerializer, - } - serializer_class = AuthorSerializer # fallback def get_serializer_class(self): - try: - return self.serializer_classes.get(self.action, self.serializer_class) - except AttributeError: - return self.serializer_class + serializer_classes = { + "list": AuthorListSerializer, + "retrieve": AuthorDetailSerializer, + } + + action = getattr(self, "action", "") + return serializer_classes.get(action, AuthorSerializer) class CommentViewSet(ModelViewSet): diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index 2f061cbb..3df27d1f 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -154,7 +154,7 @@ def get_related_serializer(self, instance, **kwargs): return serializer_class(instance, **kwargs) def get_related_serializer_class(self): - parent_serializer_class = super(RelatedMixin, self).get_serializer_class() + parent_serializer_class = self.get_serializer_class() if "related_field" in self.kwargs: field_name = self.kwargs["related_field"] From 5e2f60d1b68b393b932f895a629e7d4c64553e98 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 17 Apr 2021 00:16:06 +0400 Subject: [PATCH 061/252] Bump django-debug-toolbar from 3.2 to 3.2.1 in /requirements (#915) Bumps [django-debug-toolbar](https://github.com/jazzband/django-debug-toolbar) from 3.2 to 3.2.1. - [Release notes](https://github.com/jazzband/django-debug-toolbar/releases) - [Changelog](https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst) - [Commits](https://github.com/jazzband/django-debug-toolbar/compare/3.2...3.2.1) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/requirements-testing.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index f76c6686..2fcda6fa 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,4 +1,4 @@ -django-debug-toolbar==3.2 +django-debug-toolbar==3.2.1 factory-boy==3.2.0 Faker==6.6.0 pytest==6.2.2 From d5aef4aecea9de0c2d4b09d9b5b3e4fd32072c0a Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 19 Apr 2021 18:49:59 +0200 Subject: [PATCH 062/252] Scheduled biweekly dependency update for week 16 (#927) * Update flake8 from 3.9.0 to 3.9.1 * Update isort from 5.7.0 to 5.8.0 * Update sphinx from 3.5.2 to 3.5.4 * Update sphinx_rtd_theme from 0.5.1 to 0.5.2 * Update twine from 3.3.0 to 3.4.1 * Update faker from 6.6.0 to 8.1.0 * Update pytest from 6.2.2 to 6.2.3 * Update pytest-django from 4.1.0 to 4.2.0 --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-documentation.txt | 4 ++-- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index dced259a..d572c8ea 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ black==20.8b1 -flake8==3.9.0 +flake8==3.9.1 flake8-isort==4.0.0 -isort==5.7.0 +isort==5.8.0 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index e904918e..1d4da97e 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==3.5.2 -sphinx_rtd_theme==0.5.1 +Sphinx==3.5.4 +sphinx_rtd_theme==0.5.2 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index 889f179f..7e9e1799 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==3.3.0 +twine==3.4.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 2fcda6fa..e8c83a71 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,8 +1,8 @@ django-debug-toolbar==3.2.1 factory-boy==3.2.0 -Faker==6.6.0 -pytest==6.2.2 +Faker==8.1.0 +pytest==6.2.3 pytest-cov==2.11.1 -pytest-django==4.1.0 +pytest-django==4.2.0 pytest-factoryboy==2.1.0 snapshottest==0.6.0 From a25e387a86af8914facdc320b6f28cdd486f32af Mon Sep 17 00:00:00 2001 From: Safa Alfulaij Date: Tue, 20 Apr 2021 17:34:03 +0300 Subject: [PATCH 063/252] Support tags in OAS schema (#928) * Suuport openapi tags * Update openapi tests * Update CHANGELOG.md * Update AUTHORS Co-authored-by: Oliver Sauder --- AUTHORS | 1 + CHANGELOG.md | 1 + example/tests/snapshots/snap_test_openapi.py | 25 ++++++++++++++++---- rest_framework_json_api/schemas/openapi.py | 1 + 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/AUTHORS b/AUTHORS index 543274a8..500dc3c6 100644 --- a/AUTHORS +++ b/AUTHORS @@ -31,6 +31,7 @@ Raphael Cohen René Kälin Roberto Barreda Rohith PR +Safa AlFulaij santiavenda Sergey Kolomenkin Stas S. diff --git a/CHANGELOG.md b/CHANGELOG.md index c78a1b12..19b4ac01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Added * Added support for Django 3.2. +* Added support for tags in OAS schema ### Fixed diff --git a/example/tests/snapshots/snap_test_openapi.py b/example/tests/snapshots/snap_test_openapi.py index 7c0b3b98..f89fb442 100644 --- a/example/tests/snapshots/snap_test_openapi.py +++ b/example/tests/snapshots/snap_test_openapi.py @@ -121,7 +121,10 @@ }, "description": "not found" } - } + }, + "tags": [ + "authors" + ] }""" snapshots[ @@ -227,7 +230,10 @@ }, "description": "not found" } - } + }, + "tags": [ + "authors" + ] }""" snapshots[ @@ -411,7 +417,10 @@ }, "description": "[Conflict](https://jsonapi.org/format/#crud-creating-responses-409)" } - } + }, + "tags": [ + "authors" + ] }""" snapshots[ @@ -589,7 +598,10 @@ }, "description": "[Conflict]([Conflict](https://jsonapi.org/format/#crud-updating-responses-409)" } - } + }, + "tags": [ + "authors" + ] }""" snapshots[ @@ -652,5 +664,8 @@ }, "description": "[Resource does not exist](https://jsonapi.org/format/#crud-deleting-responses-404)" } - } + }, + "tags": [ + "authors" + ] }""" diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index b51f366f..77a0d4ed 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -427,6 +427,7 @@ def get_operation(self, path, method): parameters += self.get_pagination_parameters(path, method) parameters += self.get_filter_parameters(path, method) operation["parameters"] = parameters + operation["tags"] = self.get_tags(path, method) # get request and response code schemas if method == "GET": From 36a60a33b10165c97e942b505e75c9708639fd22 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 23 Apr 2021 18:20:20 +0400 Subject: [PATCH 064/252] Preserve field names when no formatting is configured (#909) This way it is possible to have field names which do not follow the python underscore convention. --- CHANGELOG.md | 6 ++ .../django_filters/backends.py | 5 +- rest_framework_json_api/filters.py | 10 +-- rest_framework_json_api/metadata.py | 4 +- rest_framework_json_api/parsers.py | 28 ++---- rest_framework_json_api/utils.py | 85 +++++++++++++++---- rest_framework_json_api/views.py | 4 +- tests/test_parsers.py | 2 +- tests/test_relations.py | 2 +- tests/test_utils.py | 80 ++++++++++++++++- tests/test_views.py | 10 ++- 11 files changed, 179 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19b4ac01..b977ac07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,12 @@ any parts of the framework not mentioned in the documentation should generally b ### Fixed * Allow `get_serializer_class` to be overwritten when using related urls without defining `serializer_class` fallback +* Preserve field names when no formatting is configured. + +### Deprecated + +* Deprecated default `format_type` argument of `rest_framework_json_api.utils.format_value`. Use `rest_framework_json_api.utils.format_field_name` or specify specifc `format_type` instead. +* Deprecated `format_type` argument of `rest_framework_json_api.utils.format_link_segment`. Use `format_value` instead. ## [4.1.0] - 2021-03-08 diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index bb24756c..3906308c 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -4,7 +4,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.settings import api_settings -from rest_framework_json_api.utils import format_value +from rest_framework_json_api.utils import undo_format_field_name class DjangoFilterBackend(DjangoFilterBackend): @@ -119,8 +119,7 @@ def get_filterset_kwargs(self, request, queryset, view): ) # convert jsonapi relationship path to Django ORM's __ notation key = m.groupdict()["assoc"].replace(".", "__") - # undo JSON_API_FORMAT_FIELD_NAMES conversion: - key = format_value(key, "underscore") + key = undo_format_field_name(key) data.setlist(key, val) filter_keys.append(key) del data[qp] diff --git a/rest_framework_json_api/filters.py b/rest_framework_json_api/filters.py index 06f7667e..95056666 100644 --- a/rest_framework_json_api/filters.py +++ b/rest_framework_json_api/filters.py @@ -3,7 +3,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.filters import BaseFilterBackend, OrderingFilter -from rest_framework_json_api.utils import format_value +from rest_framework_json_api.utils import undo_format_field_name class OrderingFilter(OrderingFilter): @@ -15,7 +15,7 @@ class OrderingFilter(OrderingFilter): :py:class:`rest_framework.filters.OrderingFilter` with :py:attr:`~rest_framework.filters.OrderingFilter.ordering_param` = "sort" - Also applies DJA format_value() to convert (e.g. camelcase) to underscore. + Also supports undo of field name formatting (See JSON_API_FORMAT_FIELD_NAMES in docs/usage.md) """ @@ -38,7 +38,7 @@ def remove_invalid_fields(self, queryset, fields, view, request): bad_terms = [ term for term in fields - if format_value(term.replace(".", "__").lstrip("-"), "underscore") + if undo_format_field_name(term.replace(".", "__").lstrip("-")) not in valid_fields ] if bad_terms: @@ -56,10 +56,10 @@ def remove_invalid_fields(self, queryset, fields, view, request): item_rewritten = item.replace(".", "__") if item_rewritten.startswith("-"): underscore_fields.append( - "-" + format_value(item_rewritten.lstrip("-"), "underscore") + "-" + undo_format_field_name(item_rewritten.lstrip("-")) ) else: - underscore_fields.append(format_value(item_rewritten, "underscore")) + underscore_fields.append(undo_format_field_name(item_rewritten)) return super(OrderingFilter, self).remove_invalid_fields( queryset, underscore_fields, view, request diff --git a/rest_framework_json_api/metadata.py b/rest_framework_json_api/metadata.py index a48af532..6b88e578 100644 --- a/rest_framework_json_api/metadata.py +++ b/rest_framework_json_api/metadata.py @@ -7,7 +7,7 @@ from rest_framework.settings import api_settings from rest_framework.utils.field_mapping import ClassLookupDict -from rest_framework_json_api.utils import format_value, get_related_resource_type +from rest_framework_json_api.utils import format_field_name, get_related_resource_type class JSONAPIMetadata(SimpleMetadata): @@ -93,7 +93,7 @@ def get_serializer_info(self, serializer): return OrderedDict( [ - (format_value(field_name), self.get_field_info(field)) + (format_field_name(field_name), self.get_field_info(field)) for field_name, field in serializer.fields.items() ] ) diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index e3315334..433bcb32 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -4,8 +4,8 @@ from rest_framework import parsers from rest_framework.exceptions import ParseError -from . import exceptions, renderers, serializers, utils -from .settings import json_api_settings +from rest_framework_json_api import exceptions, renderers, serializers +from rest_framework_json_api.utils import get_resource_name, undo_format_field_names class JSONParser(parsers.JSONParser): @@ -37,27 +37,13 @@ class JSONParser(parsers.JSONParser): @staticmethod def parse_attributes(data): - attributes = data.get("attributes") - uses_format_translation = json_api_settings.FORMAT_FIELD_NAMES - - if not attributes: - return dict() - elif uses_format_translation: - # convert back to python/rest_framework's preferred underscore format - return utils.format_field_names(attributes, "underscore") - else: - return attributes + attributes = data.get("attributes") or dict() + return undo_format_field_names(attributes) @staticmethod def parse_relationships(data): - uses_format_translation = json_api_settings.FORMAT_FIELD_NAMES - relationships = data.get("relationships") - - if not relationships: - relationships = dict() - elif uses_format_translation: - # convert back to python/rest_framework's preferred underscore format - relationships = utils.format_field_names(relationships, "underscore") + relationships = data.get("relationships") or dict() + relationships = undo_format_field_names(relationships) # Parse the relationships parsed_relationships = dict() @@ -130,7 +116,7 @@ def parse(self, stream, media_type=None, parser_context=None): # Check for inconsistencies if request.method in ("PUT", "POST", "PATCH"): - resource_name = utils.get_resource_name( + resource_name = get_resource_name( parser_context, expand_polymorphic_types=True ) if isinstance(resource_name, str): diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index c87fa000..f8c6833c 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -1,6 +1,7 @@ import copy import inspect import operator +import warnings from collections import OrderedDict import inflection @@ -118,8 +119,76 @@ def format_field_names(obj, format_type=None): return obj +def undo_format_field_names(obj): + """ + Takes a dict and undo format field names to underscore which is the Python convention + but only in case `JSON_API_FORMAT_FIELD_NAMES` is actually configured. + """ + if json_api_settings.FORMAT_FIELD_NAMES: + return format_field_names(obj, "underscore") + + return obj + + +def format_field_name(field_name): + """ + Takes a field name and returns it with formatted keys as set in + `JSON_API_FORMAT_FIELD_NAMES` + """ + return format_value(field_name, json_api_settings.FORMAT_FIELD_NAMES) + + +def undo_format_field_name(field_name): + """ + Takes a string and undos format field name to underscore which is the Python convention + but only in case `JSON_API_FORMAT_FIELD_NAMES` is actually configured. + """ + if json_api_settings.FORMAT_FIELD_NAMES: + return format_value(field_name, "underscore") + + return field_name + + +def format_link_segment(value, format_type=None): + """ + Takes a string value and returns it with formatted keys as set in `format_type` + or `JSON_API_FORMAT_RELATED_LINKS`. + + :format_type: Either 'dasherize', 'camelize', 'capitalize' or 'underscore' + """ + if format_type is None: + format_type = json_api_settings.FORMAT_RELATED_LINKS + else: + warnings.warn( + DeprecationWarning( + "Using `format_type` argument is deprecated." + "Use `format_value` instead." + ) + ) + + return format_value(value, format_type) + + +def undo_format_link_segment(value): + """ + Takes a link segment and undos format link segment to underscore which is the Python convention + but only in case `JSON_API_FORMAT_RELATED_LINKS` is actually configured. + """ + + if json_api_settings.FORMAT_RELATED_LINKS: + return format_value(value, "underscore") + + return value + + def format_value(value, format_type=None): if format_type is None: + warnings.warn( + DeprecationWarning( + "Using `format_value` without passing on `format_type` argument is deprecated." + "Use `format_field_name` instead." + ) + ) format_type = json_api_settings.FORMAT_FIELD_NAMES if format_type == "dasherize": # inflection can't dasherize camelCase @@ -142,25 +211,11 @@ def format_resource_type(value, format_type=None, pluralize=None): pluralize = json_api_settings.PLURALIZE_TYPES if format_type: - # format_type will never be None here so we can use format_value value = format_value(value, format_type) return inflection.pluralize(value) if pluralize else value -def format_link_segment(value, format_type=None): - """ - Takes a string value and returns it with formatted keys as set in `format_type` - or `JSON_API_FORMAT_RELATED_LINKS`. - - :format_type: Either 'dasherize', 'camelize', 'capitalize' or 'underscore' - """ - if format_type is None: - format_type = json_api_settings.FORMAT_RELATED_LINKS - - return format_value(value, format_type) - - def get_related_resource_type(relation): from rest_framework_json_api.serializers import PolymorphicModelSerializer @@ -348,7 +403,7 @@ def format_drf_errors(response, context, exc): # handle all errors thrown from serializers else: for field, error in response.data.items(): - field = format_value(field) + field = format_field_name(field) pointer = "/data/attributes/{}".format(field) if isinstance(exc, Http404) and isinstance(error, str): # 404 errors don't have a pointer diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index 3df27d1f..de1c2f25 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -25,9 +25,9 @@ from rest_framework_json_api.utils import ( Hyperlink, OrderedDict, - format_value, get_included_resources, get_resource_type_from_instance, + undo_format_link_segment, ) @@ -187,7 +187,7 @@ def get_related_serializer_class(self): def get_related_field_name(self): field_name = self.kwargs["related_field"] - return format_value(field_name, "underscore") + return undo_format_link_segment(field_name) def get_related_instance(self): parent_obj = self.get_object() diff --git a/tests/test_parsers.py b/tests/test_parsers.py index 907d1eb6..03770970 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -29,7 +29,7 @@ def parser_context(self, rf): @pytest.mark.parametrize( "format_field_names", [ - None, + False, "dasherize", "camelize", "capitalize", diff --git a/tests/test_relations.py b/tests/test_relations.py index 1baafdd0..630dd9c8 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -233,7 +233,7 @@ def test_to_representation(self, model, field): @pytest.mark.parametrize( "format_related_links", [ - None, + False, "dasherize", "camelize", "capitalize", diff --git a/tests/test_utils.py b/tests/test_utils.py index 40f9d391..00bf0836 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -7,6 +7,7 @@ from rest_framework_json_api import serializers from rest_framework_json_api.utils import ( + format_field_name, format_field_names, format_link_segment, format_resource_type, @@ -14,6 +15,9 @@ get_included_serializers, get_related_resource_type, get_resource_name, + undo_format_field_name, + undo_format_field_names, + undo_format_link_segment, ) from tests.models import ( BasicModel, @@ -176,6 +180,7 @@ def test_get_resource_name_with_errors(status_code): @pytest.mark.parametrize( "format_type,output", [ + (False, {"full_name": {"last-name": "a", "first-name": "b"}}), ("camelize", {"fullName": {"last-name": "a", "first-name": "b"}}), ("capitalize", {"FullName": {"last-name": "a", "first-name": "b"}}), ("dasherize", {"full-name": {"last-name": "a", "first-name": "b"}}), @@ -192,22 +197,86 @@ def test_format_field_names(settings, format_type, output): @pytest.mark.parametrize( "format_type,output", [ - (None, "first_Name"), + (False, {"fullName": "Test Name"}), + ("camelize", {"full_name": "Test Name"}), + ], +) +def test_undo_format_field_names(settings, format_type, output): + settings.JSON_API_FORMAT_FIELD_NAMES = format_type + + value = {"fullName": "Test Name"} + assert undo_format_field_names(value) == output + + +@pytest.mark.parametrize( + "format_type,output", + [ + (False, "full_name"), + ("camelize", "fullName"), + ("capitalize", "FullName"), + ("dasherize", "full-name"), + ("underscore", "full_name"), + ], +) +def test_format_field_name(settings, format_type, output): + settings.JSON_API_FORMAT_FIELD_NAMES = format_type + + field_name = "full_name" + assert format_field_name(field_name) == output + + +@pytest.mark.parametrize( + "format_type,output", + [ + (False, "fullName"), + ("camelize", "full_name"), + ], +) +def test_undo_format_field_name(settings, format_type, output): + settings.JSON_API_FORMAT_FIELD_NAMES = format_type + + field_name = "fullName" + assert undo_format_field_name(field_name) == output + + +@pytest.mark.parametrize( + "format_type,output", + [ + (False, "first_Name"), ("camelize", "firstName"), ("capitalize", "FirstName"), ("dasherize", "first-name"), ("underscore", "first_name"), ], ) -def test_format_field_segment(settings, format_type, output): +def test_format_link_segment(settings, format_type, output): settings.JSON_API_FORMAT_RELATED_LINKS = format_type assert format_link_segment("first_Name") == output +def test_format_link_segment_deprecates_format_type_argument(): + with pytest.deprecated_call(): + assert "first-name" == format_link_segment("first_name", "dasherize") + + +@pytest.mark.parametrize( + "format_links,output", + [ + (False, "fullName"), + ("camelize", "full_name"), + ], +) +def test_undo_format_link_segment(settings, format_links, output): + settings.JSON_API_FORMAT_RELATED_LINKS = format_links + + link_segment = "fullName" + assert undo_format_link_segment(link_segment) == output + + @pytest.mark.parametrize( "format_type,output", [ - (None, "first_name"), + (False, "first_name"), ("camelize", "firstName"), ("capitalize", "FirstName"), ("dasherize", "first-name"), @@ -218,6 +287,11 @@ def test_format_value(settings, format_type, output): assert format_value("first_name", format_type) == output +def test_format_value_deprecates_default_format_type_argument(): + with pytest.deprecated_call(): + assert "first_name" == format_value("first_name") + + @pytest.mark.parametrize( "resource_type,pluralize,output", [ diff --git a/tests/test_views.py b/tests/test_views.py index e419ea0f..f8967982 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -8,7 +8,7 @@ from rest_framework_json_api.parsers import JSONParser from rest_framework_json_api.relations import ResourceRelatedField from rest_framework_json_api.renderers import JSONRenderer -from rest_framework_json_api.utils import format_value +from rest_framework_json_api.utils import format_link_segment from rest_framework_json_api.views import ModelViewSet from tests.models import BasicModel @@ -17,7 +17,7 @@ class TestModelViewSet: @pytest.mark.parametrize( "format_links", [ - None, + False, "dasherize", "camelize", "capitalize", @@ -25,8 +25,10 @@ class TestModelViewSet: ], ) def test_get_related_field_name_handles_formatted_link_segments( - self, format_links, rf + self, settings, format_links, rf ): + settings.JSON_API_FORMAT_RELATED_LINKS = format_links + # use field name which actually gets formatted related_model_field_name = "related_field_model" @@ -43,7 +45,7 @@ class Meta: class RelatedFieldNameView(ModelViewSet): serializer_class = RelatedFieldNameSerializer - url_segment = format_value(related_model_field_name, format_links) + url_segment = format_link_segment(related_model_field_name) request = rf.get(f"/basic_models/1/{url_segment}") From 7d2970a4e8652952801c4af070fd5faae37f24d6 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 23 Apr 2021 18:27:27 +0400 Subject: [PATCH 065/252] Avoided error when using include query parameter on related urls (#914) This is a regression from version 4.1.0. Co-authored-by: Alan Crosswell --- CHANGELOG.md | 4 ++++ example/tests/integration/test_browsable_api.py | 11 +++++++++++ example/tests/test_views.py | 11 +++++++++-- rest_framework_json_api/renderers.py | 5 ++++- rest_framework_json_api/serializers.py | 5 ++++- 5 files changed, 32 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b977ac07..ab9ec4f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,10 @@ any parts of the framework not mentioned in the documentation should generally b * Deprecated default `format_type` argument of `rest_framework_json_api.utils.format_value`. Use `rest_framework_json_api.utils.format_field_name` or specify specifc `format_type` instead. * Deprecated `format_type` argument of `rest_framework_json_api.utils.format_link_segment`. Use `format_value` instead. +### Fixed + +* Avoided error when using `include` query parameter on related urls (a regression since 4.1.0) + ## [4.1.0] - 2021-03-08 ### Added diff --git a/example/tests/integration/test_browsable_api.py b/example/tests/integration/test_browsable_api.py index 9156eb92..14ebe3fb 100644 --- a/example/tests/integration/test_browsable_api.py +++ b/example/tests/integration/test_browsable_api.py @@ -18,6 +18,17 @@ def test_browsable_api_with_included_serializers(single_entry, client): ) +def test_browsable_api_on_related_url(author, client): + url = reverse("author-related", kwargs={"pk": author.pk, "related_field": "bio"}) + response = client.get(url, data={"format": "api"}) + content = str(response.content) + assert response.status_code == 200 + assert re.search(r"JSON:API includes", content) + assert re.search( + r']* value="metadata"', content + ) + + def test_browsable_api_with_no_included_serializers(client): response = client.get(reverse("projecttype-list", kwargs={"format": "api"})) content = str(response.content) diff --git a/example/tests/test_views.py b/example/tests/test_views.py index 16b51622..71e2e2db 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -432,7 +432,7 @@ def test_retrieve_related_single_reverse_lookup(self): url = reverse( "author-related", kwargs={"pk": self.author.pk, "related_field": "bio"} ) - resp = self.client.get(url) + resp = self.client.get(url, data={"include": "metadata"}) expected = { "data": { "type": "authorBios", @@ -447,7 +447,14 @@ def test_retrieve_related_single_reverse_lookup(self): }, }, "attributes": {"body": str(self.author.bio.body)}, - } + }, + "included": [ + { + "attributes": {"body": str(self.author.bio.metadata.body)}, + "id": str(self.author.bio.metadata.id), + "type": "authorBioMetadata", + } + ], } self.assertEqual(resp.status_code, 200) self.assertEqual(resp.json(), expected) diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index e84c562b..b50c2b1a 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -708,7 +708,10 @@ def _get_included_serializers(cls, serializer, prefix="", already_seen=None): def get_includes_form(self, view): try: - serializer_class = view.get_serializer_class() + if "related_field" in view.kwargs: + serializer_class = view.get_related_serializer_class() + else: + serializer_class = view.get_serializer_class() except AttributeError: return diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 56949818..a73a5d47 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -140,7 +140,10 @@ def validate_path(serializer_class, field_path, path): included_resources = get_included_resources(request) for included_field_name in included_resources: included_field_path = included_field_name.split(".") - this_serializer_class = view.get_serializer_class() + if "related_field" in view.kwargs: + this_serializer_class = view.get_related_serializer_class() + else: + this_serializer_class = view.get_serializer_class() # lets validate the current path validate_path( this_serializer_class, included_field_path, included_field_name From a5df955794ad344ab599550a0cd433d5eefbcd75 Mon Sep 17 00:00:00 2001 From: Kevin Partington Date: Thu, 29 Apr 2021 13:53:38 -0500 Subject: [PATCH 066/252] Properly support formatting of link segments in related urls (#897) --- CHANGELOG.md | 7 +++++++ docs/usage.md | 9 +++++++-- example/tests/test_views.py | 25 ++++++++++++++++++++++++- example/urls_test.py | 6 +++--- rest_framework_json_api/views.py | 4 +++- 5 files changed, 44 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab9ec4f8..32799366 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,13 @@ any parts of the framework not mentioned in the documentation should generally b * Allow `get_serializer_class` to be overwritten when using related urls without defining `serializer_class` fallback * Preserve field names when no formatting is configured. +* Properly support `JSON_API_FORMAT_RELATED_LINKS` setting in related urls. In case you want to use `dasherize` for formatting links make sure that your url pattern matches dashes as well like following example: + ``` + url(r'^orders/(?P[^/.]+)/(?P[-\w]+)/$', + OrderViewSet.as_view({'get': 'retrieve_related'}), + name='order-related'), + ``` + ### Deprecated diff --git a/docs/usage.md b/docs/usage.md index 81e52989..79a2d918 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -515,6 +515,11 @@ For example, with a serializer property `created_by` and with `'dasherize'` form The relationship name is formatted by the `JSON_API_FORMAT_FIELD_NAMES` setting, but the URL segments are formatted by the `JSON_API_FORMAT_RELATED_LINKS` setting. +
+ Note: + When using this setting make sure that your url pattern matches the formatted url segement. +
+ ### Related fields #### ResourceRelatedField @@ -702,7 +707,7 @@ All you need is just add to `urls.py`: url(r'^orders/(?P[^/.]+)/$', OrderViewSet.as_view({'get': 'retrieve'}), name='order-detail'), -url(r'^orders/(?P[^/.]+)/(?P\w+)/$', +url(r'^orders/(?P[^/.]+)/(?P[-\w]+)/$', OrderViewSet.as_view({'get': 'retrieve_related'}), name='order-related'), ``` @@ -775,7 +780,7 @@ The urlconf would need to contain a route like the following: ```python url( - regex=r'^orders/(?P[^/.]+)/relationships/(?P[^/.]+)$', + regex=r'^orders/(?P[^/.]+)/relationships/(?P[-/w]+)$', view=OrderRelationshipView.as_view(), name='order-relationships' ) diff --git a/example/tests/test_views.py b/example/tests/test_views.py index 71e2e2db..d976ed56 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -1,7 +1,7 @@ import json from datetime import datetime -from django.test import RequestFactory +from django.test import RequestFactory, override_settings from django.utils import timezone from rest_framework import status from rest_framework.decorators import action @@ -92,6 +92,18 @@ def test_get_blog_relationship_entry_set(self): assert response.data == expected_data + @override_settings(JSON_API_FORMAT_RELATED_LINKS="dasherize") + def test_get_blog_relationship_entry_set_with_formatted_link(self): + response = self.client.get( + "/blogs/{}/relationships/entry-set".format(self.blog.id) + ) + expected_data = [ + {"type": format_resource_type("Entry"), "id": str(self.first_entry.id)}, + {"type": format_resource_type("Entry"), "id": str(self.second_entry.id)}, + ] + + assert response.data == expected_data + def test_put_entry_relationship_blog_returns_405(self): url = "/entries/{}/relationships/blog".format(self.first_entry.id) response = self.client.put(url, data={}) @@ -507,6 +519,17 @@ def test_retrieve_related_None(self): self.assertEqual(resp.status_code, 200) self.assertEqual(resp.json(), {"data": None}) + @override_settings(JSON_API_FORMAT_RELATED_LINKS="dasherize") + def test_retrieve_related_with_formatted_link(self): + first_entry = EntryFactory(authors=(self.author,)) + + kwargs = {"pk": self.author.pk, "related_field": "first-entry"} + url = reverse("author-related", kwargs=kwargs) + resp = self.client.get(url) + + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()["data"]["id"], str(first_entry.id)) + class TestValidationErrorResponses(TestBase): def test_if_returns_error_on_empty_post(self): diff --git a/example/urls_test.py b/example/urls_test.py index 7e875936..0219ac51 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -78,17 +78,17 @@ name="entry-featured", ), re_path( - r"^authors/(?P[^/.]+)/(?P\w+)/$", + r"^authors/(?P[^/.]+)/(?P[-\w]+)/$", AuthorViewSet.as_view({"get": "retrieve_related"}), name="author-related", ), re_path( - r"^entries/(?P[^/.]+)/relationships/(?P\w+)$", + r"^entries/(?P[^/.]+)/relationships/(?P[\-\w]+)$", EntryRelationshipView.as_view(), name="entry-relationships", ), re_path( - r"^blogs/(?P[^/.]+)/relationships/(?P\w+)$", + r"^blogs/(?P[^/.]+)/relationships/(?P[^/.]+)$", BlogRelationshipView.as_view(), name="blog-relationships", ), diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index de1c2f25..84ec509e 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -157,7 +157,7 @@ def get_related_serializer_class(self): parent_serializer_class = self.get_serializer_class() if "related_field" in self.kwargs: - field_name = self.kwargs["related_field"] + field_name = self.get_related_field_name() # Try get the class from related_serializers if hasattr(parent_serializer_class, "related_serializers"): @@ -402,6 +402,8 @@ def get_related_instance(self): def get_related_field_name(self): field_name = self.kwargs["related_field"] + field_name = undo_format_link_segment(field_name) + if field_name in self.field_name_mapping: return self.field_name_mapping[field_name] return field_name From fbb4b033a483b7edb62de3377c83a046a55f69b2 Mon Sep 17 00:00:00 2001 From: Safa Alfulaij Date: Sun, 2 May 2021 21:58:42 +0300 Subject: [PATCH 067/252] Prefetch default included resources (#900) Co-authored-by: Oliver Sauder --- CHANGELOG.md | 1 + example/tests/test_performance.py | 18 ++++++++++++++++-- rest_framework_json_api/serializers.py | 2 +- rest_framework_json_api/views.py | 8 ++++++-- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32799366..c62b5383 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ any parts of the framework not mentioned in the documentation should generally b OrderViewSet.as_view({'get': 'retrieve_related'}), name='order-related'), ``` +* Ensure default `included_resources` are considered when calculating prefetches. ### Deprecated diff --git a/example/tests/test_performance.py b/example/tests/test_performance.py index e42afada..cae11ed4 100644 --- a/example/tests/test_performance.py +++ b/example/tests/test_performance.py @@ -1,7 +1,7 @@ from django.utils import timezone from rest_framework.test import APITestCase -from example.factories import CommentFactory +from example.factories import CommentFactory, EntryFactory from example.models import Author, Blog, Comment, Entry @@ -36,6 +36,7 @@ def setUp(self): ) self.comment = Comment.objects.create(entry=self.first_entry) CommentFactory.create_batch(50) + EntryFactory.create_batch(50) def test_query_count_no_includes(self): """We expect a simple list view to issue only two queries. @@ -49,7 +50,7 @@ def test_query_count_no_includes(self): self.assertEqual(len(response.data["results"]), 25) def test_query_count_include_author(self): - """We expect a list view with an include have three queries: + """We expect a list view with an include have five queries: 1. Primary resource COUNT query 2. Primary resource SELECT @@ -70,3 +71,16 @@ def test_query_select_related_entry(self): with self.assertNumQueries(2): response = self.client.get("/comments?include=writer&page[size]=25") self.assertEqual(len(response.data["results"]), 25) + + def test_query_prefetch_uses_included_resources(self): + """We expect a list view with `included_resources` to have three queries: + + 1. Primary resource COUNT query + 2. Primary resource SELECT + 3. Comments prefetched + """ + with self.assertNumQueries(3): + response = self.client.get( + "/entries?fields[entries]=comments&page[size]=25" + ) + self.assertEqual(len(response.data["results"]), 25) diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index a73a5d47..5ca773d0 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -137,7 +137,7 @@ def validate_path(serializer_class, field_path, path): validate_path(this_included_serializer, new_included_field_path, path) if request and view: - included_resources = get_included_resources(request) + included_resources = get_included_resources(request, self) for included_field_name in included_resources: included_field_path = included_field_name.split(".") if "related_field" in view.kwargs: diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index 84ec509e..8369cec9 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -63,7 +63,9 @@ def get_prefetch_related(self, include): def get_queryset(self, *args, **kwargs): qs = super(PreloadIncludesMixin, self).get_queryset(*args, **kwargs) - included_resources = get_included_resources(self.request) + included_resources = get_included_resources( + self.request, self.get_serializer_class() + ) for included in included_resources + ["__all__"]: select_related = self.get_select_related(included) @@ -82,7 +84,9 @@ def get_queryset(self, *args, **kwargs): """ This mixin adds automatic prefetching for OneToOne and ManyToMany fields. """ qs = super(AutoPrefetchMixin, self).get_queryset(*args, **kwargs) - included_resources = get_included_resources(self.request) + included_resources = get_included_resources( + self.request, self.get_serializer_class() + ) for included in included_resources + ["__all__"]: # If include was not defined, trying to resolve it automatically From 16a3d6b02f15b84151f73826ea9071acd3abd708 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 4 May 2021 20:21:22 +0200 Subject: [PATCH 068/252] Scheduled biweekly dependency update for week 18 (#939) * Update black from 20.8b1 to 21.4b2 * Update faker from 8.1.0 to 8.1.2 Co-authored-by: Oliver Sauder --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-testing.txt | 2 +- rest_framework_json_api/utils.py | 2 +- rest_framework_json_api/views.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index d572c8ea..8395b9e1 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==20.8b1 +black==21.4b2 flake8==3.9.1 flake8-isort==4.0.0 isort==5.8.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index e8c83a71..7887a137 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,6 +1,6 @@ django-debug-toolbar==3.2.1 factory-boy==3.2.0 -Faker==8.1.0 +Faker==8.1.2 pytest==6.2.3 pytest-cov==2.11.1 pytest-django==4.2.0 diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index f8c6833c..ac31979a 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -326,7 +326,7 @@ def get_resource_type_from_serializer(serializer): def get_included_resources(request, serializer=None): - """ Build a list of included resources. """ + """Build a list of included resources.""" include_resources_param = request.query_params.get("include") if request else None if include_resources_param: return include_resources_param.split(",") diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index 8369cec9..e457009b 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -81,7 +81,7 @@ def get_queryset(self, *args, **kwargs): class AutoPrefetchMixin(object): def get_queryset(self, *args, **kwargs): - """ This mixin adds automatic prefetching for OneToOne and ManyToMany fields. """ + """This mixin adds automatic prefetching for OneToOne and ManyToMany fields.""" qs = super(AutoPrefetchMixin, self).get_queryset(*args, **kwargs) included_resources = get_included_resources( From 0fcede4890abf833cd3ca948aaa0bf103cba2745 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 12 May 2021 22:45:11 +0400 Subject: [PATCH 069/252] Release 4.2.0 (#937) --- CHANGELOG.md | 9 +++------ rest_framework_json_api/__init__.py | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c62b5383..e7f43647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [4.2.0] - 2021-05-03 ### Added @@ -26,16 +26,13 @@ any parts of the framework not mentioned in the documentation should generally b name='order-related'), ``` * Ensure default `included_resources` are considered when calculating prefetches. - +* Avoided error when using `include` query parameter on related urls (a regression since 4.1.0) ### Deprecated * Deprecated default `format_type` argument of `rest_framework_json_api.utils.format_value`. Use `rest_framework_json_api.utils.format_field_name` or specify specifc `format_type` instead. -* Deprecated `format_type` argument of `rest_framework_json_api.utils.format_link_segment`. Use `format_value` instead. +* Deprecated `format_type` argument of `rest_framework_json_api.utils.format_link_segment`. Use `rest_framework_json_api.utils.format_value` instead. -### Fixed - -* Avoided error when using `include` query parameter on related urls (a regression since 4.1.0) ## [4.1.0] - 2021-03-08 diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index 3ce910ef..d166001f 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- __title__ = "djangorestframework-jsonapi" -__version__ = "4.1.0" +__version__ = "4.2.0" __author__ = "" __license__ = "BSD" __copyright__ = "" From 71065026aa5ea58359f38c6195041e19a7d6aeea Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 12 May 2021 22:48:18 +0400 Subject: [PATCH 070/252] Set correct date of release 4.2.0 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7f43647..d7155df3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [4.2.0] - 2021-05-03 +## [4.2.0] - 2021-05-12 ### Added From af1c804340f97b3e6ca9f147d1eba78295622e28 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 17 May 2021 21:11:59 +0400 Subject: [PATCH 071/252] Use correct security email address (#934) Not sure how this happened but the DRF security email address was used instead of our newly created one. Co-authored-by: Alan Crosswell --- SECURITY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index ef73aad3..78781946 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,6 +4,6 @@ If you believe you've found something in Django REST Framework JSON API which has security implications, please **do not raise the issue in a public forum**. -Send a description of the issue via email to [rest-framework-security@googlegroups.com][security-mail]. The project maintainers will then work with you to resolve any issues where required, prior to any public disclosure. +Send a description of the issue via email to [rest-framework-jsonapi-security@googlegroups.com][security-mail]. The project maintainers will then work with you to resolve any issues where required, prior to any public disclosure. -[security-mail]: mailto:rest-framework-security@googlegroups.com +[security-mail]: mailto:rest-framework-jsonapi-security@googlegroups.com From 2eea7725e85d908740671352951cbf854f28bc3f Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 17 May 2021 21:15:10 +0400 Subject: [PATCH 072/252] Added initial issue templates (#941) Co-authored-by: Alan Crosswell --- .github/ISSUE_TEMPLATE/bug_report.md | 15 +++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 17 +++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..7cb238e2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,15 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +## Description of the Bug Report + +## Checklist + +- [ ] Certain that this is a bug (if unsure or you have a question use [discussions](https://github.com/django-json-api/django-rest-framework-json-api/discussions) instead) +- [ ] Code snippet or unit test added to reproduce bug diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..99156b88 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Please only raise a feature request if you've been advised to do so after discussion. + Thanks! +title: '' +labels: enhancement +assignees: '' + +--- + +## Description of feature request + +## Checklist + +- [ ] Raised initially as discussion #... +- [ ] This cannot be dealt with as a third party library. (We prefer new functionality to be in the form of third party libraries where feasible.) +- [ ] I have reduced the issue to the simplest possible case. From 858119e0949bb813077c15a906b24dd073f09147 Mon Sep 17 00:00:00 2001 From: Jonas Metzener Date: Fri, 28 May 2021 21:01:41 +0200 Subject: [PATCH 073/252] Use PreloadIncludesMixin for ReadOnlyModelViewSet (#946) --- AUTHORS | 1 + CHANGELOG.md | 6 ++++ docs/usage.md | 2 +- example/migrations/0009_labresults_author.py | 25 ++++++++++++++ example/models.py | 7 ++++ example/serializers.py | 4 ++- example/tests/test_performance.py | 34 +++++++++++++++++++- example/urls.py | 2 ++ example/urls_test.py | 2 ++ example/views.py | 27 ++++++++++++++-- rest_framework_json_api/views.py | 2 +- 11 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 example/migrations/0009_labresults_author.py diff --git a/AUTHORS b/AUTHORS index 500dc3c6..8d2af17d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -14,6 +14,7 @@ Jamie Bliss Jason Housley Jeppe Fihl-Pearson Jerel Unruh +Jonas Metzener Jonathan Senecal Joseba Mendivil Kevin Partington diff --git a/CHANGELOG.md b/CHANGELOG.md index d7155df3..508da91f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## Unreleased + +### Fixed + +* Include `PreloadIncludesMixin` in `ReadOnlyModelViewSet` to enable the usage of [performance utilities](https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#performance-improvements) on read only views (regression since 2.8.0) + ## [4.2.0] - 2021-05-12 ### Added diff --git a/docs/usage.md b/docs/usage.md index 79a2d918..1e45976b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -942,7 +942,7 @@ class QuestSerializer(serializers.ModelSerializer): Be aware that using included resources without any form of prefetching **WILL HURT PERFORMANCE** as it will introduce m\*(n+1) queries. -A viewset helper was therefore designed to automatically preload data when possible. Such is automatically available when subclassing `ModelViewSet`. +A viewset helper was therefore designed to automatically preload data when possible. Such is automatically available when subclassing `ModelViewSet` or `ReadOnlyModelViewSet`. It also allows to define custom `select_related` and `prefetch_related` for each requested `include` when needed in special cases: diff --git a/example/migrations/0009_labresults_author.py b/example/migrations/0009_labresults_author.py new file mode 100644 index 00000000..6365d01c --- /dev/null +++ b/example/migrations/0009_labresults_author.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.3 on 2021-05-26 03:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("example", "0008_labresults"), + ] + + operations = [ + migrations.AddField( + model_name="labresults", + name="author", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="lab_results", + to="example.author", + ), + ), + ] diff --git a/example/models.py b/example/models.py index 47537b57..c3785c27 100644 --- a/example/models.py +++ b/example/models.py @@ -161,6 +161,13 @@ class LabResults(models.Model): ) date = models.DateField() measurements = models.TextField() + author = models.ForeignKey( + Author, + null=True, + blank=True, + on_delete=models.CASCADE, + related_name="lab_results", + ) class Company(models.Model): diff --git a/example/serializers.py b/example/serializers.py index 4d80c87c..64444e9e 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -356,9 +356,11 @@ class Meta: class LabResultsSerializer(serializers.ModelSerializer): + included_serializers = {"author": AuthorSerializer} + class Meta: model = LabResults - fields = ("date", "measurements") + fields = ("date", "measurements", "author") class ProjectSerializer(serializers.PolymorphicModelSerializer): diff --git a/example/tests/test_performance.py b/example/tests/test_performance.py index cae11ed4..ec4a81b2 100644 --- a/example/tests/test_performance.py +++ b/example/tests/test_performance.py @@ -1,8 +1,11 @@ +from datetime import date, timedelta +from random import randint + from django.utils import timezone from rest_framework.test import APITestCase from example.factories import CommentFactory, EntryFactory -from example.models import Author, Blog, Comment, Entry +from example.models import Author, Blog, Comment, Entry, LabResults, ResearchProject class PerformanceTestCase(APITestCase): @@ -84,3 +87,32 @@ def test_query_prefetch_uses_included_resources(self): "/entries?fields[entries]=comments&page[size]=25" ) self.assertEqual(len(response.data["results"]), 25) + + def test_query_prefetch_read_only(self): + """We expect a read only list view with an include have five queries: + + 1. Primary resource COUNT query + 2. Primary resource SELECT + 3. Authors prefetched + 4. Author types prefetched + 5. Entries prefetched + """ + project = ResearchProject.objects.create( + topic="Mars Mission", supervisor="Elon Musk" + ) + + LabResults.objects.bulk_create( + [ + LabResults( + research_project=project, + date=date.today() + timedelta(days=i), + measurements=randint(0, 10000), + author=self.author, + ) + for i in range(20) + ] + ) + + with self.assertNumQueries(5): + response = self.client.get("/lab-results?include=author&page[size]=25") + self.assertEqual(len(response.data["results"]), 20) diff --git a/example/urls.py b/example/urls.py index 800b9f79..867f1bd7 100644 --- a/example/urls.py +++ b/example/urls.py @@ -17,6 +17,7 @@ CompanyViewset, EntryRelationshipView, EntryViewSet, + LabResultViewSet, NonPaginatedEntryViewSet, ProjectTypeViewset, ProjectViewset, @@ -32,6 +33,7 @@ router.register(r"companies", CompanyViewset) router.register(r"projects", ProjectViewset) router.register(r"project-types", ProjectTypeViewset) +router.register(r"lab-results", LabResultViewSet) urlpatterns = [ url(r"^", include(router.urls)), diff --git a/example/urls_test.py b/example/urls_test.py index 0219ac51..5ee06a23 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -15,6 +15,7 @@ EntryRelationshipView, EntryViewSet, FiltersetEntryViewSet, + LabResultViewSet, NoFiltersetEntryViewSet, NonPaginatedEntryViewSet, ProjectTypeViewset, @@ -36,6 +37,7 @@ router.register(r"companies", CompanyViewset) router.register(r"projects", ProjectViewset) router.register(r"project-types", ProjectTypeViewset) +router.register(r"lab-results", LabResultViewSet) # for the old tests router.register(r"identities", Identity) diff --git a/example/views.py b/example/views.py index 6a1b15a6..0b35d4e4 100644 --- a/example/views.py +++ b/example/views.py @@ -14,9 +14,22 @@ ) from rest_framework_json_api.pagination import JsonApiPageNumberPagination from rest_framework_json_api.utils import format_drf_errors -from rest_framework_json_api.views import ModelViewSet, RelationshipView +from rest_framework_json_api.views import ( + ModelViewSet, + ReadOnlyModelViewSet, + RelationshipView, +) -from example.models import Author, Blog, Comment, Company, Entry, Project, ProjectType +from example.models import ( + Author, + Blog, + Comment, + Company, + Entry, + LabResults, + Project, + ProjectType, +) from example.serializers import ( AuthorDetailSerializer, AuthorListSerializer, @@ -27,6 +40,7 @@ CompanySerializer, EntryDRFSerializers, EntrySerializer, + LabResultsSerializer, ProjectSerializer, ProjectTypeSerializer, ) @@ -266,3 +280,12 @@ class CommentRelationshipView(RelationshipView): class AuthorRelationshipView(RelationshipView): queryset = Author.objects.all() self_link_view_name = "author-relationships" + + +class LabResultViewSet(ReadOnlyModelViewSet): + queryset = LabResults.objects.all() + serializer_class = LabResultsSerializer + prefetch_for_includes = { + "__all__": [], + "author": ["author__bio", "author__entries"], + } diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index e457009b..bb6f09bb 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -224,7 +224,7 @@ class ModelViewSet( class ReadOnlyModelViewSet( - AutoPrefetchMixin, RelatedMixin, viewsets.ReadOnlyModelViewSet + AutoPrefetchMixin, PreloadIncludesMixin, RelatedMixin, viewsets.ReadOnlyModelViewSet ): http_method_names = ["get", "post", "patch", "delete", "head", "options"] From 74d2ff855ccdaff55681a8dbce5d808b0e6d6516 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Sun, 13 Jun 2021 21:32:39 +0200 Subject: [PATCH 074/252] Scheduled biweekly dependency update for week 23 (#950) * Update black from 21.4b2 to 21.5b2 * Update flake8 from 3.9.1 to 3.9.2 * Update sphinx from 3.5.4 to 4.0.2 * Update faker from 8.1.2 to 8.5.1 * Update pytest from 6.2.3 to 6.2.4 * Update pytest-cov from 2.11.1 to 2.12.1 * Update pytest-django from 4.2.0 to 4.4.0 --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-documentation.txt | 2 +- requirements/requirements-testing.txt | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 8395b9e1..550ec311 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==21.4b2 -flake8==3.9.1 +black==21.5b2 +flake8==3.9.2 flake8-isort==4.0.0 isort==5.8.0 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 1d4da97e..8246714f 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==3.5.4 +Sphinx==4.0.2 sphinx_rtd_theme==0.5.2 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 7887a137..1eabec55 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,8 +1,8 @@ django-debug-toolbar==3.2.1 factory-boy==3.2.0 -Faker==8.1.2 -pytest==6.2.3 -pytest-cov==2.11.1 -pytest-django==4.2.0 +Faker==8.5.1 +pytest==6.2.4 +pytest-cov==2.12.1 +pytest-django==4.4.0 pytest-factoryboy==2.1.0 snapshottest==0.6.0 From b49aa6e9a4ed317446861c1d035fd0e4ae84e142 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 6 Jul 2021 22:57:09 +0400 Subject: [PATCH 075/252] Remove invalid validation of default included_resources (#956) --- CHANGELOG.md | 3 ++- example/serializers.py | 3 +++ example/tests/test_serializers.py | 20 +++++++++++++++++++- rest_framework_json_api/serializers.py | 2 +- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 508da91f..48a5bd7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## Unreleased +## [Unreleased] ### Fixed * Include `PreloadIncludesMixin` in `ReadOnlyModelViewSet` to enable the usage of [performance utilities](https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#performance-improvements) on read only views (regression since 2.8.0) +* Remove invalid validation of default `included_resources` (regression since 4.2.0) ## [4.2.0] - 2021-05-12 diff --git a/example/serializers.py b/example/serializers.py index 64444e9e..43415243 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -321,6 +321,9 @@ class Meta: # fields = ('entry', 'body', 'author',) meta_fields = ("modified_days_ago",) + class JSONAPIMeta: + included_resources = ("writer",) + def get_modified_days_ago(self, obj): return (datetime.now() - obj.modified_at).days diff --git a/example/tests/test_serializers.py b/example/tests/test_serializers.py index 7550fd2c..9cfe6db9 100644 --- a/example/tests/test_serializers.py +++ b/example/tests/test_serializers.py @@ -198,7 +198,25 @@ def test_model_serializer_with_implicit_fields(self, comment, client): "meta": { "modifiedDaysAgo": (datetime.now() - comment.modified_at).days }, - } + }, + "included": [ + { + "attributes": { + "email": comment.author.email, + "name": comment.author.name, + }, + "id": str(comment.author.pk), + "relationships": { + "bio": { + "data": { + "id": str(comment.author.bio.pk), + "type": "authorBios", + } + } + }, + "type": "writers", + } + ], } response = client.get(reverse("comment-detail", kwargs={"pk": comment.pk})) diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 5ca773d0..a73a5d47 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -137,7 +137,7 @@ def validate_path(serializer_class, field_path, path): validate_path(this_included_serializer, new_included_field_path, path) if request and view: - included_resources = get_included_resources(request, self) + included_resources = get_included_resources(request) for included_field_name in included_resources: included_field_path = included_field_name.split(".") if "related_field" in view.kwargs: From cb023fc5f6f94aa532746e6c153efcf79e336367 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 6 Jul 2021 23:16:15 +0400 Subject: [PATCH 076/252] Release 4.2.1 (#958) --- CHANGELOG.md | 6 +++--- rest_framework_json_api/__init__.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48a5bd7a..3206cfa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [4.2.1] - 2021-07-06 ### Fixed -* Include `PreloadIncludesMixin` in `ReadOnlyModelViewSet` to enable the usage of [performance utilities](https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#performance-improvements) on read only views (regression since 2.8.0) -* Remove invalid validation of default `included_resources` (regression since 4.2.0) +* Included `PreloadIncludesMixin` in `ReadOnlyModelViewSet` to enable the usage of [performance utilities](https://django-rest-framework-json-api.readthedocs.io/en/stable/usage.html#performance-improvements) on read only views (regression since 2.8.0) +* Removed invalid validation of default `included_resources` (regression since 4.2.0) ## [4.2.0] - 2021-05-12 diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index d166001f..8f8e5dd7 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- __title__ = "djangorestframework-jsonapi" -__version__ = "4.2.0" +__version__ = "4.2.1" __author__ = "" __license__ = "BSD" __copyright__ = "" From 684063b4a878afd834c712ef2ea1cb8b8dffe2bf Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 16 Jul 2021 17:42:12 +0400 Subject: [PATCH 077/252] Resolve warning of inconsistent result when call LabResults view (#959) As LabResults is used in a test with pagination, a default ordering is required to omit warning about unpredictable order. --- example/migrations/0010_auto_20210714_0809.py | 17 +++++++++++++++++ example/models.py | 3 +++ 2 files changed, 20 insertions(+) create mode 100644 example/migrations/0010_auto_20210714_0809.py diff --git a/example/migrations/0010_auto_20210714_0809.py b/example/migrations/0010_auto_20210714_0809.py new file mode 100644 index 00000000..de36ba20 --- /dev/null +++ b/example/migrations/0010_auto_20210714_0809.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.9 on 2021-07-14 08:09 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("example", "0009_labresults_author"), + ] + + operations = [ + migrations.AlterModelOptions( + name="labresults", + options={"ordering": ("id",)}, + ), + ] diff --git a/example/models.py b/example/models.py index c3785c27..18b965a3 100644 --- a/example/models.py +++ b/example/models.py @@ -169,6 +169,9 @@ class LabResults(models.Model): related_name="lab_results", ) + class Meta: + ordering = ("id",) + class Company(models.Model): name = models.CharField(max_length=100) From ca627a98c46b298e2ebdfbed1e5e483068ac857c Mon Sep 17 00:00:00 2001 From: Safa Alfulaij Date: Sun, 18 Jul 2021 22:06:40 +0300 Subject: [PATCH 078/252] Load included and related serializers in meta class (#926) --- CHANGELOG.md | 11 ++++- rest_framework_json_api/relations.py | 3 +- rest_framework_json_api/renderers.py | 8 ++-- rest_framework_json_api/schemas/openapi.py | 9 +--- rest_framework_json_api/serializers.py | 54 ++++++++++++++++++++-- rest_framework_json_api/utils.py | 20 +++----- rest_framework_json_api/views.py | 3 -- tests/test_serializers.py | 35 ++++++++++++++ tests/test_utils.py | 14 ++++-- 9 files changed, 118 insertions(+), 39 deletions(-) create mode 100644 tests/test_serializers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3206cfa8..d9eaf504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## [Unreleased] + +### Changed + +* Moved resolving of `included_serialzers` and `related_serializers` classes to serializer's meta class. + +### Deprecated + +* Deprecated `get_included_serializers(serializer)` function under `rest_framework_json_api.utils`. Use `serializer.included_serializers` instead. + ## [4.2.1] - 2021-07-06 ### Fixed @@ -40,7 +50,6 @@ any parts of the framework not mentioned in the documentation should generally b * Deprecated default `format_type` argument of `rest_framework_json_api.utils.format_value`. Use `rest_framework_json_api.utils.format_field_name` or specify specifc `format_type` instead. * Deprecated `format_type` argument of `rest_framework_json_api.utils.format_link_segment`. Use `rest_framework_json_api.utils.format_value` instead. - ## [4.1.0] - 2021-03-08 ### Added diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index c9dd765d..605f7c1c 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -16,7 +16,6 @@ from rest_framework_json_api.utils import ( Hyperlink, format_link_segment, - get_included_serializers, get_resource_type_from_instance, get_resource_type_from_queryset, get_resource_type_from_serializer, @@ -274,7 +273,7 @@ def get_resource_type_from_included_serializer(self): inflection.singularize(field_name), inflection.pluralize(field_name), ] - includes = get_included_serializers(parent) + includes = getattr(parent, "included_serializers", dict()) for field in field_names: if field in includes.keys(): return get_resource_type_from_serializer(includes[field]) diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index b50c2b1a..3c649331 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -284,7 +284,9 @@ def extract_included( current_serializer = fields.serializer context = current_serializer.context - included_serializers = utils.get_included_serializers(current_serializer) + included_serializers = getattr( + current_serializer, "included_serializers", dict() + ) included_resources = copy.copy(included_resources) included_resources = [ inflection.underscore(value) for value in included_resources @@ -692,8 +694,8 @@ def _get_included_serializers(cls, serializer, prefix="", already_seen=None): included_serializers = [] already_seen.add(serializer) - for include, included_serializer in utils.get_included_serializers( - serializer + for include, included_serializer in getattr( + serializer, "included_serializers", dict() ).items(): included_serializers.append(f"{prefix}{include}") included_serializers.extend( diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 77a0d4ed..efe14914 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -1,7 +1,6 @@ import warnings from urllib.parse import urljoin -from django.utils.module_loading import import_string as import_class_from_dotted_path from rest_framework.fields import empty from rest_framework.relations import ManyRelatedField from rest_framework.schemas import openapi as drf_openapi @@ -379,13 +378,7 @@ def _find_related_view(self, view_endpoints, related_serializer, parent_view): """ for path, method, view in view_endpoints: view_serializer = view.get_serializer() - if not isinstance(related_serializer, type): - related_serializer_class = import_class_from_dotted_path( - related_serializer - ) - else: - related_serializer_class = related_serializer - if isinstance(view_serializer, related_serializer_class): + if isinstance(view_serializer, related_serializer): return view return None diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index a73a5d47..bc60f193 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -1,8 +1,10 @@ from collections import OrderedDict +from collections.abc import Mapping import inflection from django.core.exceptions import ObjectDoesNotExist from django.db.models.query import QuerySet +from django.utils.module_loading import import_string as import_class_from_dotted_path from django.utils.translation import gettext_lazy as _ from rest_framework.exceptions import ParseError @@ -22,7 +24,6 @@ from rest_framework_json_api.relations import ResourceRelatedField from rest_framework_json_api.utils import ( get_included_resources, - get_included_serializers, get_resource_type_from_instance, get_resource_type_from_model, get_resource_type_from_serializer, @@ -120,7 +121,7 @@ def __init__(self, *args, **kwargs): view = context.get("view") if context else None def validate_path(serializer_class, field_path, path): - serializers = get_included_serializers(serializer_class) + serializers = getattr(serializer_class, "included_serializers", None) if serializers is None: raise ParseError("This endpoint does not support the include parameter") this_field_name = inflection.underscore(field_path[0]) @@ -152,8 +153,55 @@ def validate_path(serializer_class, field_path, path): super(IncludedResourcesValidationMixin, self).__init__(*args, **kwargs) +class LazySerializersDict(Mapping): + """ + A dictionary of serializers which lazily import dotted class path and self. + """ + + def __init__(self, parent, serializers): + self.parent = parent + self.serializers = serializers + + def __getitem__(self, key): + value = self.serializers[key] + if not isinstance(value, type): + if value == "self": + value = self.parent + else: + value = import_class_from_dotted_path(value) + self.serializers[key] = value + + return value + + def __iter__(self): + return iter(self.serializers) + + def __len__(self): + return len(self.serializers) + + def __repr__(self): + return dict.__repr__(self.serializers) + + class SerializerMetaclass(SerializerMetaclass): - pass + def __new__(cls, name, bases, attrs): + serializer = super().__new__(cls, name, bases, attrs) + + if attrs.get("included_serializers", None): + setattr( + serializer, + "included_serializers", + LazySerializersDict(serializer, attrs["included_serializers"]), + ) + + if attrs.get("related_serializers", None): + setattr( + serializer, + "related_serializers", + LazySerializersDict(serializer, attrs["related_serializers"]), + ) + + return serializer # If user imports serializer from here we can catch class definition and check diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index ac31979a..19c72809 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -1,4 +1,3 @@ -import copy import inspect import operator import warnings @@ -13,7 +12,6 @@ ) from django.http import Http404 from django.utils import encoding -from django.utils.module_loading import import_string as import_class_from_dotted_path from django.utils.translation import gettext_lazy as _ from rest_framework import exceptions from rest_framework.exceptions import APIException @@ -342,20 +340,14 @@ def get_default_included_resources_from_serializer(serializer): def get_included_serializers(serializer): - included_serializers = copy.copy( - getattr(serializer, "included_serializers", dict()) + warnings.warn( + DeprecationWarning( + "Using of `get_included_serializers(serializer)` function is deprecated." + "Use `serializer.included_serializers` instead." + ) ) - for name, value in iter(included_serializers.items()): - if not isinstance(value, type): - if value == "self": - included_serializers[name] = ( - serializer if isinstance(serializer, type) else serializer.__class__ - ) - else: - included_serializers[name] = import_class_from_dotted_path(value) - - return included_serializers + return getattr(serializer, "included_serializers", dict()) def get_relation_instance(resource_instance, source, serializer): diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index bb6f09bb..6b739582 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -11,7 +11,6 @@ from django.db.models.manager import Manager from django.db.models.query import QuerySet from django.urls import NoReverseMatch -from django.utils.module_loading import import_string as import_class_from_dotted_path from rest_framework import generics, viewsets from rest_framework.exceptions import MethodNotAllowed, NotFound from rest_framework.fields import get_attribute @@ -183,8 +182,6 @@ def get_related_serializer_class(self): False ), 'Either "included_serializers" or "related_serializers" should be configured' - if not isinstance(_class, type): - return import_class_from_dotted_path(_class) return _class return parent_serializer_class diff --git a/tests/test_serializers.py b/tests/test_serializers.py new file mode 100644 index 00000000..6726fa8c --- /dev/null +++ b/tests/test_serializers.py @@ -0,0 +1,35 @@ +from django.db import models + +from rest_framework_json_api import serializers +from tests.models import DJAModel, ManyToManyTarget +from tests.serializers import ManyToManyTargetSerializer + + +def test_get_included_serializers(): + class IncludedSerializersModel(DJAModel): + self = models.ForeignKey("self", on_delete=models.CASCADE) + target = models.ForeignKey(ManyToManyTarget, on_delete=models.CASCADE) + other_target = models.ForeignKey(ManyToManyTarget, on_delete=models.CASCADE) + + class Meta: + app_label = "tests" + + class IncludedSerializersSerializer(serializers.ModelSerializer): + included_serializers = { + "self": "self", + "target": ManyToManyTargetSerializer, + "other_target": "tests.serializers.ManyToManyTargetSerializer", + } + + class Meta: + model = IncludedSerializersModel + fields = ("self", "other_target", "target") + + included_serializers = IncludedSerializersSerializer.included_serializers + expected_included_serializers = { + "self": IncludedSerializersSerializer, + "target": ManyToManyTargetSerializer, + "other_target": ManyToManyTargetSerializer, + } + + assert included_serializers == expected_included_serializers diff --git a/tests/test_utils.py b/tests/test_utils.py index 00bf0836..43c12dcf 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -346,7 +346,7 @@ class PlainRelatedResourceTypeSerializer(serializers.Serializer): def test_get_included_serializers(): - class IncludedSerializersModel(DJAModel): + class DeprecatedIncludedSerializersModel(DJAModel): self = models.ForeignKey("self", on_delete=models.CASCADE) target = models.ForeignKey(ManyToManyTarget, on_delete=models.CASCADE) other_target = models.ForeignKey(ManyToManyTarget, on_delete=models.CASCADE) @@ -354,7 +354,7 @@ class IncludedSerializersModel(DJAModel): class Meta: app_label = "tests" - class IncludedSerializersSerializer(serializers.ModelSerializer): + class DeprecatedIncludedSerializersSerializer(serializers.ModelSerializer): included_serializers = { "self": "self", "target": ManyToManyTargetSerializer, @@ -362,12 +362,16 @@ class IncludedSerializersSerializer(serializers.ModelSerializer): } class Meta: - model = IncludedSerializersModel + model = DeprecatedIncludedSerializersModel fields = ("self", "other_target", "target") - included_serializers = get_included_serializers(IncludedSerializersSerializer) + with pytest.deprecated_call(): + included_serializers = get_included_serializers( + DeprecatedIncludedSerializersSerializer + ) + expected_included_serializers = { - "self": IncludedSerializersSerializer, + "self": DeprecatedIncludedSerializersSerializer, "target": ManyToManyTargetSerializer, "other_target": ManyToManyTargetSerializer, } From 4c62f1d664375c1df1d3a52d12790403530f6183 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 27 Jul 2021 01:44:37 +0400 Subject: [PATCH 079/252] Resolved invalid example app browse to list in README (#961) --- README.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/README.rst b/README.rst index 03f9d52a..4cc41c5c 100644 --- a/README.rst +++ b/README.rst @@ -138,6 +138,7 @@ installed and activated: $ django-admin runserver --settings=example.settings Browse to + * http://localhost:8000 for the list of available collections (in a non-JSONAPI format!), * http://localhost:8000/swagger-ui/ for a Swagger user interface to the dynamic schema view, or * http://localhost:8000/openapi for the schema view's OpenAPI specification document. From d20247fd8d3d4e1dca8482fa218e192c91ca40e0 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 27 Jul 2021 01:47:08 +0400 Subject: [PATCH 080/252] Documented how to add DJA to installed apps (#963) This setting is now needed for browsable api and openapi schema. There might be more features coming needing this and it is also common practice to do this for django apps. Co-authored-by: Alan Crosswell --- README.rst | 18 ++++++++++++++---- docs/getting-started.md | 14 ++++++++++++-- docs/usage.md | 2 +- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/README.rst b/README.rst index 4cc41c5c..9cfb0e5f 100644 --- a/README.rst +++ b/README.rst @@ -100,8 +100,7 @@ Generally Python and Django series are supported till the official end of life. Installation ------------ -From PyPI -^^^^^^^^^ +Install using ``pip``... :: @@ -112,8 +111,7 @@ From PyPI $ pip install djangorestframework-jsonapi['openapi'] -From Source -^^^^^^^^^^^ +or from source... :: @@ -122,6 +120,18 @@ From Source $ pip install -e . +and add ``rest_framework_json_api`` to your ``INSTALLED_APPS`` setting below ``rest_framework``. + +:: + + INSTALLED_APPS = [ + ... + 'rest_framework', + 'rest_framework_json_api', + ... + ] + + Running the example app ^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/getting-started.md b/docs/getting-started.md index 5e374b30..debef71b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -61,7 +61,7 @@ Generally Python and Django series are supported till the official end of life. ## Installation -From PyPI +Install using `pip`... pip install djangorestframework-jsonapi # for optional package integrations @@ -69,11 +69,21 @@ From PyPI pip install djangorestframework-jsonapi['django-polymorphic'] pip install djangorestframework-jsonapi['openapi'] -From Source +or from source... git clone https://github.com/django-json-api/django-rest-framework-json-api.git cd django-rest-framework-json-api && pip install -e . + +and add `rest_framework_json_api` to your `INSTALLED_APPS` setting below `rest_framework`. + + INSTALLED_APPS = [ + ... + 'rest_framework', + 'rest_framework_json_api', + ... + ] + ## Running the example app git clone https://github.com/django-json-api/django-rest-framework-json-api.git diff --git a/docs/usage.md b/docs/usage.md index 1e45976b..e6109713 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -998,7 +998,7 @@ The `prefetch_related` case will issue 4 queries, but they will be small and fas ## Generating an OpenAPI Specification (OAS) 3.0 schema document -DRF >= 3.12 has a [new OAS schema functionality](https://www.django-rest-framework.org/api-guide/schemas/) to generate an +DRF has a [OAS schema functionality](https://www.django-rest-framework.org/api-guide/schemas/) to generate an [OAS 3.0 schema](https://www.openapis.org/) as a YAML or JSON file. DJA extends DRF's schema support to generate an OAS schema in the JSON:API format. From 5d25e2f91be7584dbe707cd8f0664895114c47e3 Mon Sep 17 00:00:00 2001 From: MsMansiDhruv <38699181+MsMansiDhruv@users.noreply.github.com> Date: Sun, 15 Aug 2021 00:17:29 +0530 Subject: [PATCH 081/252] Unified usage of JSON:API abbreviation (#945) --- AUTHORS | 1 + CHANGELOG.md | 12 +++++++---- README.rst | 14 ++++++------- SECURITY.md | 2 +- docs/CONTRIBUTING.md | 4 ++-- docs/conf.py | 16 +++++++-------- docs/getting-started.md | 6 +++--- docs/index.rst | 4 ++-- docs/usage.md | 12 +++++------ example/tests/test_filters.py | 2 +- example/tests/test_generic_viewset.py | 2 +- requirements.txt | 2 +- .../django_filters/backends.py | 6 +++--- rest_framework_json_api/exceptions.py | 6 +++--- rest_framework_json_api/filters.py | 2 +- rest_framework_json_api/pagination.py | 2 +- rest_framework_json_api/parsers.py | 10 +++++----- rest_framework_json_api/renderers.py | 6 +++--- rest_framework_json_api/schemas/openapi.py | 20 +++++++++---------- rest_framework_json_api/serializers.py | 4 ++-- rest_framework_json_api/settings.py | 6 +++--- setup.py | 2 +- tests/test_parsers.py | 5 +++-- 23 files changed, 75 insertions(+), 71 deletions(-) diff --git a/AUTHORS b/AUTHORS index 8d2af17d..03da87a8 100644 --- a/AUTHORS +++ b/AUTHORS @@ -21,6 +21,7 @@ Kevin Partington Kieran Evans Léo S. Luc Cary +Mansi Dhruv Matt Layman Michael Haselton Mohammed Ali Zubair diff --git a/CHANGELOG.md b/CHANGELOG.md index d9eaf504..c8c5e9d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ any parts of the framework not mentioned in the documentation should generally b ## [Unreleased] +### Fixed + +* Adjusted error messages to correctly use capitial "JSON:API" abbreviation as used in the specification. + ### Changed * Moved resolving of `included_serialzers` and `related_serializers` classes to serializer's meta class. @@ -104,10 +108,10 @@ This is the last release supporting Django 1.11, Django 2.1, Django REST Framewo ### Added * Added support for serializing nested serializers as attribute json value introducing setting `JSON_API_SERIALIZE_NESTED_SERIALIZERS_AS_ATTRIBUTE` - * Note: As keys of nested serializers are not json:api spec field names they are not inflected by format field names option. -* Added `rest_framework_json_api.serializer.Serializer` class to support initial JSON API views without models. + * Note: As keys of nested serializers are not JSON:API spec field names they are not inflected by format field names option. +* Added `rest_framework_json_api.serializer.Serializer` class to support initial JSON:API views without models. * Note that serializers derived from this class need to define `resource_name` in their `Meta` class. - * This fix might be a **BREAKING CHANGE** if you use `rest_framework_json_api.serializers.Serializer` for non json:api spec views (usually `APIView`). You need to change those serializers classes to use `rest_framework.serializers.Serializer` instead. + * This fix might be a **BREAKING CHANGE** if you use `rest_framework_json_api.serializers.Serializer` for non JSON:API spec views (usually `APIView`). You need to change those serializers classes to use `rest_framework.serializers.Serializer` instead. ### Fixed @@ -202,7 +206,7 @@ This is the last release supporting Python 2.7, Python 3.4, Django Filter 1.1, D * Don't swallow `filter[]` params when there are several * Fix DeprecationWarning regarding collections.abc import in Python 3.7 * Allow OPTIONS request to be used on RelationshipView -* Remove non-JSONAPI methods (PUT and TRACE) from ModelViewSet and RelationshipView. +* Remove non-JSON:API methods (PUT and TRACE) from ModelViewSet and RelationshipView. This fix might be a **BREAKING CHANGE** if your clients are incorrectly using PUT instead of PATCH. * Avoid validation error for missing fields on a PATCH request using polymorphic serializers diff --git a/README.rst b/README.rst index 9cfb0e5f..b52977a8 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ ================================== -JSON API and Django Rest Framework +JSON:API and Django Rest Framework ================================== .. image:: https://github.com/django-json-api/django-rest-framework-json-api/workflows/Tests/badge.svg @@ -18,7 +18,7 @@ JSON API and Django Rest Framework Overview -------- -**JSON API support for Django REST Framework** +**JSON:API support for Django REST Framework** * Documentation: https://django-rest-framework-json-api.readthedocs.org/ * Format specification: http://jsonapi.org/format/ @@ -38,7 +38,7 @@ By default, Django REST Framework will produce a response like:: } -However, for an ``identity`` model in JSON API format the response should look +However, for an ``identity`` model in JSON:API format the response should look like the following:: { @@ -67,9 +67,9 @@ like the following:: Goals ----- -As a Django REST Framework JSON API (short DJA) we are trying to address following goals: +As a Django REST Framework JSON:API (short DJA) we are trying to address following goals: -1. Support the `JSON API`_ spec to compliance +1. Support the `JSON:API`_ spec to compliance 2. Be as compatible with `Django REST Framework`_ as possible @@ -81,7 +81,7 @@ As a Django REST Framework JSON API (short DJA) we are trying to address followi 5. Be performant -.. _JSON API: http://jsonapi.org +.. _JSON:API: http://jsonapi.org .. _Django REST Framework: https://www.django-rest-framework.org/ ------------ @@ -149,7 +149,7 @@ installed and activated: Browse to -* http://localhost:8000 for the list of available collections (in a non-JSONAPI format!), +* http://localhost:8000 for the list of available collections (in a non-JSON:API format!), * http://localhost:8000/swagger-ui/ for a Swagger user interface to the dynamic schema view, or * http://localhost:8000/openapi for the schema view's OpenAPI specification document. diff --git a/SECURITY.md b/SECURITY.md index 78781946..c30001ac 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Reporting a Vulnerability -If you believe you've found something in Django REST Framework JSON API which has security implications, please **do not raise the issue in a public forum**. +If you believe you've found something in Django REST Framework JSON:API which has security implications, please **do not raise the issue in a public forum**. Send a description of the issue via email to [rest-framework-jsonapi-security@googlegroups.com][security-mail]. The project maintainers will then work with you to resolve any issues where required, prior to any public disclosure. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index be1d0499..df054335 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -Django REST Framework JSON API (aka DJA) should be easy to contribute to. +Django REST Framework JSON:API (aka DJA) should be easy to contribute to. If anything is unclear about how to contribute, please submit an issue on GitHub so that we can fix it! @@ -11,7 +11,7 @@ if the proposed change makes sense for the project. ### Clone -To start developing on Django REST Framework JSON API you need to first clone the repository: +To start developing on Django REST Framework JSON:API you need to first clone the repository: git clone https://github.com/django-json-api/django-rest-framework-json-api.git diff --git a/docs/conf.py b/docs/conf.py index 0b8caa69..9d4e12ae 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- # -# Django REST Framework JSON API documentation build configuration file, created by +# Django REST Framework JSON:API documentation build configuration file, created by # sphinx-quickstart on Fri Jul 24 23:31:15 2015. # # This file is execfile()d with the current directory set to its @@ -59,10 +59,10 @@ master_doc = "index" # General information about the project. -project = "Django REST Framework JSON API" +project = "Django REST Framework JSON:API" year = datetime.date.today().year -copyright = "{}, Django REST Framework JSON API contributors".format(year) -author = "Django REST Framework JSON API contributors" +copyright = "{}, Django REST Framework JSON:API contributors".format(year) +author = "Django REST Framework JSON:API contributors" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -244,8 +244,8 @@ ( master_doc, "DjangoRESTFrameworkJSONAPI.tex", - "Django REST Framework JSON API Documentation", - "Django REST Framework JSON API contributors", + "Django REST Framework JSON:API Documentation", + "Django REST Framework JSON:API contributors", "manual", ), ] @@ -279,7 +279,7 @@ ( master_doc, "djangorestframeworkjsonapi", - "Django REST Framework JSON API Documentation", + "Django REST Framework JSON:API Documentation", [author], 1, ) @@ -298,7 +298,7 @@ ( master_doc, "DjangoRESTFrameworkJSONAPI", - "Django REST Framework JSON API Documentation", + "Django REST Framework JSON:API Documentation", author, "DjangoRESTFrameworkJSONAPI", "One line description of project.", diff --git a/docs/getting-started.md b/docs/getting-started.md index debef71b..75522ddf 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,7 +1,7 @@ # Getting Started -*Note: this package is named Django REST Framework JSON API to follow the naming +*Note: this package is named Django REST Framework JSON:API to follow the naming convention of other Django REST Framework packages. Since that's quite a bit to say or type this package will be referred to as DJA elsewhere in these docs.* @@ -20,7 +20,7 @@ By default, Django REST Framework produces a response like: ``` -However, for the same `identity` model in JSON API format the response should look +However, for the same `identity` model in JSON:API format the response should look like the following: ``` js { @@ -97,7 +97,7 @@ and add `rest_framework_json_api` to your `INSTALLED_APPS` setting below `rest_f Browse to -* [http://localhost:8000](http://localhost:8000) for the list of available collections (in a non-JSONAPI format!), +* [http://localhost:8000](http://localhost:8000) for the list of available collections (in a non-JSON:API format!), * [http://localhost:8000/swagger-ui/](http://localhost:8000/swagger-ui/) for a Swagger user interface to the dynamic schema view, or * [http://localhost:8000/openapi](http://localhost:8000/openapi) for the schema view's OpenAPI specification document. diff --git a/docs/index.rst b/docs/index.rst index b18b8b6e..ee2afd7a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,9 +1,9 @@ -.. Django REST Framework JSON API documentation master file, created by +.. Django REST Framework JSON:API documentation master file, created by sphinx-quickstart on Fri Jul 24 23:31:15 2015. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -Welcome to Django REST Framework JSON API +Welcome to Django REST Framework JSON:API ========================================================== Contents: diff --git a/docs/usage.md b/docs/usage.md index e6109713..bf77f3dd 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -240,9 +240,9 @@ class MyViewset(ModelViewSet): ### Exception handling For the `exception_handler` class, if the optional `JSON_API_UNIFORM_EXCEPTIONS` is set to True, -all exceptions will respond with the JSON API [error format](http://jsonapi.org/format/#error-objects). +all exceptions will respond with the JSON:API [error format](http://jsonapi.org/format/#error-objects). -When `JSON_API_UNIFORM_EXCEPTIONS` is False (the default), non-JSON API views will respond +When `JSON_API_UNIFORM_EXCEPTIONS` is False (the default), non-JSON:API views will respond with the normal DRF error format. ### Performance Testing @@ -312,8 +312,7 @@ multiple endpoints. Setting the `resource_name` on views may result in a differe ### Inflecting object and relation keys -This package includes the ability (off by default) to automatically convert [json -api field names](http://jsonapi.org/format/#document-resource-object-fields) of requests and responses from the python/rest_framework's preferred underscore to +This package includes the ability (off by default) to automatically convert [JSON:API field names](http://jsonapi.org/format/#document-resource-object-fields) of requests and responses from the python/rest_framework's preferred underscore to a format of your choice. To hook this up include the following setting in your project settings: @@ -524,8 +523,7 @@ The relationship name is formatted by the `JSON_API_FORMAT_FIELD_NAMES` setting, #### ResourceRelatedField -Because of the additional structure needed to represent relationships in JSON -API, this package provides the `ResourceRelatedField` for serializers, which +Because of the additional structure needed to represent relationships in JSON:API, this package provides the `ResourceRelatedField` for serializers, which works similarly to `PrimaryKeyRelatedField`. By default, `rest_framework_json_api.serializers.ModelSerializer` will use this for related fields automatically. It can be instantiated explicitly as in the @@ -896,7 +894,7 @@ Related links will be created automatically when using the Relationship View. ### Included -JSON API can include additional resources in a single network request. +JSON:API can include additional resources in a single network request. The specification refers to this feature as [Compound Documents](http://jsonapi.org/format/#document-compound-documents). Compound Documents can reduce the number of network requests diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index 68ad1452..10d8886a 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -104,7 +104,7 @@ def test_sort_underscore(self): def test_sort_related(self): """ - test sort via related field using jsonapi path `.` and django orm `__` notation. + test sort via related field using JSON:API path `.` and django orm `__` notation. ORM relations must be predefined in the View's .ordering_fields attr """ for datum in ("blog__id", "blog.id"): diff --git a/example/tests/test_generic_viewset.py b/example/tests/test_generic_viewset.py index a07eb610..c8a28f24 100644 --- a/example/tests/test_generic_viewset.py +++ b/example/tests/test_generic_viewset.py @@ -55,7 +55,7 @@ def test_ember_expected_renderer(self): def test_default_validation_exceptions(self): """ - Default validation exceptions should conform to json api spec + Default validation exceptions should conform to JSON:API spec """ expected = { "errors": [ diff --git a/requirements.txt b/requirements.txt index f1ba742f..ba98a85d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -# The base set of requirements for Django REST framework JSON API is actually +# The base set of requirements for Django REST framework JSON:API is actually # fairly small, but for the purposes of development and testing # there are a number of packages that are useful to install. diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index 3906308c..09e64bc2 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -11,7 +11,7 @@ class DjangoFilterBackend(DjangoFilterBackend): """ A Django-style ORM filter implementation, using `django-filter`. - This is not part of the jsonapi standard per-se, other than the requirement + This is not part of the JSON:API standard per-se, other than the requirement to use the `filter` keyword: This is an optional implementation of style of filtering in which each filter is an ORM expression as implemented by DjangoFilterBackend and seems to be in alignment with an interpretation of @@ -50,7 +50,7 @@ class DjangoFilterBackend(DjangoFilterBackend): the name of the query parameter for searching to make sure it doesn't conflict with a field name defined in the filterset. The recommended value is: `search_param="filter[search]"` but just make sure it's - `filter[]` to comply with the jsonapi spec requirement to use the filter + `filter[]` to comply with the JSON:API spec requirement to use the filter keyword. The default is "search" unless overriden but it's used here just to make sure we don't complain about it being an invalid filter. """ @@ -117,7 +117,7 @@ def get_filterset_kwargs(self, request, queryset, view): raise ValidationError( "missing value for query parameter {}".format(qp) ) - # convert jsonapi relationship path to Django ORM's __ notation + # convert JSON:API relationship path to Django ORM's __ notation key = m.groupdict()["assoc"].replace(".", "__") key = undo_format_field_name(key) data.setlist(key, val) diff --git a/rest_framework_json_api/exceptions.py b/rest_framework_json_api/exceptions.py index 05f56756..b13ccad5 100644 --- a/rest_framework_json_api/exceptions.py +++ b/rest_framework_json_api/exceptions.py @@ -29,16 +29,16 @@ def exception_handler(exc, context): if not response: return response - # Use regular DRF format if not rendered by DRF JSON API and not uniform + # Use regular DRF format if not rendered by DRF JSON:API and not uniform is_json_api_view = rendered_with_json_api(context["view"]) is_uniform = json_api_settings.UNIFORM_EXCEPTIONS if not is_json_api_view and not is_uniform: return response - # Convert to DRF JSON API error format + # Convert to DRF JSON:API error format response = utils.format_drf_errors(response, context, exc) - # Add top-level 'errors' object when not rendered by DRF JSON API + # Add top-level 'errors' object when not rendered by DRF JSON:API if not is_json_api_view: response.data = utils.format_errors(response.data) diff --git a/rest_framework_json_api/filters.py b/rest_framework_json_api/filters.py index 95056666..3862057a 100644 --- a/rest_framework_json_api/filters.py +++ b/rest_framework_json_api/filters.py @@ -90,7 +90,7 @@ def validate_query_params(self, request): :raises ValidationError: if not. """ - # TODO: For jsonapi error object conformance, must set jsonapi errors "parameter" for + # TODO: For JSON:API error object conformance, must set JSON:API errors "parameter" for # the ValidationError. This requires extending DRF/DJA Exceptions. for qp in request.query_params.keys(): m = self.query_regex.match(qp) diff --git a/rest_framework_json_api/pagination.py b/rest_framework_json_api/pagination.py index 468f684c..6cbd744c 100644 --- a/rest_framework_json_api/pagination.py +++ b/rest_framework_json_api/pagination.py @@ -10,7 +10,7 @@ class JsonApiPageNumberPagination(PageNumberPagination): """ - A json-api compatible pagination format. + A JSON:API compatible pagination format. """ page_query_param = "page[number]" diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index 433bcb32..434e2925 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -13,7 +13,7 @@ class JSONParser(parsers.JSONParser): Similar to `JSONRenderer`, the `JSONParser` you may override the following methods if you need highly custom parsing control. - A JSON API client will send a payload that looks like this: + A JSON:API client will send a payload that looks like this: .. code:: json @@ -87,7 +87,7 @@ def parse(self, stream, media_type=None, parser_context=None): from rest_framework_json_api.views import RelationshipView if isinstance(view, RelationshipView): - # We skip parsing the object as JSONAPI Resource Identifier Object and not a regular + # We skip parsing the object as JSON:API Resource Identifier Object and not a regular # Resource Object if isinstance(data, list): for resource_identifier_object in data: @@ -96,12 +96,12 @@ def parse(self, stream, media_type=None, parser_context=None): and resource_identifier_object.get("type") ): raise ParseError( - "Received data contains one or more malformed JSONAPI " + "Received data contains one or more malformed JSON:API " "Resource Identifier Object(s)" ) elif not (data.get("id") and data.get("type")): raise ParseError( - "Received data is not a valid JSONAPI Resource Identifier Object" + "Received data is not a valid JSON:API Resource Identifier Object" ) return data @@ -111,7 +111,7 @@ def parse(self, stream, media_type=None, parser_context=None): # Sanity check if not isinstance(data, dict): raise ParseError( - "Received data is not a valid JSONAPI Resource Identifier Object" + "Received data is not a valid JSON:API Resource Identifier Object" ) # Check for inconsistencies diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 3c649331..de9e3c32 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -29,7 +29,7 @@ class JSONRenderer(renderers.JSONRenderer): The `JSONRenderer` exposes a number of methods that you may override if you need highly custom rendering control. - Render a JSON response per the JSON API spec: + Render a JSON response per the JSON:API spec: .. code-block:: json @@ -54,11 +54,11 @@ class JSONRenderer(renderers.JSONRenderer): @classmethod def extract_attributes(cls, fields, resource): """ - Builds the `attributes` object of the JSON API resource object. + Builds the `attributes` object of the JSON:API resource object. """ data = OrderedDict() for field_name, field in iter(fields.items()): - # ID is always provided in the root of JSON API so remove it from attributes + # ID is always provided in the root of JSON:API so remove it from attributes if field_name == "id": continue # don't output a key for write only fields diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index efe14914..2dff2e10 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -11,10 +11,10 @@ class SchemaGenerator(drf_openapi.SchemaGenerator): """ - Extend DRF's SchemaGenerator to implement jsonapi-flavored generateschema command. + Extend DRF's SchemaGenerator to implement JSON:API flavored generateschema command. """ - #: These JSONAPI component definitions are referenced by the generated OAS schema. + #: These JSON:API component definitions are referenced by the generated OAS schema. #: If you need to add more or change these static component definitions, extend this dict. jsonapi_components = { "schemas": { @@ -258,7 +258,7 @@ class SchemaGenerator(drf_openapi.SchemaGenerator): def get_schema(self, request=None, public=False): """ - Generate a JSONAPI OpenAPI schema. + Generate a JSON:API OpenAPI schema. Overrides upstream DRF's get_schema. """ # TODO: avoid copying so much of upstream get_schema() @@ -393,15 +393,15 @@ def _field_is_one_or_many(self, field, view): class AutoSchema(drf_openapi.AutoSchema): """ - Extend DRF's openapi.AutoSchema for JSONAPI serialization. + Extend DRF's openapi.AutoSchema for JSON:API serialization. """ - #: ignore all the media types and only generate a JSONAPI schema. + #: ignore all the media types and only generate a JSON:API schema. content_types = ["application/vnd.api+json"] def get_operation(self, path, method): """ - JSONAPI adds some standard fields to the API response that are not in upstream DRF: + JSON:API adds some standard fields to the API response that are not in upstream DRF: - some that only apply to GET/HEAD methods. - collections - special handling for POST, PATCH, DELETE @@ -505,7 +505,7 @@ def _add_get_item_response(self, operation): def _get_toplevel_200_response(self, operation, collection=True): """ - return top-level JSONAPI GET 200 response + return top-level JSON:API GET 200 response :param collection: True for collections; False for individual items. @@ -587,7 +587,7 @@ def _add_delete_item_response(self, operation, path): def get_request_body(self, path, method): """ - A request body is required by jsonapi for POST, PATCH, and DELETE methods. + A request body is required by JSON:API for POST, PATCH, and DELETE methods. """ serializer = self.get_serializer(path, method) if not isinstance(serializer, (serializers.BaseSerializer,)): @@ -595,7 +595,7 @@ def get_request_body(self, path, method): is_relationship = isinstance(self.view, views.RelationshipView) # DRF uses a $ref to the component schema definition, but this - # doesn't work for jsonapi due to the different required fields based on + # doesn't work for JSON:API due to the different required fields based on # the method, so make those changes and inline another copy of the schema. # TODO: A future improvement could make this DRYer with multiple component schemas: # A base schema for each viewset that has no required fields @@ -640,7 +640,7 @@ def get_request_body(self, path, method): def map_serializer(self, serializer): """ - Custom map_serializer that serializes the schema using the jsonapi spec. + Custom map_serializer that serializes the schema using the JSON:API spec. Non-attributes like related and identity fields, are move to 'relationships' and 'links'. """ # TODO: remove attributes, etc. for relationshipView?? diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index bc60f193..b0a85df7 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -214,9 +214,9 @@ class Serializer( ): """ A `Serializer` is a model-less serializer class with additional - support for json:api spec features. + support for JSON:API spec features. - As in json:api specification a type is always required you need to + As in JSON:API specification a type is always required you need to make sure that you define `resource_name` in your `Meta` class when deriving from this class. diff --git a/rest_framework_json_api/settings.py b/rest_framework_json_api/settings.py index 0e790847..fd59c80c 100644 --- a/rest_framework_json_api/settings.py +++ b/rest_framework_json_api/settings.py @@ -1,6 +1,6 @@ """ This module provides the `json_api_settings` object that is used to access -JSON API REST framework settings, checking for user settings first, then falling back to +JSON:API REST framework settings, checking for user settings first, then falling back to the defaults. """ @@ -20,7 +20,7 @@ class JSONAPISettings(object): """ - A settings object that allows json api settings to be access as + A settings object that allows JSON:API settings to be access as properties. """ @@ -30,7 +30,7 @@ def __init__(self, user_settings=settings, defaults=DEFAULTS): def __getattr__(self, attr): if attr not in self.defaults: - raise AttributeError("Invalid JSON API setting: '%s'" % attr) + raise AttributeError("Invalid JSON:API setting: '%s'" % attr) value = getattr( self.user_settings, JSON_API_SETTINGS_PREFIX + attr, self.defaults[attr] diff --git a/setup.py b/setup.py index 8cce9a5d..eff9d026 100755 --- a/setup.py +++ b/setup.py @@ -72,7 +72,7 @@ def get_package_data(package): version=get_version("rest_framework_json_api"), url="https://github.com/django-json-api/django-rest-framework-json-api", license="BSD", - description="A Django REST framework API adapter for the JSON API spec.", + description="A Django REST framework API adapter for the JSON:API spec.", long_description=read("README.rst"), author="Jerel Unruh", author_email="", diff --git a/tests/test_parsers.py b/tests/test_parsers.py index 03770970..5dd9036e 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -110,8 +110,9 @@ def test_parse_fails_on_list_of_objects(self, parse): with pytest.raises(ParseError) as excinfo: parse(data) - assert "Received data is not a valid JSONAPI Resource Identifier Object" == str( - excinfo.value + assert ( + "Received data is not a valid JSON:API Resource Identifier Object" + == str(excinfo.value) ) def test_parse_fails_when_id_is_missing_on_patch(self, rf, parse, parser_context): From 50841f704f9b7063f21f027f815010df7161e50d Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 16 Aug 2021 18:42:54 +0200 Subject: [PATCH 082/252] Scheduled biweekly dependency update for week 33 (#970) * Update black from 21.5b2 to 21.7b0 * Update isort from 5.8.0 to 5.9.3 * Update sphinx from 4.0.2 to 4.1.2 * Update twine from 3.4.1 to 3.4.2 * Update django-debug-toolbar from 3.2.1 to 3.2.2 * Update faker from 8.5.1 to 8.11.0 --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-documentation.txt | 2 +- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 550ec311..9326aaec 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==21.5b2 +black==21.7b0 flake8==3.9.2 flake8-isort==4.0.0 -isort==5.8.0 +isort==5.9.3 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 8246714f..2835e77e 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==4.0.2 +Sphinx==4.1.2 sphinx_rtd_theme==0.5.2 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index 7e9e1799..1a95714a 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==3.4.1 +twine==3.4.2 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 1eabec55..e793221b 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,6 +1,6 @@ -django-debug-toolbar==3.2.1 +django-debug-toolbar==3.2.2 factory-boy==3.2.0 -Faker==8.5.1 +Faker==8.11.0 pytest==6.2.4 pytest-cov==2.12.1 pytest-django==4.4.0 From e1feedc60ae2ed2c0b71c51227f30ba55d1c9dbe Mon Sep 17 00:00:00 2001 From: Asif Saif Uddin Date: Fri, 10 Sep 2021 13:29:20 +0600 Subject: [PATCH 083/252] Clean up python2 related code crafts (#974) --- AUTHORS | 1 + rest_framework_json_api/relations.py | 14 +++++++------- rest_framework_json_api/renderers.py | 18 +++++------------- rest_framework_json_api/serializers.py | 16 +++++++--------- rest_framework_json_api/settings.py | 2 +- rest_framework_json_api/views.py | 12 ++++++------ 6 files changed, 27 insertions(+), 36 deletions(-) diff --git a/AUTHORS b/AUTHORS index 03da87a8..cb038bd4 100644 --- a/AUTHORS +++ b/AUTHORS @@ -2,6 +2,7 @@ Adam Wróbel Adam Ziolkowski Alan Crosswell Anton Shutik +Asif Saif Uddin Beni Keller Boris Pleshakov Charlie Allatson diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index 605f7c1c..f6e91cfe 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -29,14 +29,14 @@ ] -class SkipDataMixin(object): +class SkipDataMixin: """ This workaround skips "data" rendering for relationships in order to save some sql queries and improve performance """ def __init__(self, *args, **kwargs): - super(SkipDataMixin, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def get_attribute(self, instance): raise SkipField @@ -49,7 +49,7 @@ class ManyRelatedFieldWithNoData(SkipDataMixin, DRFManyRelatedField): pass -class HyperlinkedMixin(object): +class HyperlinkedMixin: self_link_view_name = None related_link_view_name = None related_link_lookup_field = "pk" @@ -72,7 +72,7 @@ def __init__(self, self_link_view_name=None, related_link_view_name=None, **kwar # implicit `self` argument to be passed. self.reverse = reverse - super(HyperlinkedMixin, self).__init__(**kwargs) + super().__init__(**kwargs) def get_url(self, name, view_name, kwargs, request): """ @@ -197,7 +197,7 @@ def __init__(self, **kwargs): if model: self.model = model - super(ResourceRelatedField, self).__init__(**kwargs) + super().__init__(**kwargs) def use_pk_only_optimization(self): # We need the real object to determine its type... @@ -245,7 +245,7 @@ def to_internal_value(self, data): received_type=data["type"], ) - return super(ResourceRelatedField, self).to_internal_value(data["id"]) + return super().to_internal_value(data["id"]) def to_representation(self, value): if getattr(self, "pk_field", None) is not None: @@ -329,7 +329,7 @@ class PolymorphicResourceRelatedField(ResourceRelatedField): def __init__(self, polymorphic_serializer, *args, **kwargs): self.polymorphic_serializer = polymorphic_serializer - super(PolymorphicResourceRelatedField, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def use_pk_only_optimization(self): return False diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index de9e3c32..e86c0c0b 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -495,12 +495,10 @@ def render_relationship_view( links = view.get_links() if links: render_data.update({"links": links}), - return super(JSONRenderer, self).render( - render_data, accepted_media_type, renderer_context - ) + return super().render(render_data, accepted_media_type, renderer_context) def render_errors(self, data, accepted_media_type=None, renderer_context=None): - return super(JSONRenderer, self).render( + return super().render( utils.format_errors(data), accepted_media_type, renderer_context ) @@ -522,9 +520,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): # be None response = renderer_context.get("response", None) if response is not None and response.status_code == 204: - return super(JSONRenderer, self).render( - None, accepted_media_type, renderer_context - ) + return super().render(None, accepted_media_type, renderer_context) from rest_framework_json_api.views import RelationshipView @@ -536,9 +532,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): # If `resource_name` is set to None then render default as the dev # wants to build the output format manually. if resource_name is None or resource_name is False: - return super(JSONRenderer, self).render( - data, accepted_media_type, renderer_context - ) + return super().render(data, accepted_media_type, renderer_context) json_api_data = data # initialize json_api_meta with pagination meta or an empty dict @@ -664,9 +658,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): if json_api_meta: render_data["meta"] = utils.format_field_names(json_api_meta) - return super(JSONRenderer, self).render( - render_data, accepted_media_type, renderer_context - ) + return super().render(render_data, accepted_media_type, renderer_context) class BrowsableAPIRenderer(renderers.BrowsableAPIRenderer): diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index b0a85df7..ce80874a 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -45,7 +45,7 @@ def __init__(self, *args, **kwargs): self.model_class = kwargs.pop("model_class", self.model_class) # this has no fields but assumptions are made elsewhere that self.fields exists. self.fields = {} - super(ResourceIdentifierObjectSerializer, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def to_representation(self, instance): return { @@ -69,7 +69,7 @@ def to_internal_value(self, data): self.fail("incorrect_type", data_type=type(data["pk"]).__name__) -class SparseFieldsetsMixin(object): +class SparseFieldsetsMixin: """ A serializer mixin that adds support for sparse fieldsets through `fields` query parameter. @@ -77,7 +77,7 @@ class SparseFieldsetsMixin(object): """ def __init__(self, *args, **kwargs): - super(SparseFieldsetsMixin, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) context = kwargs.get("context") request = context.get("request") if context else None @@ -107,7 +107,7 @@ def __init__(self, *args, **kwargs): self.fields.pop(field_name) -class IncludedResourcesValidationMixin(object): +class IncludedResourcesValidationMixin: """ A serializer mixin that adds validation of `include` query parameter to support compound documents. @@ -150,7 +150,7 @@ def validate_path(serializer_class, field_path, path): this_serializer_class, included_field_path, included_field_name ) - super(IncludedResourcesValidationMixin, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) class LazySerializersDict(Mapping): @@ -302,9 +302,7 @@ class PolymorphicSerializerMetaclass(SerializerMetaclass): """ def __new__(cls, name, bases, attrs): - new_class = super(PolymorphicSerializerMetaclass, cls).__new__( - cls, name, bases, attrs - ) + new_class = super().__new__(cls, name, bases, attrs) # Ensure initialization is only performed for subclasses of PolymorphicModelSerializer # (excluding PolymorphicModelSerializer class itself). @@ -363,7 +361,7 @@ def get_fields(self): raise Exception( "Cannot get fields from a polymorphic serializer given a queryset" ) - return super(PolymorphicModelSerializer, self).get_fields() + return super().get_fields() @classmethod def get_polymorphic_serializer_for_instance(cls, instance): diff --git a/rest_framework_json_api/settings.py b/rest_framework_json_api/settings.py index fd59c80c..00a28fe5 100644 --- a/rest_framework_json_api/settings.py +++ b/rest_framework_json_api/settings.py @@ -18,7 +18,7 @@ } -class JSONAPISettings(object): +class JSONAPISettings: """ A settings object that allows JSON:API settings to be access as properties. diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index 6b739582..c53864a0 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -30,7 +30,7 @@ ) -class PreloadIncludesMixin(object): +class PreloadIncludesMixin: """ This mixin provides a helper attributes to select or prefetch related models based on the include specified in the URL. @@ -60,7 +60,7 @@ def get_prefetch_related(self, include): return getattr(self, "prefetch_for_includes", {}).get(include, None) def get_queryset(self, *args, **kwargs): - qs = super(PreloadIncludesMixin, self).get_queryset(*args, **kwargs) + qs = super().get_queryset(*args, **kwargs) included_resources = get_included_resources( self.request, self.get_serializer_class() @@ -78,10 +78,10 @@ def get_queryset(self, *args, **kwargs): return qs -class AutoPrefetchMixin(object): +class AutoPrefetchMixin: def get_queryset(self, *args, **kwargs): """This mixin adds automatic prefetching for OneToOne and ManyToMany fields.""" - qs = super(AutoPrefetchMixin, self).get_queryset(*args, **kwargs) + qs = super().get_queryset(*args, **kwargs) included_resources = get_included_resources( self.request, self.get_serializer_class() @@ -127,7 +127,7 @@ def get_queryset(self, *args, **kwargs): return qs -class RelatedMixin(object): +class RelatedMixin: """ This mixin handles all related entities, whose Serializers are declared in "related_serializers" """ @@ -239,7 +239,7 @@ def get_serializer_class(self): return self.serializer_class def __init__(self, **kwargs): - super(RelationshipView, self).__init__(**kwargs) + super().__init__(**kwargs) # We include this simply for dependency injection in tests. # We can't add it as a class attributes or it would expect an # implicit `self` argument to be passed. From 7202f42ee61ec7443cea28ae006be4eccda9841d Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sat, 18 Sep 2021 05:07:17 +0400 Subject: [PATCH 084/252] Add missing tests folder to MAINIFEST.in (#975) Removed obsolete exclude as there is already a global exclude with those extensions. --- MANIFEST.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index 44e3d86c..9102318a 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,7 +2,7 @@ include AUTHORS include LICENSE include README.rst recursive-include example * -recursive-exclude example *.pyc *.pyo +recursive-include tests * global-exclude __pycache__ global-exclude *.py[co] From f8209c0eadbb6d6ca55a83e8a7887e2b10f5f2ae Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sat, 18 Sep 2021 05:16:27 +0400 Subject: [PATCH 085/252] Use consistent naming for Django REST framework (#977) Co-authored-by: Alan Crosswell --- CHANGELOG.md | 16 ++++++++-------- README.rst | 22 +++++++++++----------- SECURITY.md | 2 +- docs/CONTRIBUTING.md | 4 ++-- docs/conf.py | 16 ++++++++-------- docs/getting-started.md | 12 ++++++------ docs/index.rst | 4 ++-- rest_framework_json_api/settings.py | 2 +- 8 files changed, 39 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8c5e9d1..4d14d29f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -Note that in line with [Django REST Framework policy](http://www.django-rest-framework.org/topics/release-notes/), +Note that in line with [Django REST framework policy](http://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. ## [Unreleased] @@ -103,7 +103,7 @@ This release is not backwards compatible. For easy migration best upgrade first ## [3.2.0] - 2020-08-26 -This is the last release supporting Django 1.11, Django 2.1, Django REST Framework 3.10, Django REST Framework 3.11 and Python 3.5. +This is the last release supporting Django 1.11, Django 2.1, Django REST framework 3.10, Django REST framework 3.11 and Python 3.5. ### Added @@ -171,7 +171,7 @@ This release is not backwards compatible. For easy migration best upgrade first * Removed support for Python 2.7 and 3.4. * Removed support for Django Filter 1.1. * Removed obsolete dependency six. -* Removed support for Django REST Framework <=3.9. +* Removed support for Django REST framework <=3.9. * Removed support for Django 2.0. * Removed obsolete mixins `MultipleIDMixin` and `PrefetchForIncludesHelperMixin` * Removed obsolete settings `JSON_API_FORMAT_KEYS`, `JSON_API_FORMAT_RELATION_KEYS` and @@ -188,7 +188,7 @@ This release is not backwards compatible. For easy migration best upgrade first ## [2.8.0] - 2019-06-13 -This is the last release supporting Python 2.7, Python 3.4, Django Filter 1.1, Django REST Framework <=3.9 and Django 2.0. +This is the last release supporting Python 2.7, Python 3.4, Django Filter 1.1, Django REST framework <=3.9 and Django 2.0. ### Added @@ -265,7 +265,7 @@ This is the last release supporting Python 2.7, Python 3.4, Django Filter 1.1, D * Add new pagination classes based on JSON:API query parameter *recommendations*: * `JsonApiPageNumberPagination` and `JsonApiLimitOffsetPagination`. See [usage docs](docs/usage.md#pagination). * Add `ReadOnlyModelViewSet` extension with prefetch mixins -* Add support for Django REST Framework 3.8.x +* Add support for Django REST framework 3.8.x * Introduce `JSON_API_FORMAT_FIELD_NAMES` option replacing `JSON_API_FORMAT_KEYS` but in comparison preserving values from being formatted as attributes can contain any [json value](http://jsonapi.org/format/#document-resource-object-attributes). * Allow overwriting of `get_queryset()` in custom `ResourceRelatedField` @@ -293,13 +293,13 @@ This is the last release supporting Python 2.7, Python 3.4, Django Filter 1.1, D ### Added -* Add support for Django REST Framework 3.7.x. +* Add support for Django REST framework 3.7.x. * Add support for Django 2.0. ### Removed * Drop support for Django 1.8 - 1.10 (EOL) -* Drop support for Django REST Framework < 3.6.3 +* Drop support for Django REST framework < 3.6.3 (3.6.3 is the first to support Django 1.11) * Drop support for Python 3.3 (EOL) @@ -326,7 +326,7 @@ This is the last release supporting Python 2.7, Python 3.4, Django Filter 1.1, D ### Added -* Add support for Django REST Framework 3.5 and 3.6 +* Add support for Django REST framework 3.5 and 3.6 * Add support for Django 1.11 * Add support for Python 3.6 diff --git a/README.rst b/README.rst index b52977a8..856b4058 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ ================================== -JSON:API and Django Rest Framework +JSON:API and Django REST framework ================================== .. image:: https://github.com/django-json-api/django-rest-framework-json-api/workflows/Tests/badge.svg @@ -18,13 +18,13 @@ JSON:API and Django Rest Framework Overview -------- -**JSON:API support for Django REST Framework** +**JSON:API support for Django REST framework** * Documentation: https://django-rest-framework-json-api.readthedocs.org/ * Format specification: http://jsonapi.org/format/ -By default, Django REST Framework will produce a response like:: +By default, Django REST framework will produce a response like:: { "count": 20, @@ -67,13 +67,13 @@ like the following:: Goals ----- -As a Django REST Framework JSON:API (short DJA) we are trying to address following goals: +As a Django REST framework JSON:API (short DJA) we are trying to address following goals: 1. Support the `JSON:API`_ spec to compliance -2. Be as compatible with `Django REST Framework`_ as possible +2. Be as compatible with `Django REST framework`_ as possible - e.g. issues in Django REST Framework should be fixed upstream and not worked around in DJA + e.g. issues in Django REST framework should be fixed upstream and not worked around in DJA 3. Have sane defaults to be as easy to pick up as possible @@ -82,7 +82,7 @@ As a Django REST Framework JSON:API (short DJA) we are trying to address followi 5. Be performant .. _JSON:API: http://jsonapi.org -.. _Django REST Framework: https://www.django-rest-framework.org/ +.. _Django REST framework: https://www.django-rest-framework.org/ ------------ Requirements @@ -90,11 +90,11 @@ Requirements 1. Python (3.6, 3.7, 3.8, 3.9) 2. Django (2.2, 3.0, 3.1, 3.2) -3. Django REST Framework (3.12) +3. Django REST framework (3.12) -We **highly** recommend and only officially support the latest patch release of each Python, Django and REST Framework series. +We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. -Generally Python and Django series are supported till the official end of life. For Django REST Framework the last two series are supported. +Generally Python and Django series are supported till the official end of life. For Django REST framework the last two series are supported. ------------ Installation @@ -160,7 +160,7 @@ Usage ``rest_framework_json_api`` assumes you are using class-based views in Django -Rest Framework. +REST framework. Settings diff --git a/SECURITY.md b/SECURITY.md index c30001ac..e524f6a5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Reporting a Vulnerability -If you believe you've found something in Django REST Framework JSON:API which has security implications, please **do not raise the issue in a public forum**. +If you believe you've found something in Django REST framework JSON:API which has security implications, please **do not raise the issue in a public forum**. Send a description of the issue via email to [rest-framework-jsonapi-security@googlegroups.com][security-mail]. The project maintainers will then work with you to resolve any issues where required, prior to any public disclosure. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index df054335..2aa87cfe 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -Django REST Framework JSON:API (aka DJA) should be easy to contribute to. +Django REST framework JSON:API (aka DJA) should be easy to contribute to. If anything is unclear about how to contribute, please submit an issue on GitHub so that we can fix it! @@ -11,7 +11,7 @@ if the proposed change makes sense for the project. ### Clone -To start developing on Django REST Framework JSON:API you need to first clone the repository: +To start developing on Django REST framework JSON:API you need to first clone the repository: git clone https://github.com/django-json-api/django-rest-framework-json-api.git diff --git a/docs/conf.py b/docs/conf.py index 9d4e12ae..d36b32c1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- # -# Django REST Framework JSON:API documentation build configuration file, created by +# Django REST framework JSON:API documentation build configuration file, created by # sphinx-quickstart on Fri Jul 24 23:31:15 2015. # # This file is execfile()d with the current directory set to its @@ -59,10 +59,10 @@ master_doc = "index" # General information about the project. -project = "Django REST Framework JSON:API" +project = "Django REST framework JSON:API" year = datetime.date.today().year -copyright = "{}, Django REST Framework JSON:API contributors".format(year) -author = "Django REST Framework JSON:API contributors" +copyright = "{}, Django REST framework JSON:API contributors".format(year) +author = "Django REST framework JSON:API contributors" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -244,8 +244,8 @@ ( master_doc, "DjangoRESTFrameworkJSONAPI.tex", - "Django REST Framework JSON:API Documentation", - "Django REST Framework JSON:API contributors", + "Django REST framework JSON:API Documentation", + "Django REST framework JSON:API contributors", "manual", ), ] @@ -279,7 +279,7 @@ ( master_doc, "djangorestframeworkjsonapi", - "Django REST Framework JSON:API Documentation", + "Django REST framework JSON:API Documentation", [author], 1, ) @@ -298,7 +298,7 @@ ( master_doc, "DjangoRESTFrameworkJSONAPI", - "Django REST Framework JSON:API Documentation", + "Django REST framework JSON:API Documentation", author, "DjangoRESTFrameworkJSONAPI", "One line description of project.", diff --git a/docs/getting-started.md b/docs/getting-started.md index 75522ddf..9351905b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,11 +1,11 @@ # Getting Started -*Note: this package is named Django REST Framework JSON:API to follow the naming -convention of other Django REST Framework packages. Since that's quite a bit +*Note: this package is named Django REST framework JSON:API to follow the naming +convention of other Django REST framework packages. Since that's quite a bit to say or type this package will be referred to as DJA elsewhere in these docs.* -By default, Django REST Framework produces a response like: +By default, Django REST framework produces a response like: ``` js { "count": 20, @@ -53,11 +53,11 @@ like the following: 1. Python (3.6, 3.7, 3.8, 3.9) 2. Django (2.2, 3.0, 3.1, 3.2) -3. Django REST Framework (3.12) +3. Django REST framework (3.12) -We **highly** recommend and only officially support the latest patch release of each Python, Django and REST Framework series. +We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. -Generally Python and Django series are supported till the official end of life. For Django REST Framework the last two series are supported. +Generally Python and Django series are supported till the official end of life. For Django REST framework the last two series are supported. ## Installation diff --git a/docs/index.rst b/docs/index.rst index ee2afd7a..a1219bc0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,9 +1,9 @@ -.. Django REST Framework JSON:API documentation master file, created by +.. Django REST framework JSON:API documentation master file, created by sphinx-quickstart on Fri Jul 24 23:31:15 2015. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -Welcome to Django REST Framework JSON:API +Welcome to Django REST framework JSON:API ========================================================== Contents: diff --git a/rest_framework_json_api/settings.py b/rest_framework_json_api/settings.py index 00a28fe5..0800e901 100644 --- a/rest_framework_json_api/settings.py +++ b/rest_framework_json_api/settings.py @@ -1,6 +1,6 @@ """ This module provides the `json_api_settings` object that is used to access -JSON:API REST framework settings, checking for user settings first, then falling back to +Django REST framework JSON:API settings, checking for user settings first, then falling back to the defaults. """ From a6644ebfc580e28700967bb5c88f1d3515eafd59 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 23 Sep 2021 21:27:11 +0400 Subject: [PATCH 086/252] Align overwriting of get_queryset in example app and documentation (#979) Fixes #487 --- docs/usage.md | 6 +++--- example/views.py | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index bf77f3dd..02d9fbd2 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -620,14 +620,14 @@ class LineItemViewSet(viewsets.ModelViewSet): serializer_class = LineItemSerializer def get_queryset(self): - queryset = super(LineItemViewSet, self).get_queryset() + queryset = super().get_queryset() # if this viewset is accessed via the 'order-lineitems-list' route, # it wll have been passed the `order_pk` kwarg and the queryset # needs to be filtered accordingly; if it was accessed via the # unnested '/lineitems' route, the queryset should include all LineItems - if 'order_pk' in self.kwargs: - order_pk = self.kwargs['order_pk'] + order_pk = self.kwargs.get('order_pk') + if order_pk is not None: queryset = queryset.filter(order__pk=order_pk) return queryset diff --git a/example/views.py b/example/views.py index 0b35d4e4..64d31780 100644 --- a/example/views.py +++ b/example/views.py @@ -243,11 +243,13 @@ class CommentViewSet(ModelViewSet): } def get_queryset(self, *args, **kwargs): + queryset = super().get_queryset() + entry_pk = self.kwargs.get("entry_pk", None) if entry_pk is not None: - return self.queryset.filter(entry_id=entry_pk) + queryset = queryset.filter(entry_id=entry_pk) - return super(CommentViewSet, self).get_queryset() + return queryset class CompanyViewset(ModelViewSet): From 29971b45aba199c290b0a7e789421642c8f95d30 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 23 Sep 2021 22:16:21 +0400 Subject: [PATCH 087/252] Allow parser context to be None (#981) Default parameter of `parser_context` was already set to `None` but would had always raised an error. DRF parser allows `parser_context` to be `None` to so brings DJA to use the same API. --- CHANGELOG.md | 1 + rest_framework_json_api/parsers.py | 10 +++++---- tests/test_parsers.py | 36 ++++++++++++++++++++---------- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d14d29f..a3011d2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Fixed * Adjusted error messages to correctly use capitial "JSON:API" abbreviation as used in the specification. +* Avoid error when `parser_context` is `None` while parsing. ### Changed diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index 434e2925..4c04fd52 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -82,7 +82,8 @@ def parse(self, stream, media_type=None, parser_context=None): raise ParseError("Received document does not contain primary data") data = result.get("data") - view = parser_context["view"] + parser_context = parser_context or {} + view = parser_context.get("view") from rest_framework_json_api.views import RelationshipView @@ -107,6 +108,7 @@ def parse(self, stream, media_type=None, parser_context=None): return data request = parser_context.get("request") + method = request and request.method # Sanity check if not isinstance(data, dict): @@ -115,7 +117,7 @@ def parse(self, stream, media_type=None, parser_context=None): ) # Check for inconsistencies - if request.method in ("PUT", "POST", "PATCH"): + if method in ("PUT", "POST", "PATCH"): resource_name = get_resource_name( parser_context, expand_polymorphic_types=True ) @@ -138,12 +140,12 @@ def parse(self, stream, media_type=None, parser_context=None): resource_types=", ".join(resource_name), ) ) - if not data.get("id") and request.method in ("PATCH", "PUT"): + if not data.get("id") and method in ("PATCH", "PUT"): raise ParseError( "The resource identifier object must contain an 'id' member" ) - if request.method in ("PATCH", "PUT"): + if method in ("PATCH", "PUT"): lookup_url_kwarg = getattr(view, "lookup_url_kwarg", None) or getattr( view, "lookup_field", None ) diff --git a/tests/test_parsers.py b/tests/test_parsers.py index 5dd9036e..f1207757 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -15,8 +15,8 @@ def parser(self): return JSONParser() @pytest.fixture - def parse(self, parser, parser_context): - def parse_wrapper(data): + def parse(self, parser): + def parse_wrapper(data, parser_context): stream = BytesIO(json.dumps(data).encode("utf-8")) return parser.parse(stream, None, parser_context) @@ -41,6 +41,7 @@ def test_parse_formats_field_names( settings, format_field_names, parse, + parser_context, ): settings.JSON_API_FORMAT_FIELD_NAMES = format_field_names @@ -59,14 +60,14 @@ def test_parse_formats_field_names( } } - result = parse(data) + result = parse(data, parser_context) assert result == { "id": "123", "test_attribute": "test-value", "test_relationship": {"id": "123", "type": "TestRelationship"}, } - def test_parse_extracts_meta(self, parse): + def test_parse_extracts_meta(self, parse, parser_context): data = { "data": { "type": "BasicModel", @@ -74,10 +75,21 @@ def test_parse_extracts_meta(self, parse): "meta": {"random_key": "random_value"}, } - result = parse(data) + result = parse(data, parser_context) assert result["_meta"] == data["meta"] - def test_parse_preserves_json_value_field_names(self, settings, parse): + def test_parse_with_default_arguments(self, parse): + data = { + "data": { + "type": "BasicModel", + }, + } + result = parse(data, None) + assert result == {} + + def test_parse_preserves_json_value_field_names( + self, settings, parse, parser_context + ): settings.JSON_API_FORMAT_FIELD_NAMES = "dasherize" data = { @@ -87,17 +99,17 @@ def test_parse_preserves_json_value_field_names(self, settings, parse): }, } - result = parse(data) + result = parse(data, parser_context) assert result["json_value"] == {"JsonKey": "JsonValue"} - def test_parse_raises_error_on_empty_data(self, parse): + def test_parse_raises_error_on_empty_data(self, parse, parser_context): data = [] with pytest.raises(ParseError) as excinfo: - parse(data) + parse(data, parser_context) assert "Received document does not contain primary data" == str(excinfo.value) - def test_parse_fails_on_list_of_objects(self, parse): + def test_parse_fails_on_list_of_objects(self, parse, parser_context): data = { "data": [ { @@ -108,7 +120,7 @@ def test_parse_fails_on_list_of_objects(self, parse): } with pytest.raises(ParseError) as excinfo: - parse(data) + parse(data, parser_context) assert ( "Received data is not a valid JSON:API Resource Identifier Object" @@ -124,7 +136,7 @@ def test_parse_fails_when_id_is_missing_on_patch(self, rf, parse, parser_context } with pytest.raises(ParseError) as excinfo: - parse(data) + parse(data, parser_context) assert "The resource identifier object must contain an 'id' member" == str( excinfo.value From 4f596d762e794a8368d9b47220eef822a8af6ff2 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 4 Oct 2021 13:41:12 -0500 Subject: [PATCH 088/252] Scheduled biweekly dependency update for week 40 (#988) * Update black from 21.7b0 to 21.9b0 * Update sphinx from 4.1.2 to 4.2.0 * Update sphinx_rtd_theme from 0.5.2 to 1.0.0 * Update django-filter from 2.4.0 to 21.1 * Update faker from 8.11.0 to 8.14.1 * Update pytest from 6.2.4 to 6.2.5 * Update pytest-cov from 2.12.1 to 3.0.0 --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-documentation.txt | 4 ++-- requirements/requirements-optionals.txt | 2 +- requirements/requirements-testing.txt | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 9326aaec..4e7090cb 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==21.7b0 +black==21.9b0 flake8==3.9.2 flake8-isort==4.0.0 isort==5.9.3 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 2835e77e..d24d7101 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==4.1.2 -sphinx_rtd_theme==0.5.2 +Sphinx==4.2.0 +sphinx_rtd_theme==1.0.0 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 15e87ee5..4d543da3 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,4 +1,4 @@ -django-filter==2.4.0 +django-filter==21.1 django-polymorphic==3.0.0 pyyaml==5.4.1 uritemplate==3.0.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index e793221b..a64d5b2c 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,8 +1,8 @@ django-debug-toolbar==3.2.2 factory-boy==3.2.0 -Faker==8.11.0 -pytest==6.2.4 -pytest-cov==2.12.1 +Faker==8.14.1 +pytest==6.2.5 +pytest-cov==3.0.0 pytest-django==4.4.0 pytest-factoryboy==2.1.0 snapshottest==0.6.0 From 414547a34cac2ae3a143cc2578b320e3d05d8d85 Mon Sep 17 00:00:00 2001 From: Swaraj Baral Date: Wed, 6 Oct 2021 01:18:30 +0530 Subject: [PATCH 089/252] Changed all external links to https (#989) --- AUTHORS | 1 + CHANGELOG.md | 6 ++--- README.rst | 16 ++++++------- docs/Makefile | 2 +- docs/getting-started.md | 12 +++++----- docs/make.bat | 2 +- docs/usage.md | 24 +++++++++---------- .../django_filters/backends.py | 6 ++--- rest_framework_json_api/filters.py | 6 ++--- 9 files changed, 38 insertions(+), 37 deletions(-) diff --git a/AUTHORS b/AUTHORS index cb038bd4..c3865471 100644 --- a/AUTHORS +++ b/AUTHORS @@ -38,6 +38,7 @@ Safa AlFulaij santiavenda Sergey Kolomenkin Stas S. +Swaraj Baral Tim Selman Tom Glowka Ulrich Schuster diff --git a/CHANGELOG.md b/CHANGELOG.md index a3011d2e..97ef872d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -Note that in line with [Django REST framework policy](http://www.django-rest-framework.org/topics/release-notes/), +Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. ## [Unreleased] @@ -240,7 +240,7 @@ This is the last release supporting Python 2.7, Python 3.4, Django Filter 1.1, D * Add testing configuration to `REST_FRAMEWORK` configuration as described in [DRF](https://www.django-rest-framework.org/api-guide/testing/#configuration) * Add `HyperlinkedRelatedField` and `SerializerMethodHyperlinkedRelatedField`. See [usage docs](docs/usage.md#related-fields) * Add related urls support. See [usage docs](docs/usage.md#related-urls) -* Add optional [jsonapi-style](http://jsonapi.org/format/) filter backends. See [usage docs](docs/usage.md#filter-backends) +* Add optional [jsonapi-style](https://jsonapi.org/format/) filter backends. See [usage docs](docs/usage.md#filter-backends) ### Deprecated @@ -268,7 +268,7 @@ This is the last release supporting Python 2.7, Python 3.4, Django Filter 1.1, D * Add `ReadOnlyModelViewSet` extension with prefetch mixins * Add support for Django REST framework 3.8.x * Introduce `JSON_API_FORMAT_FIELD_NAMES` option replacing `JSON_API_FORMAT_KEYS` but in comparison preserving - values from being formatted as attributes can contain any [json value](http://jsonapi.org/format/#document-resource-object-attributes). + values from being formatted as attributes can contain any [json value](https://jsonapi.org/format/#document-resource-object-attributes). * Allow overwriting of `get_queryset()` in custom `ResourceRelatedField` ### Deprecated diff --git a/README.rst b/README.rst index 856b4058..c3e661e3 100644 --- a/README.rst +++ b/README.rst @@ -21,15 +21,15 @@ Overview **JSON:API support for Django REST framework** * Documentation: https://django-rest-framework-json-api.readthedocs.org/ -* Format specification: http://jsonapi.org/format/ +* Format specification: https://jsonapi.org/format/ By default, Django REST framework will produce a response like:: { "count": 20, - "next": "http://example.com/api/1.0/identities/?page=3", - "previous": "http://example.com/api/1.0/identities/?page=1", + "next": "https://example.com/api/1.0/identities/?page=3", + "previous": "https://example.com/api/1.0/identities/?page=1", "results": [{ "id": 3, "username": "john", @@ -43,9 +43,9 @@ like the following:: { "links": { - "prev": "http://example.com/api/1.0/identities", - "self": "http://example.com/api/1.0/identities?page=2", - "next": "http://example.com/api/1.0/identities?page=3", + "prev": "https://example.com/api/1.0/identities", + "self": "https://example.com/api/1.0/identities?page=2", + "next": "https://example.com/api/1.0/identities?page=3", }, "data": [{ "type": "identities", @@ -81,7 +81,7 @@ As a Django REST framework JSON:API (short DJA) we are trying to address followi 5. Be performant -.. _JSON:API: http://jsonapi.org +.. _JSON:API: https://jsonapi.org .. _Django REST framework: https://www.django-rest-framework.org/ ------------ @@ -202,4 +202,4 @@ override ``settings.REST_FRAMEWORK`` This package provides much more including automatic inflection of JSON keys, extra top level data (using nested serializers), relationships, links, paginators, filters, and handy shortcuts. -Read more at http://django-rest-framework-json-api.readthedocs.org/ +Read more at https://django-rest-framework-json-api.readthedocs.org/ diff --git a/docs/Makefile b/docs/Makefile index fc44e850..ba724aee 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -9,7 +9,7 @@ BUILDDIR = _build # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) -$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from https://sphinx-doc.org/) endif # Internal variables. diff --git a/docs/getting-started.md b/docs/getting-started.md index 9351905b..51780af2 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -9,8 +9,8 @@ By default, Django REST framework produces a response like: ``` js { "count": 20, - "next": "http://example.com/api/1.0/identities/?page=3", - "previous": "http://example.com/api/1.0/identities/?page=1", + "next": "https://example.com/api/1.0/identities/?page=3", + "previous": "https://example.com/api/1.0/identities/?page=1", "results": [{ "id": 3, "username": "john", @@ -25,10 +25,10 @@ like the following: ``` js { "links": { - "first": "http://example.com/api/1.0/identities", - "last": "http://example.com/api/1.0/identities?page=5", - "next": "http://example.com/api/1.0/identities?page=3", - "prev": "http://example.com/api/1.0/identities", + "first": "https://example.com/api/1.0/identities", + "last": "https://example.com/api/1.0/identities?page=5", + "next": "https://example.com/api/1.0/identities?page=3", + "prev": "https://example.com/api/1.0/identities", }, "data": [{ "type": "identities", diff --git a/docs/make.bat b/docs/make.bat index ed3e42a0..c2ddc7bb 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -65,7 +65,7 @@ if errorlevel 9009 ( echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ + echo.https://sphinx-doc.org/ exit /b 1 ) diff --git a/docs/usage.md b/docs/usage.md index 02d9fbd2..d0bd07f4 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -3,7 +3,7 @@ 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](http://jsonapi.org/format) format standard have been implemented using +Many features of the [JSON:API](https://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` @@ -116,7 +116,7 @@ is used. This can help the client identify misspelled query parameters, for exam If you want to change the list of valid query parameters, override the `.query_regex` attribute: ```python -# compiled regex that matches the allowed http://jsonapi.org/format/#query-parameters +# compiled regex that matches the allowed https://jsonapi.org/format/#query-parameters # `sort` and `include` stand alone; `filter`, `fields`, and `page` have []'s query_regex = re.compile(r'^(sort|include)$|^(filter|fields|page)(\[[\w\.\-]+\])?$') ``` @@ -134,7 +134,7 @@ simply don't use this filter backend. #### OrderingFilter -`OrderingFilter` implements the [JSON:API `sort`](http://jsonapi.org/format/#fetching-sorting) and uses +`OrderingFilter` implements the [JSON:API `sort`](https://jsonapi.org/format/#fetching-sorting) and uses DRF's [ordering filter](https://www.django-rest-framework.org/api-guide/filtering/#orderingfilter). Per the JSON:API specification, "If the server does not support sorting as specified in the query parameter `sort`, @@ -159,14 +159,14 @@ If you want to silently ignore bad sort fields, just use `rest_framework.filters #### DjangoFilterBackend -`DjangoFilterBackend` implements a Django ORM-style [JSON:API `filter`](http://jsonapi.org/format/#fetching-filtering) +`DjangoFilterBackend` implements a Django ORM-style [JSON:API `filter`](https://jsonapi.org/format/#fetching-filtering) using the [django-filter](https://django-filter.readthedocs.io/) package. This filter is not part of the JSON:API standard per-se, other than the requirement to use the `filter` keyword: It is an optional implementation of a style of filtering in which each filter is an ORM expression as implemented by `DjangoFilterBackend` and seems to be in alignment with an interpretation of the -[JSON:API _recommendations_](http://jsonapi.org/recommendations/#filtering), including relationship +[JSON:API _recommendations_](https://jsonapi.org/recommendations/#filtering), including relationship chaining. Filters can be: @@ -240,7 +240,7 @@ class MyViewset(ModelViewSet): ### Exception handling For the `exception_handler` class, if the optional `JSON_API_UNIFORM_EXCEPTIONS` is set to True, -all exceptions will respond with the JSON:API [error format](http://jsonapi.org/format/#error-objects). +all exceptions will respond with the JSON:API [error format](https://jsonapi.org/format/#error-objects). When `JSON_API_UNIFORM_EXCEPTIONS` is False (the default), non-JSON:API views will respond with the normal DRF error format. @@ -312,7 +312,7 @@ multiple endpoints. Setting the `resource_name` on views may result in a differe ### Inflecting object and relation keys -This package includes the ability (off by default) to automatically convert [JSON:API field names](http://jsonapi.org/format/#document-resource-object-fields) of requests and responses from the python/rest_framework's preferred underscore to +This package includes the ability (off by default) to automatically convert [JSON:API field names](https://jsonapi.org/format/#document-resource-object-fields) of requests and responses from the python/rest_framework's preferred underscore to a format of your choice. To hook this up include the following setting in your project settings: @@ -551,7 +551,7 @@ class OrderSerializer(serializers.ModelSerializer): ``` -In the [JSON:API spec](http://jsonapi.org/format/#document-resource-objects), +In the [JSON:API spec](https://jsonapi.org/format/#document-resource-objects), relationship objects contain links to related objects. To make this work on a serializer we need to tell the `ResourceRelatedField` about the corresponding view. Use the `HyperlinkedModelSerializer` and instantiate @@ -755,12 +755,12 @@ class OrderSerializer(serializers.HyperlinkedModelSerializer): ### RelationshipView `rest_framework_json_api.views.RelationshipView` is used to build relationship views (see the -[JSON:API spec](http://jsonapi.org/format/#fetching-relationships)). +[JSON:API spec](https://jsonapi.org/format/#fetching-relationships)). The `self` link on a relationship object should point to the corresponding relationship view. The relationship view is fairly simple because it only serializes -[Resource Identifier Objects](http://jsonapi.org/format/#document-resource-identifier-objects) +[Resource Identifier Objects](https://jsonapi.org/format/#document-resource-identifier-objects) rather than full resource objects. In most cases the following is sufficient: ```python @@ -896,7 +896,7 @@ Related links will be created automatically when using the Relationship View. JSON:API can include additional resources in a single network request. The specification refers to this feature as -[Compound Documents](http://jsonapi.org/format/#document-compound-documents). +[Compound Documents](https://jsonapi.org/format/#document-compound-documents). Compound Documents can reduce the number of network requests which can lead to a better performing web application. To accomplish this, @@ -1058,7 +1058,7 @@ class MySchemaGenerator(JSONAPISchemaGenerator): } } schema['servers'] = [ - {'url': 'https://localhost/v1', 'description': 'local docker'}, + {'url': 'http://localhost/v1', 'description': 'local docker'}, {'url': 'http://localhost:8000/v1', 'description': 'local dev'}, {'url': 'https://api.example.com/v1', 'description': 'demo server'}, {'url': '{serverURL}', 'description': 'provide your server URL', diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index 09e64bc2..72ae873e 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -15,7 +15,7 @@ class DjangoFilterBackend(DjangoFilterBackend): to use the `filter` keyword: This is an optional implementation of style of filtering in which each filter is an ORM expression as implemented by DjangoFilterBackend and seems to be in alignment with an interpretation of - http://jsonapi.org/recommendations/#filtering, including relationship + https://jsonapi.org/recommendations/#filtering, including relationship chaining. It also returns a 400 error for invalid filters. Filters can be: @@ -58,8 +58,8 @@ class DjangoFilterBackend(DjangoFilterBackend): search_param = api_settings.SEARCH_PARAM # Make this regex check for 'filter' as well as 'filter[...]' - # See http://jsonapi.org/format/#document-member-names for allowed characters - # and http://jsonapi.org/format/#document-member-names-reserved-characters for reserved + # See https://jsonapi.org/format/#document-member-names for allowed characters + # and https://jsonapi.org/format/#document-member-names-reserved-characters for reserved # characters (for use in paths, lists or as delimiters). # regex `\w` matches [a-zA-Z0-9_]. # TODO: U+0080 and above allowed but not recommended. Leave them out for now.e diff --git a/rest_framework_json_api/filters.py b/rest_framework_json_api/filters.py index 3862057a..d5dd337c 100644 --- a/rest_framework_json_api/filters.py +++ b/rest_framework_json_api/filters.py @@ -8,7 +8,7 @@ class OrderingFilter(OrderingFilter): """ - A backend filter that implements http://jsonapi.org/format/#fetching-sorting and + A backend filter that implements https://jsonapi.org/format/#fetching-sorting and raises a 400 error if any sort field is invalid. If you prefer *not* to report 400 errors for invalid sort fields, just use @@ -74,10 +74,10 @@ class QueryParameterValidationFilter(BaseFilterBackend): If you want to add some additional non-standard query parameters, override :py:attr:`query_regex` adding the new parameters. Make sure to comply with - the rules at http://jsonapi.org/format/#query-parameters. + the rules at https://jsonapi.org/format/#query-parameters. """ - #: compiled regex that matches the allowed http://jsonapi.org/format/#query-parameters: + #: compiled regex that matches the allowed https://jsonapi.org/format/#query-parameters: #: `sort` and `include` stand alone; `filter`, `fields`, and `page` have []'s query_regex = re.compile( r"^(sort|include)$|^(?Pfilter|fields|page)(\[[\w\.\-]+\])?$" From e68a0f277fbf2e28f34c2ab3a9bff7dac25a1c7e Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 6 Oct 2021 16:42:20 +0400 Subject: [PATCH 090/252] Check for reserved field names in serializer meta class (#987) Fixes #518 Fixes #710 This is to avoid an incomprehensible exception during runtime when either meta or results is used as a field name. Co-authored-by: Alan Crosswell --- CHANGELOG.md | 3 ++- rest_framework_json_api/serializers.py | 19 +++++++++++++++++++ tests/test_serializers.py | 15 +++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97ef872d..30ef91ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,9 @@ any parts of the framework not mentioned in the documentation should generally b ### Fixed -* Adjusted error messages to correctly use capitial "JSON:API" abbreviation as used in the specification. +* Adjusted error messages to correctly use capital "JSON:API" abbreviation as used in the specification. * Avoid error when `parser_context` is `None` while parsing. +* Raise comprehensible error when reserved field names `meta` and `results` are used. ### Changed diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index ce80874a..ee6d15b6 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -184,6 +184,25 @@ def __repr__(self): class SerializerMetaclass(SerializerMetaclass): + + _reserved_field_names = {"meta", "results"} + + @classmethod + def _get_declared_fields(cls, bases, attrs): + fields = super()._get_declared_fields(bases, attrs) + + found_reserved_field_names = cls._reserved_field_names.intersection( + fields.keys() + ) + if found_reserved_field_names: + raise AttributeError( + f"Serializer class {attrs['__module__']}.{attrs['__qualname__']} uses " + f"following reserved field name(s) which is not allowed: " + f"{', '.join(sorted(found_reserved_field_names))}" + ) + + return fields + def __new__(cls, name, bases, attrs): serializer = super().__new__(cls, name, bases, attrs) diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 6726fa8c..505111ca 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -1,3 +1,4 @@ +import pytest from django.db import models from rest_framework_json_api import serializers @@ -33,3 +34,17 @@ class Meta: } assert included_serializers == expected_included_serializers + + +def test_reserved_field_names(): + with pytest.raises(AttributeError) as e: + + class ReservedFieldNamesSerializer(serializers.Serializer): + meta = serializers.CharField() + results = serializers.CharField() + + assert str(e.value) == ( + "Serializer class tests.test_serializers.test_reserved_field_names.." + "ReservedFieldNamesSerializer uses following reserved field name(s) which is " + "not allowed: meta, results" + ) From 081619296ac87fbd740bddf7b053e0a989df9a59 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 6 Oct 2021 17:22:06 +0400 Subject: [PATCH 091/252] Move pull request template to github directory (#991) This file is part of the github workflow and not the documentation so for consistency reason be moved to `.github` to be accompanied with the issue template. --- {docs => .github}/pull_request_template.md | 0 docs/conf.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename {docs => .github}/pull_request_template.md (100%) diff --git a/docs/pull_request_template.md b/.github/pull_request_template.md similarity index 100% rename from docs/pull_request_template.md rename to .github/pull_request_template.md diff --git a/docs/conf.py b/docs/conf.py index d36b32c1..cd9266ce 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -88,7 +88,7 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ["_build", "pull_request_template.md"] +exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all # documents. From ee679ee72a9b845811639e60ff745ded4238a6fc Mon Sep 17 00:00:00 2001 From: Mehdy Khoshnoody Date: Thu, 7 Oct 2021 21:31:15 +0330 Subject: [PATCH 092/252] Use relationships in the error object pointer when the field is actually a relationship (#986) --- AUTHORS | 1 + CHANGELOG.md | 1 + example/tests/snapshots/snap_test_errors.py | 11 +++++++ example/tests/test_errors.py | 32 +++++++++++++++++---- rest_framework_json_api/renderers.py | 10 ++----- rest_framework_json_api/utils.py | 23 +++++++++++++-- 6 files changed, 63 insertions(+), 15 deletions(-) diff --git a/AUTHORS b/AUTHORS index c3865471..84e52286 100644 --- a/AUTHORS +++ b/AUTHORS @@ -24,6 +24,7 @@ Léo S. Luc Cary Mansi Dhruv Matt Layman +Mehdy Khoshnoody Michael Haselton Mohammed Ali Zubair Nathanael Gordon diff --git a/CHANGELOG.md b/CHANGELOG.md index 30ef91ed..217ebb14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ any parts of the framework not mentioned in the documentation should generally b * Adjusted error messages to correctly use capital "JSON:API" abbreviation as used in the specification. * Avoid error when `parser_context` is `None` while parsing. * Raise comprehensible error when reserved field names `meta` and `results` are used. +* Use `relationships` in the error object `pointer` when the field is actually a relationship. ### Changed diff --git a/example/tests/snapshots/snap_test_errors.py b/example/tests/snapshots/snap_test_errors.py index 528b831b..dd5ca4f8 100644 --- a/example/tests/snapshots/snap_test_errors.py +++ b/example/tests/snapshots/snap_test_errors.py @@ -44,6 +44,17 @@ ] } +snapshots["test_relationship_errors_has_correct_pointers 1"] = { + "errors": [ + { + "code": "incorrect_type", + "detail": "Incorrect type. Expected resource identifier object, received str.", + "source": {"pointer": "/data/relationships/author"}, + "status": "400", + } + ] +} + snapshots["test_second_level_array_error 1"] = { "errors": [ { diff --git a/example/tests/test_errors.py b/example/tests/test_errors.py index 93cdb235..6b322d72 100644 --- a/example/tests/test_errors.py +++ b/example/tests/test_errors.py @@ -1,11 +1,11 @@ import pytest from django.test import override_settings from django.urls import path, reverse -from rest_framework import views +from rest_framework import generics from rest_framework_json_api import serializers -from example.models import Blog +from example.models import Author, Blog # serializers @@ -30,6 +30,9 @@ class EntrySerializer(serializers.Serializer): comment = CommentSerializer(required=False) headline = serializers.CharField(allow_null=True, required=True) body_text = serializers.CharField() + author = serializers.ResourceRelatedField( + queryset=Author.objects.all(), required=False + ) def validate(self, attrs): body_text = attrs["body_text"] @@ -40,13 +43,12 @@ def validate(self, attrs): # view -class DummyTestView(views.APIView): +class DummyTestView(generics.CreateAPIView): serializer_class = EntrySerializer resource_name = "entries" - def post(self, request, *args, **kwargs): - serializer = self.serializer_class(data=request.data) - serializer.is_valid(raise_exception=True) + def get_serializer_context(self): + return {} urlpatterns = [ @@ -191,3 +193,21 @@ def test_many_third_level_dict_errors(client, some_blog, snapshot): } snapshot.assert_match(perform_error_test(client, data)) + + +def test_relationship_errors_has_correct_pointers(client, some_blog, snapshot): + data = { + "data": { + "type": "entries", + "attributes": { + "blog": some_blog.pk, + "bodyText": "body_text", + "headline": "headline", + }, + "relationships": { + "author": {"data": {"id": "INVALID_ID", "type": "authors"}} + }, + } + } + + snapshot.assert_match(perform_error_test(client, data)) diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index e86c0c0b..b84666db 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -65,7 +65,7 @@ def extract_attributes(cls, fields, resource): if fields[field_name].write_only: continue # Skip fields with relations - if isinstance(field, (relations.RelatedField, relations.ManyRelatedField)): + if utils.is_relationship_field(field): continue # Skip read_only attribute fields when `resource` is an empty @@ -105,9 +105,7 @@ def extract_relationships(cls, fields, resource, resource_instance): continue # Skip fields without relations - if not isinstance( - field, (relations.RelatedField, relations.ManyRelatedField) - ): + if not utils.is_relationship_field(field): continue source = field.source @@ -298,9 +296,7 @@ def extract_included( continue # Skip fields without relations - if not isinstance( - field, (relations.RelatedField, relations.ManyRelatedField) - ): + if not utils.is_relationship_field(field): continue try: diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 19c72809..65cbc645 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -13,7 +13,7 @@ from django.http import Http404 from django.utils import encoding from django.utils.translation import gettext_lazy as _ -from rest_framework import exceptions +from rest_framework import exceptions, relations from rest_framework.exceptions import APIException from .settings import json_api_settings @@ -368,6 +368,10 @@ def get_relation_instance(resource_instance, source, serializer): return True, relation_instance +def is_relationship_field(field): + return isinstance(field, (relations.RelatedField, relations.ManyRelatedField)) + + class Hyperlink(str): """ A string like object that additionally has an associated name. @@ -394,9 +398,24 @@ def format_drf_errors(response, context, exc): errors.extend(format_error_object(message, "/data", response)) # handle all errors thrown from serializers else: + # Avoid circular deps + from rest_framework import generics + + has_serializer = isinstance(context["view"], generics.GenericAPIView) + if has_serializer: + serializer = context["view"].get_serializer() + fields = get_serializer_fields(serializer) or dict() + relationship_fields = [ + name for name, field in fields.items() if is_relationship_field(field) + ] + for field, error in response.data.items(): field = format_field_name(field) - pointer = "/data/attributes/{}".format(field) + pointer = None + # pointer can be determined only if there's a serializer. + if has_serializer: + rel = "relationships" if field in relationship_fields else "attributes" + pointer = "/data/{}/{}".format(rel, field) if isinstance(exc, Http404) and isinstance(error, str): # 404 errors don't have a pointer errors.extend(format_error_object(error, None, response)) From e17ea5790df909ef67b4f0cfc5ae8d10ab066573 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 8 Oct 2021 18:07:27 +0400 Subject: [PATCH 093/252] Verified in all fields whether reserved field names are used (#992) In meta class only specifically declared fields in serializer are accessible. In case of `ModelSerializer` fields may be just on the model or if a user has overwritten what fields are being returned during runtime DJA won't notice. So there does not seem to be another way then simply hooking into the `get_fields` method to check for reserved field names. --- rest_framework_json_api/serializers.py | 43 ++++++++++++++------------ tests/test_serializers.py | 2 ++ 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index ee6d15b6..00cd3b38 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -153,6 +153,27 @@ def validate_path(serializer_class, field_path, path): super().__init__(*args, **kwargs) +class ReservedFieldNamesMixin: + """Ensures that reserved field names are not used and an error raised instead.""" + + _reserved_field_names = {"meta", "results"} + + def get_fields(self): + fields = super().get_fields() + + found_reserved_field_names = self._reserved_field_names.intersection( + fields.keys() + ) + if found_reserved_field_names: + raise AttributeError( + f"Serializer class {self.__class__.__module__}.{self.__class__.__qualname__} " + f"uses following reserved field name(s) which is not allowed: " + f"{', '.join(sorted(found_reserved_field_names))}" + ) + + return fields + + class LazySerializersDict(Mapping): """ A dictionary of serializers which lazily import dotted class path and self. @@ -184,25 +205,6 @@ def __repr__(self): class SerializerMetaclass(SerializerMetaclass): - - _reserved_field_names = {"meta", "results"} - - @classmethod - def _get_declared_fields(cls, bases, attrs): - fields = super()._get_declared_fields(bases, attrs) - - found_reserved_field_names = cls._reserved_field_names.intersection( - fields.keys() - ) - if found_reserved_field_names: - raise AttributeError( - f"Serializer class {attrs['__module__']}.{attrs['__qualname__']} uses " - f"following reserved field name(s) which is not allowed: " - f"{', '.join(sorted(found_reserved_field_names))}" - ) - - return fields - def __new__(cls, name, bases, attrs): serializer = super().__new__(cls, name, bases, attrs) @@ -228,6 +230,7 @@ def __new__(cls, name, bases, attrs): class Serializer( IncludedResourcesValidationMixin, SparseFieldsetsMixin, + ReservedFieldNamesMixin, Serializer, metaclass=SerializerMetaclass, ): @@ -251,6 +254,7 @@ class Serializer( class HyperlinkedModelSerializer( IncludedResourcesValidationMixin, SparseFieldsetsMixin, + ReservedFieldNamesMixin, HyperlinkedModelSerializer, metaclass=SerializerMetaclass, ): @@ -271,6 +275,7 @@ class HyperlinkedModelSerializer( class ModelSerializer( IncludedResourcesValidationMixin, SparseFieldsetsMixin, + ReservedFieldNamesMixin, ModelSerializer, metaclass=SerializerMetaclass, ): diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 505111ca..2c433c2a 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -43,6 +43,8 @@ class ReservedFieldNamesSerializer(serializers.Serializer): meta = serializers.CharField() results = serializers.CharField() + ReservedFieldNamesSerializer().fields + assert str(e.value) == ( "Serializer class tests.test_serializers.test_reserved_field_names.." "ReservedFieldNamesSerializer uses following reserved field name(s) which is " From 06c3ef4c03e1a329c158cdde19b504ee1b9dcf0b Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 11 Oct 2021 20:56:16 +0400 Subject: [PATCH 094/252] Deprecated usage of `type` field name (#993) This support was added in #376 but only for non polymorphic fields. However as per specification [0] `type` must not be a field name and therefore must be forbidden in DJA as well. Some dependents might depend on being allowed to have a field name `type` so deprecating it now and remove it in next major version. [0] https://jsonapi.org/format/#document-resource-object-fields --- CHANGELOG.md | 1 + example/tests/test_model_viewsets.py | 26 +++++++++++++++----------- rest_framework_json_api/parsers.py | 2 +- rest_framework_json_api/serializers.py | 13 +++++++++++++ setup.cfg | 4 ++++ tests/test_serializers.py | 9 +++++++++ 6 files changed, 43 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 217ebb14..db78a996 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Deprecated * Deprecated `get_included_serializers(serializer)` function under `rest_framework_json_api.utils`. Use `serializer.included_serializers` instead. +* Deprecated support for field name `type` as it may not be used according to the [JSON:API spec](https://jsonapi.org/format/#document-resource-object-fields). ## [4.2.1] - 2021-07-06 diff --git a/example/tests/test_model_viewsets.py b/example/tests/test_model_viewsets.py index 9d4a610f..a5bbcc00 100644 --- a/example/tests/test_model_viewsets.py +++ b/example/tests/test_model_viewsets.py @@ -223,17 +223,21 @@ def test_patch_allow_field_type(author, author_type_factory, client): """ Verify that type field may be updated. """ - author_type = author_type_factory() - url = reverse("author-detail", args=[author.id]) - - data = { - "data": { - "id": author.id, - "type": "authors", - "relationships": {"data": {"id": author_type.id, "type": "author-type"}}, + # TODO remove in next major version 5.0.0 see serializers.ReservedFieldNamesMixin + with pytest.deprecated_call(): + author_type = author_type_factory() + url = reverse("author-detail", args=[author.id]) + + data = { + "data": { + "id": author.id, + "type": "authors", + "relationships": { + "data": {"id": author_type.id, "type": "author-type"} + }, + } } - } - response = client.patch(url, data=data) + response = client.patch(url, data=data) - assert response.status_code == 200 + assert response.status_code == 200 diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index 4c04fd52..93767a07 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -162,7 +162,7 @@ def parse(self, stream, media_type=None, parser_context=None): # Construct the return data serializer_class = getattr(view, "serializer_class", None) parsed_data = {"id": data.get("id")} if "id" in data else {} - # `type` field needs to be allowed in none polymorphic serializers + # TODO remove in next major version 5.0.0 see serializers.ReservedFieldNamesMixin if serializer_class is not None: if issubclass(serializer_class, serializers.PolymorphicModelSerializer): parsed_data["type"] = data.get("type") diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 00cd3b38..d2ec5b53 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -1,3 +1,4 @@ +import warnings from collections import OrderedDict from collections.abc import Mapping @@ -171,6 +172,18 @@ def get_fields(self): f"{', '.join(sorted(found_reserved_field_names))}" ) + if "type" in fields: + # see https://jsonapi.org/format/#document-resource-object-fields + warnings.warn( + DeprecationWarning( + f"Field name 'type' found in serializer class " + f"{self.__class__.__module__}.{self.__class__.__qualname__} " + f"which is not allowed according to the JSON:API spec and " + f"won't be supported anymore in the next major DJA release. " + f"Rename 'type' field to something else. " + ) + ) + return fields diff --git a/setup.cfg b/setup.cfg index 83f6fa37..31e61331 100644 --- a/setup.cfg +++ b/setup.cfg @@ -60,6 +60,10 @@ filterwarnings = error::PendingDeprecationWarning # Django Debug Toolbar currently (2021-04-07) specifies default_app_config which is deprecated in Django 3.2: ignore:'debug_toolbar' defines default_app_config = 'debug_toolbar.apps.DebugToolbarConfig'. Django now detects this configuration automatically. You can remove default_app_config.:PendingDeprecationWarning + # TODO remove in next major version of DJA 5.0.0 + # this deprecation warning filter needs to be added as AuthorSerializer is used in + # too many tests which introduced the type field name in tests + ignore:Field name 'type' testpaths = example tests diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 2c433c2a..70bb140f 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -50,3 +50,12 @@ class ReservedFieldNamesSerializer(serializers.Serializer): "ReservedFieldNamesSerializer uses following reserved field name(s) which is " "not allowed: meta, results" ) + + +def test_serializer_fields_deprecated_field_name_type(): + with pytest.deprecated_call(): + + class TypeFieldNameSerializer(serializers.Serializer): + type = serializers.CharField() + + TypeFieldNameSerializer().fields From 7d633f03973d1e0f02e196889fdb652160b9e18a Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 18 Oct 2021 17:36:09 +0400 Subject: [PATCH 095/252] Bump to newer codecov action (#996) --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a48a7915..a38766c7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,7 +29,7 @@ jobs: - name: Test with tox run: tox - name: Upload coverage report - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v2 with: env_vars: PYTHON,DJANGO,DJANGO_REST_FRAMEWORK check: From c763b978ff553d1388849b756453724bec15d1c2 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 19 Oct 2021 12:14:20 -0500 Subject: [PATCH 096/252] Scheduled biweekly dependency update for week 42 (#997) * Update flake8 from 3.9.2 to 4.0.1 * Update flake8-isort from 4.0.0 to 4.1.1 * Update pyyaml from 5.4.1 to 6.0 * Update uritemplate from 3.0.1 to 4.1.1 * Update faker from 8.14.1 to 9.3.1 --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-optionals.txt | 4 ++-- requirements/requirements-testing.txt | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 4e7090cb..55628500 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ black==21.9b0 -flake8==3.9.2 -flake8-isort==4.0.0 +flake8==4.0.1 +flake8-isort==4.1.1 isort==5.9.3 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 4d543da3..1db26c07 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,4 +1,4 @@ django-filter==21.1 django-polymorphic==3.0.0 -pyyaml==5.4.1 -uritemplate==3.0.1 +pyyaml==6.0 +uritemplate==4.1.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index a64d5b2c..5ac6e9c9 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,6 +1,6 @@ django-debug-toolbar==3.2.2 factory-boy==3.2.0 -Faker==8.14.1 +Faker==9.3.1 pytest==6.2.5 pytest-cov==3.0.0 pytest-django==4.4.0 From c4337f15f59811c4e72f33cf6eb3a4667cb4b197 Mon Sep 17 00:00:00 2001 From: Asif Saif Uddin Date: Thu, 21 Oct 2021 00:21:00 +0600 Subject: [PATCH 097/252] clean up python 2 code (#976) --- docs/conf.py | 2 +- example/api/resources/identity.py | 2 +- example/factories.py | 2 -- example/migrations/0001_initial.py | 4 ---- example/migrations/0002_taggeditem.py | 4 ---- example/migrations/0003_polymorphics.py | 4 ---- example/migrations/0004_auto_20171011_0631.py | 4 ---- example/models.py | 3 --- example/serializers.py | 4 ++-- example/tests/__init__.py | 2 +- example/tests/snapshots/snap_test_errors.py | 4 ---- example/tests/snapshots/snap_test_openapi.py | 4 ---- example/tests/test_format_keys.py | 2 +- example/tests/test_generic_validation.py | 2 +- example/tests/test_model_viewsets.py | 2 +- example/tests/test_serializers.py | 2 +- example/tests/unit/test_filter_schema_params.py | 2 +- example/tests/unit/test_renderer_class_methods.py | 6 ++---- example/tests/unit/test_serializer_method_field.py | 2 -- example/views.py | 10 +++++----- rest_framework_json_api/__init__.py | 2 -- rest_framework_json_api/django_filters/backends.py | 2 +- rest_framework_json_api/filters.py | 4 +--- rest_framework_json_api/parsers.py | 2 +- rest_framework_json_api/renderers.py | 4 +--- rest_framework_json_api/serializers.py | 2 +- setup.py | 3 +-- 27 files changed, 23 insertions(+), 63 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index cd9266ce..9038cd2b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- +# # # Django REST framework JSON:API documentation build configuration file, created by # sphinx-quickstart on Fri Jul 24 23:31:15 2015. diff --git a/example/api/resources/identity.py b/example/api/resources/identity.py index a291ba4f..12705553 100644 --- a/example/api/resources/identity.py +++ b/example/api/resources/identity.py @@ -33,7 +33,7 @@ def posts(self, request): @action(detail=True) def manual_resource_name(self, request, *args, **kwargs): self.resource_name = "data" - return super(Identity, self).retrieve(request, args, kwargs) + return super().retrieve(request, args, kwargs) @action(detail=True) def validation(self, request, *args, **kwargs): diff --git a/example/factories.py b/example/factories.py index 0de561f6..96b85cad 100644 --- a/example/factories.py +++ b/example/factories.py @@ -1,5 +1,3 @@ -# -*- encoding: utf-8 -*- - import factory from faker import Factory as FakerFactory diff --git a/example/migrations/0001_initial.py b/example/migrations/0001_initial.py index 35e01afe..0161cd49 100644 --- a/example/migrations/0001_initial.py +++ b/example/migrations/0001_initial.py @@ -1,7 +1,3 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.5 on 2016-05-02 08:26 -from __future__ import unicode_literals - import django.db.models.deletion from django.db import migrations, models diff --git a/example/migrations/0002_taggeditem.py b/example/migrations/0002_taggeditem.py index 3a57d22f..0abf49e0 100644 --- a/example/migrations/0002_taggeditem.py +++ b/example/migrations/0002_taggeditem.py @@ -1,7 +1,3 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10.5 on 2017-02-01 08:34 -from __future__ import unicode_literals - import django.db.models.deletion from django.db import migrations, models diff --git a/example/migrations/0003_polymorphics.py b/example/migrations/0003_polymorphics.py index 46919dbb..1bae8491 100644 --- a/example/migrations/0003_polymorphics.py +++ b/example/migrations/0003_polymorphics.py @@ -1,7 +1,3 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.1 on 2017-05-17 14:49 -from __future__ import unicode_literals - import django.db.models.deletion from django.db import migrations, models diff --git a/example/migrations/0004_auto_20171011_0631.py b/example/migrations/0004_auto_20171011_0631.py index b1035dfe..fa597a29 100644 --- a/example/migrations/0004_auto_20171011_0631.py +++ b/example/migrations/0004_auto_20171011_0631.py @@ -1,7 +1,3 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.6 on 2017-10-11 06:31 -from __future__ import unicode_literals - import django.db.models.deletion from django.db import migrations, models diff --git a/example/models.py b/example/models.py index 18b965a3..63d93e33 100644 --- a/example/models.py +++ b/example/models.py @@ -1,6 +1,3 @@ -# -*- encoding: utf-8 -*- -from __future__ import unicode_literals - from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType from django.db import models diff --git a/example/serializers.py b/example/serializers.py index 43415243..0e1022cc 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -83,7 +83,7 @@ class Meta: class EntrySerializer(serializers.ModelSerializer): def __init__(self, *args, **kwargs): - super(EntrySerializer, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # to make testing more concise we'll only output the # `featured` field when it's requested via `include` request = kwargs.get("context", {}).get("request") @@ -379,7 +379,7 @@ class Meta: class CurrentProjectRelatedField(relations.PolymorphicResourceRelatedField): def get_attribute(self, instance): - obj = super(CurrentProjectRelatedField, self).get_attribute(instance) + obj = super().get_attribute(instance) is_art = self.field_name == "current_art_project" and isinstance( obj, ArtProject diff --git a/example/tests/__init__.py b/example/tests/__init__.py index daca2310..94a86486 100644 --- a/example/tests/__init__.py +++ b/example/tests/__init__.py @@ -11,7 +11,7 @@ def setUp(self): """ Create those users """ - super(TestBase, self).setUp() + super().setUp() self.create_users() def create_user(self, username, email, password="pw", first_name="", last_name=""): diff --git a/example/tests/snapshots/snap_test_errors.py b/example/tests/snapshots/snap_test_errors.py index dd5ca4f8..09bc25ef 100644 --- a/example/tests/snapshots/snap_test_errors.py +++ b/example/tests/snapshots/snap_test_errors.py @@ -1,7 +1,3 @@ -# -*- coding: utf-8 -*- -# snapshottest: v1 - https://goo.gl/zC4yUc -from __future__ import unicode_literals - from snapshottest import Snapshot snapshots = Snapshot() diff --git a/example/tests/snapshots/snap_test_openapi.py b/example/tests/snapshots/snap_test_openapi.py index f89fb442..a0e6e78e 100644 --- a/example/tests/snapshots/snap_test_openapi.py +++ b/example/tests/snapshots/snap_test_openapi.py @@ -1,7 +1,3 @@ -# -*- coding: utf-8 -*- -# snapshottest: v1 - https://goo.gl/zC4yUc -from __future__ import unicode_literals - from snapshottest import Snapshot snapshots = Snapshot() diff --git a/example/tests/test_format_keys.py b/example/tests/test_format_keys.py index 698a8bb1..8d99eec7 100644 --- a/example/tests/test_format_keys.py +++ b/example/tests/test_format_keys.py @@ -14,7 +14,7 @@ class FormatKeysSetTests(TestBase): list_url = reverse("user-list") def setUp(self): - super(FormatKeysSetTests, self).setUp() + super().setUp() self.detail_url = reverse("user-detail", kwargs={"pk": self.miles.pk}) def test_camelization(self): diff --git a/example/tests/test_generic_validation.py b/example/tests/test_generic_validation.py index 99d2d09a..1c3561e7 100644 --- a/example/tests/test_generic_validation.py +++ b/example/tests/test_generic_validation.py @@ -9,7 +9,7 @@ class GenericValidationTest(TestBase): """ def setUp(self): - super(GenericValidationTest, self).setUp() + super().setUp() self.url = reverse("user-validation", kwargs={"pk": self.miles.pk}) def test_generic_validation_error(self): diff --git a/example/tests/test_model_viewsets.py b/example/tests/test_model_viewsets.py index a5bbcc00..c58b5131 100644 --- a/example/tests/test_model_viewsets.py +++ b/example/tests/test_model_viewsets.py @@ -19,7 +19,7 @@ class ModelViewSetTests(TestBase): list_url = reverse("user-list") def setUp(self): - super(ModelViewSetTests, self).setUp() + super().setUp() self.detail_url = reverse("user-detail", kwargs={"pk": self.miles.pk}) def test_key_in_list_result(self): diff --git a/example/tests/test_serializers.py b/example/tests/test_serializers.py index 9cfe6db9..8f14fed8 100644 --- a/example/tests/test_serializers.py +++ b/example/tests/test_serializers.py @@ -179,7 +179,7 @@ def test_deserialize_many(self): print(serializer.data) -class TestModelSerializer(object): +class TestModelSerializer: def test_model_serializer_with_implicit_fields(self, comment, client): expected = { "data": { diff --git a/example/tests/unit/test_filter_schema_params.py b/example/tests/unit/test_filter_schema_params.py index 9c78dc61..f50304ac 100644 --- a/example/tests/unit/test_filter_schema_params.py +++ b/example/tests/unit/test_filter_schema_params.py @@ -22,7 +22,7 @@ class DummyEntryViewSet(EntryViewSet): def __init__(self, **kwargs): # dummy up self.request since PreloadIncludesMixin expects it to be defined self.request = None - super(DummyEntryViewSet, self).__init__(**kwargs) + super().__init__(**kwargs) def test_filters_get_schema_params(): diff --git a/example/tests/unit/test_renderer_class_methods.py b/example/tests/unit/test_renderer_class_methods.py index 51cc2481..6e7c9ea1 100644 --- a/example/tests/unit/test_renderer_class_methods.py +++ b/example/tests/unit/test_renderer_class_methods.py @@ -70,14 +70,12 @@ class CustomRenderer(JSONRenderer): @classmethod def extract_attributes(cls, fields, resource): cls.extract_attributes_was_overriden = True - return super(CustomRenderer, cls).extract_attributes(fields, resource) + return super().extract_attributes(fields, resource) @classmethod def extract_relationships(cls, fields, resource, resource_instance): cls.extract_relationships_was_overriden = True - return super(CustomRenderer, cls).extract_relationships( - fields, resource, resource_instance - ) + return super().extract_relationships(fields, resource, resource_instance) assert ( CustomRenderer.build_json_resource_obj( diff --git a/example/tests/unit/test_serializer_method_field.py b/example/tests/unit/test_serializer_method_field.py index 37e74ce6..1be56f47 100644 --- a/example/tests/unit/test_serializer_method_field.py +++ b/example/tests/unit/test_serializer_method_field.py @@ -1,5 +1,3 @@ -from __future__ import absolute_import - from rest_framework import serializers from rest_framework_json_api.relations import SerializerMethodResourceRelatedField diff --git a/example/views.py b/example/views.py index 64d31780..27ec6c2e 100644 --- a/example/views.py +++ b/example/views.py @@ -57,7 +57,7 @@ def get_object(self): if entry_pk is not None: return Entry.objects.get(id=entry_pk).blog - return super(BlogViewSet, self).get_object() + return super().get_object() class DRFBlogViewSet(ModelViewSet): @@ -70,7 +70,7 @@ def get_object(self): if entry_pk is not None: return Entry.objects.get(id=entry_pk).blog - return super(DRFBlogViewSet, self).get_object() + return super().get_object() class JsonApiViewSet(ModelViewSet): @@ -98,7 +98,7 @@ 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().handle_exception(exc) context = self.get_exception_handler_context() return format_drf_errors(response, context, exc) @@ -121,7 +121,7 @@ def get_object(self): if entry_pk is not None: return Entry.objects.exclude(pk=entry_pk).first() - return super(EntryViewSet, self).get_object() + return super().get_object() class DRFEntryViewSet(ModelViewSet): @@ -135,7 +135,7 @@ def get_object(self): if entry_pk is not None: return Entry.objects.exclude(pk=entry_pk).first() - return super(DRFEntryViewSet, self).get_object() + return super().get_object() class NoPagination(JsonApiPageNumberPagination): diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index 8f8e5dd7..6add109f 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - __title__ = "djangorestframework-jsonapi" __version__ = "4.2.1" __author__ = "" diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index 72ae873e..61518e1c 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -138,7 +138,7 @@ def get_schema_operation_parameters(self, view): This is basically the reverse of `get_filterset_kwargs` above. """ - result = super(DjangoFilterBackend, self).get_schema_operation_parameters(view) + result = super().get_schema_operation_parameters(view) for res in result: if "name" in res: res["name"] = "filter[{}]".format(res["name"]).replace("__", ".") diff --git a/rest_framework_json_api/filters.py b/rest_framework_json_api/filters.py index d5dd337c..ba3794cb 100644 --- a/rest_framework_json_api/filters.py +++ b/rest_framework_json_api/filters.py @@ -61,9 +61,7 @@ def remove_invalid_fields(self, queryset, fields, view, request): else: underscore_fields.append(undo_format_field_name(item_rewritten)) - return super(OrderingFilter, self).remove_invalid_fields( - queryset, underscore_fields, view, request - ) + return super().remove_invalid_fields(queryset, underscore_fields, view, request) class QueryParameterValidationFilter(BaseFilterBackend): diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index 93767a07..61824749 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -74,7 +74,7 @@ def parse(self, stream, media_type=None, parser_context=None): """ Parses the incoming bytestream as JSON and returns the resulting data """ - result = super(JSONParser, self).parse( + result = super().parse( stream, media_type=media_type, parser_context=parser_context ) diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index b84666db..db297870 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -662,9 +662,7 @@ class BrowsableAPIRenderer(renderers.BrowsableAPIRenderer): includes_template = "rest_framework_json_api/includes.html" def get_context(self, data, accepted_media_type, renderer_context): - context = super(BrowsableAPIRenderer, self).get_context( - data, accepted_media_type, renderer_context - ) + context = super().get_context(data, accepted_media_type, renderer_context) view = renderer_context["view"] context["includes_form"] = self.get_includes_form(view) diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index d2ec5b53..0b4a8dfd 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -328,7 +328,7 @@ def get_field_names(self, declared_fields, info): field = declared_fields[field_name] if field_name not in meta_fields: declared[field_name] = field - fields = super(ModelSerializer, self).get_field_names(declared, info) + fields = super().get_field_names(declared, info) return list(fields) + list(getattr(self.Meta, "meta_fields", list())) diff --git a/setup.py b/setup.py index eff9d026..08712129 100755 --- a/setup.py +++ b/setup.py @@ -1,5 +1,4 @@ -#!/usr/bin/env python -from __future__ import print_function +#!/usr/bin/env python3 import os import re From 2d00b013e6e7c7f3c55641fd1fdc97f0bfe8ca4e Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 27 Oct 2021 21:30:55 +0400 Subject: [PATCH 098/252] Add codecov configuration (#1002) * Ensure that PR covers 100% of diff * Disable project coverage as it may be misleading * Disable github comment to avoid notifications --- .codecov.yml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .codecov.yml diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 00000000..78f7b94d --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,7 @@ +coverage: + status: + patch: + default: + target: 100% + project: false +comment: false From f32e8232c2dced3da2c2f9713500ae74f4e3ac8f Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 10 Nov 2021 20:25:16 +0400 Subject: [PATCH 099/252] Migrate to syrupy snapshot testing (#1005) --- example/tests/__snapshots__/test_errors.ambr | 133 ++++ example/tests/__snapshots__/test_openapi.ambr | 669 ++++++++++++++++++ example/tests/snapshots/__init__.py | 0 example/tests/snapshots/snap_test_errors.py | 107 --- example/tests/snapshots/snap_test_openapi.py | 667 ----------------- example/tests/test_errors.py | 18 +- example/tests/test_openapi.py | 10 +- requirements/requirements-testing.txt | 2 +- 8 files changed, 817 insertions(+), 789 deletions(-) create mode 100644 example/tests/__snapshots__/test_errors.ambr create mode 100644 example/tests/__snapshots__/test_openapi.ambr delete mode 100644 example/tests/snapshots/__init__.py delete mode 100644 example/tests/snapshots/snap_test_errors.py delete mode 100644 example/tests/snapshots/snap_test_openapi.py diff --git a/example/tests/__snapshots__/test_errors.ambr b/example/tests/__snapshots__/test_errors.ambr new file mode 100644 index 00000000..d025fd4e --- /dev/null +++ b/example/tests/__snapshots__/test_errors.ambr @@ -0,0 +1,133 @@ +# name: test_first_level_attribute_error + { + 'errors': [ + { + 'code': 'required', + 'detail': 'This field is required.', + 'source': { + 'pointer': '/data/attributes/headline', + }, + 'status': '400', + }, + ], + } +--- +# name: test_first_level_custom_attribute_error + { + 'errors': [ + { + 'detail': 'Too short', + 'source': { + 'pointer': '/data/attributes/body-text', + }, + 'title': 'Too Short title', + }, + ], + } +--- +# name: test_many_third_level_dict_errors + { + 'errors': [ + { + 'code': 'required', + 'detail': 'This field is required.', + 'source': { + 'pointer': '/data/attributes/comments/0/attachment/data', + }, + 'status': '400', + }, + { + 'code': 'required', + 'detail': 'This field is required.', + 'source': { + 'pointer': '/data/attributes/comments/0/body', + }, + 'status': '400', + }, + ], + } +--- +# name: test_relationship_errors_has_correct_pointers + { + 'errors': [ + { + 'code': 'incorrect_type', + 'detail': 'Incorrect type. Expected resource identifier object, received str.', + 'source': { + 'pointer': '/data/relationships/author', + }, + 'status': '400', + }, + ], + } +--- +# name: test_second_level_array_error + { + 'errors': [ + { + 'code': 'required', + 'detail': 'This field is required.', + 'source': { + 'pointer': '/data/attributes/comments/0/body', + }, + 'status': '400', + }, + ], + } +--- +# name: test_second_level_dict_error + { + 'errors': [ + { + 'code': 'required', + 'detail': 'This field is required.', + 'source': { + 'pointer': '/data/attributes/comment/body', + }, + 'status': '400', + }, + ], + } +--- +# name: test_third_level_array_error + { + 'errors': [ + { + 'code': 'required', + 'detail': 'This field is required.', + 'source': { + 'pointer': '/data/attributes/comments/0/attachments/0/data', + }, + 'status': '400', + }, + ], + } +--- +# name: test_third_level_custom_array_error + { + 'errors': [ + { + 'code': 'invalid', + 'detail': 'Too short data', + 'source': { + 'pointer': '/data/attributes/comments/0/attachments/0/data', + }, + 'status': '400', + }, + ], + } +--- +# name: test_third_level_dict_error + { + 'errors': [ + { + 'code': 'required', + 'detail': 'This field is required.', + 'source': { + 'pointer': '/data/attributes/comments/0/attachment/data', + }, + 'status': '400', + }, + ], + } +--- diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr new file mode 100644 index 00000000..28044c74 --- /dev/null +++ b/example/tests/__snapshots__/test_openapi.ambr @@ -0,0 +1,669 @@ +# name: test_delete_request + ' + { + "description": "", + "operationId": "destroy/authors/{id}", + "parameters": [ + { + "description": "A unique integer value identifying this author.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/onlymeta" + } + } + }, + "description": "[OK](https://jsonapi.org/format/#crud-deleting-responses-200)" + }, + "202": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/datum" + } + } + }, + "description": "Accepted for [asynchronous processing](https://jsonapi.org/recommendations/#asynchronous-processing)" + }, + "204": { + "description": "[no content](https://jsonapi.org/format/#crud-deleting-responses-204)" + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Resource does not exist](https://jsonapi.org/format/#crud-deleting-responses-404)" + } + }, + "tags": [ + "authors" + ] + } + ' +--- +# name: test_patch_request + ' + { + "description": "", + "operationId": "update/authors/{id}", + "parameters": [ + { + "description": "A unique integer value identifying this author.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "attributes": { + "properties": { + "defaults": { + "default": "default", + "description": "help for defaults", + "maxLength": 20, + "minLength": 3, + "type": "string", + "writeOnly": true + }, + "email": { + "format": "email", + "maxLength": 254, + "type": "string" + }, + "name": { + "maxLength": 50, + "type": "string" + } + }, + "type": "object" + }, + "id": { + "$ref": "#/components/schemas/id" + }, + "links": { + "properties": { + "self": { + "$ref": "#/components/schemas/link" + } + }, + "type": "object" + }, + "relationships": { + "properties": { + "bio": { + "$ref": "#/components/schemas/reltoone" + }, + "comments": { + "$ref": "#/components/schemas/reltomany" + }, + "entries": { + "$ref": "#/components/schemas/reltomany" + }, + "first_entry": { + "$ref": "#/components/schemas/reltoone" + }, + "type": { + "$ref": "#/components/schemas/reltoone" + } + }, + "type": "object" + }, + "type": { + "$ref": "#/components/schemas/type" + } + }, + "required": [ + "type", + "id" + ], + "type": "object" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "200": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/Author" + }, + "included": { + "items": { + "$ref": "#/components/schemas/resource" + }, + "type": "array", + "uniqueItems": true + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi" + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/links" + }, + { + "$ref": "#/components/schemas/pagination" + } + ], + "description": "Link members related to primary data" + } + }, + "required": [ + "data" + ], + "type": "object" + } + } + }, + "description": "update/authors/{id}" + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "403": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Forbidden](https://jsonapi.org/format/#crud-updating-responses-403)" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Related resource does not exist](https://jsonapi.org/format/#crud-updating-responses-404)" + }, + "409": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Conflict]([Conflict](https://jsonapi.org/format/#crud-updating-responses-409)" + } + }, + "tags": [ + "authors" + ] + } + ' +--- +# name: test_path_with_id_parameter + ' + { + "description": "", + "operationId": "retrieve/authors/{id}/", + "parameters": [ + { + "description": "A unique integer value identifying this author.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/include" + }, + { + "$ref": "#/components/parameters/fields" + }, + { + "$ref": "#/components/parameters/sort" + }, + { + "description": "Which field to use when ordering the results.", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "A search term.", + "in": "query", + "name": "filter[search]", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/AuthorDetail" + }, + "included": { + "items": { + "$ref": "#/components/schemas/resource" + }, + "type": "array", + "uniqueItems": true + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi" + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/links" + }, + { + "$ref": "#/components/schemas/pagination" + } + ], + "description": "Link members related to primary data" + } + }, + "required": [ + "data" + ], + "type": "object" + } + } + }, + "description": "retrieve/authors/{id}/" + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not found" + } + }, + "tags": [ + "authors" + ] + } + ' +--- +# name: test_path_without_parameters + ' + { + "description": "", + "operationId": "List/authors/", + "parameters": [ + { + "$ref": "#/components/parameters/include" + }, + { + "$ref": "#/components/parameters/fields" + }, + { + "$ref": "#/components/parameters/sort" + }, + { + "description": "A page number within the paginated result set.", + "in": "query", + "name": "page[number]", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Number of results to return per page.", + "in": "query", + "name": "page[size]", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Which field to use when ordering the results.", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "A search term.", + "in": "query", + "name": "filter[search]", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/AuthorList" + }, + "type": "array" + }, + "included": { + "items": { + "$ref": "#/components/schemas/resource" + }, + "type": "array", + "uniqueItems": true + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi" + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/links" + }, + { + "$ref": "#/components/schemas/pagination" + } + ], + "description": "Link members related to primary data" + } + }, + "required": [ + "data" + ], + "type": "object" + } + } + }, + "description": "List/authors/" + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not found" + } + }, + "tags": [ + "authors" + ] + } + ' +--- +# name: test_post_request + ' + { + "description": "", + "operationId": "create/authors/", + "parameters": [], + "requestBody": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "additionalProperties": false, + "properties": { + "attributes": { + "properties": { + "defaults": { + "default": "default", + "description": "help for defaults", + "maxLength": 20, + "minLength": 3, + "type": "string", + "writeOnly": true + }, + "email": { + "format": "email", + "maxLength": 254, + "type": "string" + }, + "name": { + "maxLength": 50, + "type": "string" + } + }, + "required": [ + "name", + "email" + ], + "type": "object" + }, + "id": { + "$ref": "#/components/schemas/id" + }, + "links": { + "properties": { + "self": { + "$ref": "#/components/schemas/link" + } + }, + "type": "object" + }, + "relationships": { + "properties": { + "bio": { + "$ref": "#/components/schemas/reltoone" + }, + "comments": { + "$ref": "#/components/schemas/reltomany" + }, + "entries": { + "$ref": "#/components/schemas/reltomany" + }, + "first_entry": { + "$ref": "#/components/schemas/reltoone" + }, + "type": { + "$ref": "#/components/schemas/reltoone" + } + }, + "type": "object" + }, + "type": { + "$ref": "#/components/schemas/type" + } + }, + "required": [ + "type" + ], + "type": "object" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "responses": { + "201": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/Author" + }, + "included": { + "items": { + "$ref": "#/components/schemas/resource" + }, + "type": "array", + "uniqueItems": true + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi" + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/links" + }, + { + "$ref": "#/components/schemas/pagination" + } + ], + "description": "Link members related to primary data" + } + }, + "required": [ + "data" + ], + "type": "object" + } + } + }, + "description": "[Created](https://jsonapi.org/format/#crud-creating-responses-201). Assigned `id` and/or any other changes are in this response." + }, + "202": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/datum" + } + } + }, + "description": "Accepted for [asynchronous processing](https://jsonapi.org/recommendations/#asynchronous-processing)" + }, + "204": { + "description": "[Created](https://jsonapi.org/format/#crud-creating-responses-204) with the supplied `id`. No other changes from what was POSTed." + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "403": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Forbidden](https://jsonapi.org/format/#crud-creating-responses-403)" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Related resource does not exist](https://jsonapi.org/format/#crud-creating-responses-404)" + }, + "409": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "[Conflict](https://jsonapi.org/format/#crud-creating-responses-409)" + } + }, + "tags": [ + "authors" + ] + } + ' +--- diff --git a/example/tests/snapshots/__init__.py b/example/tests/snapshots/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/example/tests/snapshots/snap_test_errors.py b/example/tests/snapshots/snap_test_errors.py deleted file mode 100644 index 09bc25ef..00000000 --- a/example/tests/snapshots/snap_test_errors.py +++ /dev/null @@ -1,107 +0,0 @@ -from snapshottest import Snapshot - -snapshots = Snapshot() - -snapshots["test_first_level_attribute_error 1"] = { - "errors": [ - { - "code": "required", - "detail": "This field is required.", - "source": {"pointer": "/data/attributes/headline"}, - "status": "400", - } - ] -} - -snapshots["test_first_level_custom_attribute_error 1"] = { - "errors": [ - { - "detail": "Too short", - "source": {"pointer": "/data/attributes/body-text"}, - "title": "Too Short title", - } - ] -} - -snapshots["test_many_third_level_dict_errors 1"] = { - "errors": [ - { - "code": "required", - "detail": "This field is required.", - "source": {"pointer": "/data/attributes/comments/0/attachment/data"}, - "status": "400", - }, - { - "code": "required", - "detail": "This field is required.", - "source": {"pointer": "/data/attributes/comments/0/body"}, - "status": "400", - }, - ] -} - -snapshots["test_relationship_errors_has_correct_pointers 1"] = { - "errors": [ - { - "code": "incorrect_type", - "detail": "Incorrect type. Expected resource identifier object, received str.", - "source": {"pointer": "/data/relationships/author"}, - "status": "400", - } - ] -} - -snapshots["test_second_level_array_error 1"] = { - "errors": [ - { - "code": "required", - "detail": "This field is required.", - "source": {"pointer": "/data/attributes/comments/0/body"}, - "status": "400", - } - ] -} - -snapshots["test_second_level_dict_error 1"] = { - "errors": [ - { - "code": "required", - "detail": "This field is required.", - "source": {"pointer": "/data/attributes/comment/body"}, - "status": "400", - } - ] -} - -snapshots["test_third_level_array_error 1"] = { - "errors": [ - { - "code": "required", - "detail": "This field is required.", - "source": {"pointer": "/data/attributes/comments/0/attachments/0/data"}, - "status": "400", - } - ] -} - -snapshots["test_third_level_custom_array_error 1"] = { - "errors": [ - { - "code": "invalid", - "detail": "Too short data", - "source": {"pointer": "/data/attributes/comments/0/attachments/0/data"}, - "status": "400", - } - ] -} - -snapshots["test_third_level_dict_error 1"] = { - "errors": [ - { - "code": "required", - "detail": "This field is required.", - "source": {"pointer": "/data/attributes/comments/0/attachment/data"}, - "status": "400", - } - ] -} diff --git a/example/tests/snapshots/snap_test_openapi.py b/example/tests/snapshots/snap_test_openapi.py deleted file mode 100644 index a0e6e78e..00000000 --- a/example/tests/snapshots/snap_test_openapi.py +++ /dev/null @@ -1,667 +0,0 @@ -from snapshottest import Snapshot - -snapshots = Snapshot() - -snapshots[ - "test_path_without_parameters 1" -] = """{ - "description": "", - "operationId": "List/authors/", - "parameters": [ - { - "$ref": "#/components/parameters/include" - }, - { - "$ref": "#/components/parameters/fields" - }, - { - "$ref": "#/components/parameters/sort" - }, - { - "description": "A page number within the paginated result set.", - "in": "query", - "name": "page[number]", - "required": false, - "schema": { - "type": "integer" - } - }, - { - "description": "Number of results to return per page.", - "in": "query", - "name": "page[size]", - "required": false, - "schema": { - "type": "integer" - } - }, - { - "description": "Which field to use when ordering the results.", - "in": "query", - "name": "sort", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "A search term.", - "in": "query", - "name": "filter[search]", - "required": false, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "items": { - "$ref": "#/components/schemas/AuthorList" - }, - "type": "array" - }, - "included": { - "items": { - "$ref": "#/components/schemas/resource" - }, - "type": "array", - "uniqueItems": true - }, - "jsonapi": { - "$ref": "#/components/schemas/jsonapi" - }, - "links": { - "allOf": [ - { - "$ref": "#/components/schemas/links" - }, - { - "$ref": "#/components/schemas/pagination" - } - ], - "description": "Link members related to primary data" - } - }, - "required": [ - "data" - ], - "type": "object" - } - } - }, - "description": "List/authors/" - }, - "401": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not authorized" - }, - "404": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not found" - } - }, - "tags": [ - "authors" - ] -}""" - -snapshots[ - "test_path_with_id_parameter 1" -] = """{ - "description": "", - "operationId": "retrieve/authors/{id}/", - "parameters": [ - { - "description": "A unique integer value identifying this author.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - }, - { - "$ref": "#/components/parameters/include" - }, - { - "$ref": "#/components/parameters/fields" - }, - { - "$ref": "#/components/parameters/sort" - }, - { - "description": "Which field to use when ordering the results.", - "in": "query", - "name": "sort", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "A search term.", - "in": "query", - "name": "filter[search]", - "required": false, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/AuthorDetail" - }, - "included": { - "items": { - "$ref": "#/components/schemas/resource" - }, - "type": "array", - "uniqueItems": true - }, - "jsonapi": { - "$ref": "#/components/schemas/jsonapi" - }, - "links": { - "allOf": [ - { - "$ref": "#/components/schemas/links" - }, - { - "$ref": "#/components/schemas/pagination" - } - ], - "description": "Link members related to primary data" - } - }, - "required": [ - "data" - ], - "type": "object" - } - } - }, - "description": "retrieve/authors/{id}/" - }, - "401": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not authorized" - }, - "404": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not found" - } - }, - "tags": [ - "authors" - ] -}""" - -snapshots[ - "test_post_request 1" -] = """{ - "description": "", - "operationId": "create/authors/", - "parameters": [], - "requestBody": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "additionalProperties": false, - "properties": { - "attributes": { - "properties": { - "defaults": { - "default": "default", - "description": "help for defaults", - "maxLength": 20, - "minLength": 3, - "type": "string", - "writeOnly": true - }, - "email": { - "format": "email", - "maxLength": 254, - "type": "string" - }, - "name": { - "maxLength": 50, - "type": "string" - } - }, - "required": [ - "name", - "email" - ], - "type": "object" - }, - "id": { - "$ref": "#/components/schemas/id" - }, - "links": { - "properties": { - "self": { - "$ref": "#/components/schemas/link" - } - }, - "type": "object" - }, - "relationships": { - "properties": { - "bio": { - "$ref": "#/components/schemas/reltoone" - }, - "comments": { - "$ref": "#/components/schemas/reltomany" - }, - "entries": { - "$ref": "#/components/schemas/reltomany" - }, - "first_entry": { - "$ref": "#/components/schemas/reltoone" - }, - "type": { - "$ref": "#/components/schemas/reltoone" - } - }, - "type": "object" - }, - "type": { - "$ref": "#/components/schemas/type" - } - }, - "required": [ - "type" - ], - "type": "object" - } - }, - "required": [ - "data" - ] - } - } - } - }, - "responses": { - "201": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/Author" - }, - "included": { - "items": { - "$ref": "#/components/schemas/resource" - }, - "type": "array", - "uniqueItems": true - }, - "jsonapi": { - "$ref": "#/components/schemas/jsonapi" - }, - "links": { - "allOf": [ - { - "$ref": "#/components/schemas/links" - }, - { - "$ref": "#/components/schemas/pagination" - } - ], - "description": "Link members related to primary data" - } - }, - "required": [ - "data" - ], - "type": "object" - } - } - }, - "description": "[Created](https://jsonapi.org/format/#crud-creating-responses-201). Assigned `id` and/or any other changes are in this response." - }, - "202": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/datum" - } - } - }, - "description": "Accepted for [asynchronous processing](https://jsonapi.org/recommendations/#asynchronous-processing)" - }, - "204": { - "description": "[Created](https://jsonapi.org/format/#crud-creating-responses-204) with the supplied `id`. No other changes from what was POSTed." - }, - "401": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not authorized" - }, - "403": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Forbidden](https://jsonapi.org/format/#crud-creating-responses-403)" - }, - "404": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Related resource does not exist](https://jsonapi.org/format/#crud-creating-responses-404)" - }, - "409": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Conflict](https://jsonapi.org/format/#crud-creating-responses-409)" - } - }, - "tags": [ - "authors" - ] -}""" - -snapshots[ - "test_patch_request 1" -] = """{ - "description": "", - "operationId": "update/authors/{id}", - "parameters": [ - { - "description": "A unique integer value identifying this author.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "additionalProperties": false, - "properties": { - "attributes": { - "properties": { - "defaults": { - "default": "default", - "description": "help for defaults", - "maxLength": 20, - "minLength": 3, - "type": "string", - "writeOnly": true - }, - "email": { - "format": "email", - "maxLength": 254, - "type": "string" - }, - "name": { - "maxLength": 50, - "type": "string" - } - }, - "type": "object" - }, - "id": { - "$ref": "#/components/schemas/id" - }, - "links": { - "properties": { - "self": { - "$ref": "#/components/schemas/link" - } - }, - "type": "object" - }, - "relationships": { - "properties": { - "bio": { - "$ref": "#/components/schemas/reltoone" - }, - "comments": { - "$ref": "#/components/schemas/reltomany" - }, - "entries": { - "$ref": "#/components/schemas/reltomany" - }, - "first_entry": { - "$ref": "#/components/schemas/reltoone" - }, - "type": { - "$ref": "#/components/schemas/reltoone" - } - }, - "type": "object" - }, - "type": { - "$ref": "#/components/schemas/type" - } - }, - "required": [ - "type", - "id" - ], - "type": "object" - } - }, - "required": [ - "data" - ] - } - } - } - }, - "responses": { - "200": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/Author" - }, - "included": { - "items": { - "$ref": "#/components/schemas/resource" - }, - "type": "array", - "uniqueItems": true - }, - "jsonapi": { - "$ref": "#/components/schemas/jsonapi" - }, - "links": { - "allOf": [ - { - "$ref": "#/components/schemas/links" - }, - { - "$ref": "#/components/schemas/pagination" - } - ], - "description": "Link members related to primary data" - } - }, - "required": [ - "data" - ], - "type": "object" - } - } - }, - "description": "update/authors/{id}" - }, - "401": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not authorized" - }, - "403": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Forbidden](https://jsonapi.org/format/#crud-updating-responses-403)" - }, - "404": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Related resource does not exist](https://jsonapi.org/format/#crud-updating-responses-404)" - }, - "409": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Conflict]([Conflict](https://jsonapi.org/format/#crud-updating-responses-409)" - } - }, - "tags": [ - "authors" - ] -}""" - -snapshots[ - "test_delete_request 1" -] = """{ - "description": "", - "operationId": "destroy/authors/{id}", - "parameters": [ - { - "description": "A unique integer value identifying this author.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/onlymeta" - } - } - }, - "description": "[OK](https://jsonapi.org/format/#crud-deleting-responses-200)" - }, - "202": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/datum" - } - } - }, - "description": "Accepted for [asynchronous processing](https://jsonapi.org/recommendations/#asynchronous-processing)" - }, - "204": { - "description": "[no content](https://jsonapi.org/format/#crud-deleting-responses-204)" - }, - "401": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not authorized" - }, - "404": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Resource does not exist](https://jsonapi.org/format/#crud-deleting-responses-404)" - } - }, - "tags": [ - "authors" - ] -}""" diff --git a/example/tests/test_errors.py b/example/tests/test_errors.py index 6b322d72..2267ec6e 100644 --- a/example/tests/test_errors.py +++ b/example/tests/test_errors.py @@ -79,7 +79,7 @@ def test_first_level_attribute_error(client, some_blog, snapshot): }, } } - snapshot.assert_match(perform_error_test(client, data)) + assert snapshot == perform_error_test(client, data) def test_first_level_custom_attribute_error(client, some_blog, snapshot): @@ -94,7 +94,7 @@ def test_first_level_custom_attribute_error(client, some_blog, snapshot): } } with override_settings(JSON_API_FORMAT_FIELD_NAMES="dasherize"): - snapshot.assert_match(perform_error_test(client, data)) + assert snapshot == perform_error_test(client, data) def test_second_level_array_error(client, some_blog, snapshot): @@ -110,7 +110,7 @@ def test_second_level_array_error(client, some_blog, snapshot): } } - snapshot.assert_match(perform_error_test(client, data)) + assert snapshot == perform_error_test(client, data) def test_second_level_dict_error(client, some_blog, snapshot): @@ -126,7 +126,7 @@ def test_second_level_dict_error(client, some_blog, snapshot): } } - snapshot.assert_match(perform_error_test(client, data)) + assert snapshot == perform_error_test(client, data) def test_third_level_array_error(client, some_blog, snapshot): @@ -142,7 +142,7 @@ def test_third_level_array_error(client, some_blog, snapshot): } } - snapshot.assert_match(perform_error_test(client, data)) + assert snapshot == perform_error_test(client, data) def test_third_level_custom_array_error(client, some_blog, snapshot): @@ -160,7 +160,7 @@ def test_third_level_custom_array_error(client, some_blog, snapshot): } } - snapshot.assert_match(perform_error_test(client, data)) + assert snapshot == perform_error_test(client, data) def test_third_level_dict_error(client, some_blog, snapshot): @@ -176,7 +176,7 @@ def test_third_level_dict_error(client, some_blog, snapshot): } } - snapshot.assert_match(perform_error_test(client, data)) + assert snapshot == perform_error_test(client, data) def test_many_third_level_dict_errors(client, some_blog, snapshot): @@ -192,7 +192,7 @@ def test_many_third_level_dict_errors(client, some_blog, snapshot): } } - snapshot.assert_match(perform_error_test(client, data)) + assert snapshot == perform_error_test(client, data) def test_relationship_errors_has_correct_pointers(client, some_blog, snapshot): @@ -210,4 +210,4 @@ def test_relationship_errors_has_correct_pointers(client, some_blog, snapshot): } } - snapshot.assert_match(perform_error_test(client, data)) + assert snapshot == perform_error_test(client, data) diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index d32a9367..5b881f8b 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -33,7 +33,7 @@ def test_path_without_parameters(snapshot): inspector.view = view operation = inspector.get_operation(path, method) - snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) + assert snapshot == json.dumps(operation, indent=2, sort_keys=True) def test_path_with_id_parameter(snapshot): @@ -47,7 +47,7 @@ def test_path_with_id_parameter(snapshot): inspector.view = view operation = inspector.get_operation(path, method) - snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) + assert snapshot == json.dumps(operation, indent=2, sort_keys=True) def test_post_request(snapshot): @@ -61,7 +61,7 @@ def test_post_request(snapshot): inspector.view = view operation = inspector.get_operation(path, method) - snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) + assert snapshot == json.dumps(operation, indent=2, sort_keys=True) def test_patch_request(snapshot): @@ -75,7 +75,7 @@ def test_patch_request(snapshot): inspector.view = view operation = inspector.get_operation(path, method) - snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) + assert snapshot == json.dumps(operation, indent=2, sort_keys=True) def test_delete_request(snapshot): @@ -89,7 +89,7 @@ def test_delete_request(snapshot): inspector.view = view operation = inspector.get_operation(path, method) - snapshot.assert_match(json.dumps(operation, indent=2, sort_keys=True)) + assert snapshot == json.dumps(operation, indent=2, sort_keys=True) @override_settings( diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 5ac6e9c9..4d83cd28 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -5,4 +5,4 @@ pytest==6.2.5 pytest-cov==3.0.0 pytest-django==4.4.0 pytest-factoryboy==2.1.0 -snapshottest==0.6.0 +syrupy==1.4.7 From d0251630bd6ef3edaf03e47e5c1e6b36d32ee82a Mon Sep 17 00:00:00 2001 From: sha016 <92833633+sha016@users.noreply.github.com> Date: Thu, 11 Nov 2021 08:15:05 -0600 Subject: [PATCH 100/252] Format fields in OpenAPI Schema (#1003) --- AUTHORS | 1 + CHANGELOG.md | 1 + example/tests/__snapshots__/test_openapi.ambr | 4 ++-- rest_framework_json_api/schemas/openapi.py | 7 ++++--- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/AUTHORS b/AUTHORS index 84e52286..35d959c6 100644 --- a/AUTHORS +++ b/AUTHORS @@ -39,6 +39,7 @@ Safa AlFulaij santiavenda Sergey Kolomenkin Stas S. +Steven A. Swaraj Baral Tim Selman Tom Glowka diff --git a/CHANGELOG.md b/CHANGELOG.md index db78a996..b631eda7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ any parts of the framework not mentioned in the documentation should generally b * Avoid error when `parser_context` is `None` while parsing. * Raise comprehensible error when reserved field names `meta` and `results` are used. * Use `relationships` in the error object `pointer` when the field is actually a relationship. +* Added missing inflection to the generated OpenAPI schema. ### Changed diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr index 28044c74..48aa21fa 100644 --- a/example/tests/__snapshots__/test_openapi.ambr +++ b/example/tests/__snapshots__/test_openapi.ambr @@ -133,7 +133,7 @@ "entries": { "$ref": "#/components/schemas/reltomany" }, - "first_entry": { + "firstEntry": { "$ref": "#/components/schemas/reltoone" }, "type": { @@ -541,7 +541,7 @@ "entries": { "$ref": "#/components/schemas/reltomany" }, - "first_entry": { + "firstEntry": { "$ref": "#/components/schemas/reltoone" }, "type": { diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 2dff2e10..c3d553b7 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -7,6 +7,7 @@ from rest_framework.schemas.utils import is_list_view from rest_framework_json_api import serializers, views +from rest_framework_json_api.utils import format_field_name class SchemaGenerator(drf_openapi.SchemaGenerator): @@ -655,12 +656,12 @@ def map_serializer(self, serializer): if isinstance(field, serializers.HiddenField): continue if isinstance(field, serializers.RelatedField): - relationships[field.field_name] = { + relationships[format_field_name(field.field_name)] = { "$ref": "#/components/schemas/reltoone" } continue if isinstance(field, serializers.ManyRelatedField): - relationships[field.field_name] = { + relationships[format_field_name(field.field_name)] = { "$ref": "#/components/schemas/reltomany" } continue @@ -682,7 +683,7 @@ def map_serializer(self, serializer): schema["description"] = str(field.help_text) self.map_field_validators(field, schema) - attributes[field.field_name] = schema + attributes[format_field_name(field.field_name)] = schema result = { "type": "object", From 83d84d02b7670a05d701bcb14bf57904bce30b94 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 18 Nov 2021 23:36:42 +0400 Subject: [PATCH 101/252] Use one GitHub runner per Python version (#1011) * Moving from tox-gh-actions to tox-py (following DRF) * Move configuration what tests are allowed to failed into tox configuration instead of GitHub settings * Simplification of configuration as tox has the lead and no additional configuration is needed. Co-authored-by: Alan Crosswell --- .github/workflows/tests.yml | 13 ++++--------- tox.ini | 21 +++------------------ 2 files changed, 7 insertions(+), 27 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a38766c7..ebe31bb1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,17 +5,12 @@ jobs: test: name: Run test runs-on: ubuntu-latest - continue-on-error: ${{ matrix.django-rest-framework == 'master' }} strategy: fail-fast: false matrix: python-version: ["3.6", "3.7", "3.8", "3.9"] - django: ["2.2", "3.0", "3.1", "3.2"] - django-rest-framework: ["3.12", "master"] env: PYTHON: ${{ matrix.python-version }} - DJANGO: ${{ matrix.django }} - DJANGO_REST_FRAMEWORK: ${{ matrix.django-rest-framework }} steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} @@ -25,13 +20,13 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install tox tox-gh-actions - - name: Test with tox - run: tox + pip install tox tox-py + - name: Run tox targets for ${{ matrix.python-version }} + run: tox --py current - name: Upload coverage report uses: codecov/codecov-action@v2 with: - env_vars: PYTHON,DJANGO,DJANGO_REST_FRAMEWORK + env_vars: PYTHON check: name: Run check runs-on: ubuntu-latest diff --git a/tox.ini b/tox.ini index f4160e5a..075cffc5 100644 --- a/tox.ini +++ b/tox.ini @@ -3,24 +3,6 @@ envlist = py{36,37,38,39}-django{22,30,31,32}-drf{312,master}, lint,docs -[gh-actions] -python = - 3.6: py36 - 3.7: py37 - 3.8: py38 - 3.9: py39 - -[gh-actions:env] -DJANGO = - 2.2: django22 - 3.0: django30 - 3.1: django31 - 3.2: django32 - -DJANGO_REST_FRAMEWORK = - 3.12: drf312 - master: drfmaster - [testenv] deps = django22: Django>=2.2,<2.3 @@ -61,3 +43,6 @@ deps = -rrequirements/requirements-documentation.txt commands = sphinx-build -W -b html -d docs/_build/doctrees docs docs/_build/html + +[testenv:py{36,37,38,39}-django{22,30,31,32}-drfmaster] +ignore_outcome = true From ffedc6bb755f9b81af7ee423ba64021535fef96c Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 22 Nov 2021 21:51:03 +0400 Subject: [PATCH 102/252] Added support for Django 4.0 (#1010) * Added support for Django 4.0 * Bump to Django 4.0 first release candidate --- CHANGELOG.md | 4 ++++ README.rst | 2 +- docs/getting-started.md | 2 +- example/settings/dev.py | 1 + example/urls_test.py | 2 +- requirements/requirements-optionals.txt | 2 +- requirements/requirements-testing.txt | 5 +++++ setup.cfg | 4 ++-- setup.py | 2 +- tests/test_relations.py | 2 +- tox.ini | 2 ++ 11 files changed, 20 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b631eda7..18ca7b03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ any parts of the framework not mentioned in the documentation should generally b ## [Unreleased] +### Added + +* Added support for Django 4.0. + ### Fixed * Adjusted error messages to correctly use capital "JSON:API" abbreviation as used in the specification. diff --git a/README.rst b/README.rst index c3e661e3..b3f4ad35 100644 --- a/README.rst +++ b/README.rst @@ -89,7 +89,7 @@ Requirements ------------ 1. Python (3.6, 3.7, 3.8, 3.9) -2. Django (2.2, 3.0, 3.1, 3.2) +2. Django (2.2, 3.0, 3.1, 3.2, 4.0) 3. Django REST framework (3.12) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index 51780af2..0d0d0caf 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.6, 3.7, 3.8, 3.9) -2. Django (2.2, 3.0, 3.1, 3.2) +2. Django (2.2, 3.0, 3.1, 3.2, 4.0) 3. Django REST framework (3.12) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/example/settings/dev.py b/example/settings/dev.py index 12bed35c..c02180eb 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -5,6 +5,7 @@ MEDIA_ROOT = os.path.normcase(os.path.dirname(os.path.abspath(__file__))) MEDIA_URL = "/media/" +USE_TZ = False DATABASE_ENGINE = "sqlite3" diff --git a/example/urls_test.py b/example/urls_test.py index 5ee06a23..92802a81 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -1,4 +1,4 @@ -from django.conf.urls import re_path +from django.urls import re_path from rest_framework import routers from .api.resources.identity import GenericIdentity, Identity diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 1db26c07..0fcfcbbf 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,4 +1,4 @@ django-filter==21.1 -django-polymorphic==3.0.0 +django-polymorphic==3.1.0 pyyaml==6.0 uritemplate==4.1.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 4d83cd28..415e4124 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -6,3 +6,8 @@ pytest-cov==3.0.0 pytest-django==4.4.0 pytest-factoryboy==2.1.0 syrupy==1.4.7 +# TODO remove pytz dep again once DRF higher than 3.12.4 is released +# Django 4.0 removed dependency on pytz and made it optional but +# DRF requires it and will define it as dependency in future versions +# only adding this to testing though as DJA does not directly use pytz +pytz==2021.3 diff --git a/setup.cfg b/setup.cfg index 31e61331..2947a883 100644 --- a/setup.cfg +++ b/setup.cfg @@ -58,8 +58,8 @@ DJANGO_SETTINGS_MODULE=example.settings.test filterwarnings = error::DeprecationWarning error::PendingDeprecationWarning - # Django Debug Toolbar currently (2021-04-07) specifies default_app_config which is deprecated in Django 3.2: - ignore:'debug_toolbar' defines default_app_config = 'debug_toolbar.apps.DebugToolbarConfig'. Django now detects this configuration automatically. You can remove default_app_config.:PendingDeprecationWarning + # TODO remove again once DRF higher than 3.12.4 has been released + ignore:'rest_framework' defines default_app_config # TODO remove in next major version of DJA 5.0.0 # this deprecation warning filter needs to be added as AuthorSerializer is used in # too many tests which introduced the type field name in tests diff --git a/setup.py b/setup.py index 08712129..24611c13 100755 --- a/setup.py +++ b/setup.py @@ -97,7 +97,7 @@ def get_package_data(package): install_requires=[ "inflection>=0.3.0", "djangorestframework>=3.12,<3.13", - "django>=2.2,<3.3", + "django>=2.2,<4.1", ], extras_require={ "django-polymorphic": ["django-polymorphic>=2.0"], diff --git a/tests/test_relations.py b/tests/test_relations.py index 630dd9c8..74721cfa 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -1,5 +1,5 @@ import pytest -from django.conf.urls import re_path +from django.urls import re_path from rest_framework import status from rest_framework.fields import SkipField from rest_framework.routers import SimpleRouter diff --git a/tox.ini b/tox.ini index 075cffc5..b8b1a6ea 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,7 @@ [tox] envlist = py{36,37,38,39}-django{22,30,31,32}-drf{312,master}, + py{38,39}-django40-drf{312,master}, lint,docs [testenv] @@ -9,6 +10,7 @@ deps = django30: Django>=3.0,<3.1 django31: Django>=3.1,<3.2 django32: Django>=3.2,<3.3 + django40: Django>=4.0rc1,<5.0 drf312: djangorestframework>=3.12,<3.13 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip -rrequirements/requirements-testing.txt From 3db6bce73e017a883e3f9139f5acaa466b634c71 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 29 Nov 2021 20:44:06 +0400 Subject: [PATCH 103/252] Added support for Python 3.10 (#1013) --- .github/workflows/tests.yml | 2 +- CHANGELOG.md | 1 + README.rst | 2 +- docs/getting-started.md | 2 +- setup.py | 1 + tox.ini | 1 + 6 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ebe31bb1..3d085c5c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.6", "3.7", "3.8", "3.9"] + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] env: PYTHON: ${{ matrix.python-version }} steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 18ca7b03..10559ddb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Added * Added support for Django 4.0. +* Added support for Python 3.10. ### Fixed diff --git a/README.rst b/README.rst index b3f4ad35..9f5c4c4b 100644 --- a/README.rst +++ b/README.rst @@ -88,7 +88,7 @@ As a Django REST framework JSON:API (short DJA) we are trying to address followi Requirements ------------ -1. Python (3.6, 3.7, 3.8, 3.9) +1. Python (3.6, 3.7, 3.8, 3.9, 3.10) 2. Django (2.2, 3.0, 3.1, 3.2, 4.0) 3. Django REST framework (3.12) diff --git a/docs/getting-started.md b/docs/getting-started.md index 0d0d0caf..41bffc39 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -51,7 +51,7 @@ like the following: ## Requirements -1. Python (3.6, 3.7, 3.8, 3.9) +1. Python (3.6, 3.7, 3.8, 3.9, 3.10) 2. Django (2.2, 3.0, 3.1, 3.2, 4.0) 3. Django REST framework (3.12) diff --git a/setup.py b/setup.py index 24611c13..482d7b22 100755 --- a/setup.py +++ b/setup.py @@ -90,6 +90,7 @@ def get_package_data(package): "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", diff --git a/tox.ini b/tox.ini index b8b1a6ea..a5d4869f 100644 --- a/tox.ini +++ b/tox.ini @@ -2,6 +2,7 @@ envlist = py{36,37,38,39}-django{22,30,31,32}-drf{312,master}, py{38,39}-django40-drf{312,master}, + py310-django{32,40}-drf{312,master}, lint,docs [testenv] From a4262ddaae31beb7a1b9106df55115e373bc8d24 Mon Sep 17 00:00:00 2001 From: Jonas Kiefer <17874616+jokiefer@users.noreply.github.com> Date: Sun, 5 Dec 2021 17:24:11 +0100 Subject: [PATCH 104/252] Added missing error message when no resource name is configured on serializer (#1020) --- AUTHORS | 1 + CHANGELOG.md | 1 + rest_framework_json_api/utils.py | 5 ++++- tests/test_utils.py | 16 ++++++++++++++++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index 35d959c6..fa91e9b2 100644 --- a/AUTHORS +++ b/AUTHORS @@ -15,6 +15,7 @@ Jamie Bliss Jason Housley Jeppe Fihl-Pearson Jerel Unruh +Jonas Kiefer Jonas Metzener Jonathan Senecal Joseba Mendivil diff --git a/CHANGELOG.md b/CHANGELOG.md index 10559ddb..1fa3d92e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ any parts of the framework not mentioned in the documentation should generally b * Raise comprehensible error when reserved field names `meta` and `results` are used. * Use `relationships` in the error object `pointer` when the field is actually a relationship. * Added missing inflection to the generated OpenAPI schema. +* Added missing error message when `resource_name` is not properly configured. ### Changed diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 65cbc645..6189093f 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -320,7 +320,10 @@ def get_resource_type_from_serializer(serializer): return meta.resource_name elif hasattr(meta, "model"): return get_resource_type_from_model(meta.model) - raise AttributeError() + raise AttributeError( + f"can not detect 'resource_name' on serializer '{serializer.__class__.__name__}'" + f" in module '{serializer.__class__.__module__}'" + ) def get_included_resources(request, serializer=None): diff --git a/tests/test_utils.py b/tests/test_utils.py index 43c12dcf..d8810c0b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,7 @@ import pytest from django.db import models from rest_framework import status +from rest_framework.fields import Field from rest_framework.generics import GenericAPIView from rest_framework.response import Response from rest_framework.views import APIView @@ -15,6 +16,7 @@ get_included_serializers, get_related_resource_type, get_resource_name, + get_resource_type_from_serializer, undo_format_field_name, undo_format_field_names, undo_format_link_segment, @@ -377,3 +379,17 @@ class Meta: } assert included_serializers == expected_included_serializers + + +def test_get_resource_type_from_serializer_without_resource_name_raises_error(): + class SerializerWithoutResourceName(serializers.Serializer): + something = Field() + + serializer = SerializerWithoutResourceName() + + with pytest.raises(AttributeError) as excinfo: + get_resource_type_from_serializer(serializer=serializer) + assert str(excinfo.value) == ( + "can not detect 'resource_name' on serializer " + "'SerializerWithoutResourceName' in module 'tests.test_utils'" + ) From b62ca08ea41596bb585ef7d977adbe1be704a09b Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 6 Dec 2021 10:22:23 -0500 Subject: [PATCH 105/252] Scheduled biweekly dependency update for week 49 (#1021) * Update black from 21.9b0 to 21.12b0 * Update isort from 5.9.3 to 5.10.1 * Update sphinx from 4.2.0 to 4.3.1 * Update twine from 3.4.2 to 3.7.0 * Update factory-boy from 3.2.0 to 3.2.1 * Update faker from 9.3.1 to 9.9.0 * Update pytest-django from 4.4.0 to 4.5.1 * Update syrupy from 1.4.7 to 1.5.0 --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-documentation.txt | 2 +- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 55628500..99c23c93 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==21.9b0 +black==21.12b0 flake8==4.0.1 flake8-isort==4.1.1 -isort==5.9.3 +isort==5.10.1 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index d24d7101..e381bed3 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==4.2.0 +Sphinx==4.3.1 sphinx_rtd_theme==1.0.0 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index 1a95714a..4cc9f1eb 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==3.4.2 +twine==3.7.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 415e4124..1221211e 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,11 +1,11 @@ django-debug-toolbar==3.2.2 -factory-boy==3.2.0 -Faker==9.3.1 +factory-boy==3.2.1 +Faker==9.9.0 pytest==6.2.5 pytest-cov==3.0.0 -pytest-django==4.4.0 +pytest-django==4.5.1 pytest-factoryboy==2.1.0 -syrupy==1.4.7 +syrupy==1.5.0 # TODO remove pytz dep again once DRF higher than 3.12.4 is released # Django 4.0 removed dependency on pytz and made it optional but # DRF requires it and will define it as dependency in future versions From 8e368a2a838f9e673a05a66bd72230b6ae7ef2ab Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 7 Dec 2021 20:58:19 +0400 Subject: [PATCH 106/252] Bumped to released Django version 4.0.0 (#1022) --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index a5d4869f..ffba965a 100644 --- a/tox.ini +++ b/tox.ini @@ -11,7 +11,7 @@ deps = django30: Django>=3.0,<3.1 django31: Django>=3.1,<3.2 django32: Django>=3.2,<3.3 - django40: Django>=4.0rc1,<5.0 + django40: Django>=4.0,<5.0 drf312: djangorestframework>=3.12,<3.13 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip -rrequirements/requirements-testing.txt From 5403d508065b52ad84e35bf093c4cd37f05a24fb Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 7 Dec 2021 21:12:17 +0400 Subject: [PATCH 107/252] Running pyupgrade with Python 3+ support (#1023) Removes some old Python 2 literals Co-authored-by: Alan Crosswell --- example/tests/integration/test_includes.py | 22 ++++++--------- .../tests/integration/test_polymorphism.py | 28 +++++++++---------- example/tests/test_filters.py | 2 +- 3 files changed, 22 insertions(+), 30 deletions(-) diff --git a/example/tests/integration/test_includes.py b/example/tests/integration/test_includes.py index 223645d2..f8fe8604 100644 --- a/example/tests/integration/test_includes.py +++ b/example/tests/integration/test_includes.py @@ -21,7 +21,7 @@ def test_included_data_on_list(multiple_entries, client): comment_count = len( [resource for resource in included if resource["type"] == "comments"] ) - expected_comment_count = sum([entry.comments.count() for entry in multiple_entries]) + expected_comment_count = sum(entry.comments.count() for entry in multiple_entries) assert comment_count == expected_comment_count, "List comment count is incorrect" @@ -135,17 +135,15 @@ def test_deep_included_data_on_list(multiple_entries, client): comment_count = len( [resource for resource in included if resource["type"] == "comments"] ) - expected_comment_count = sum([entry.comments.count() for entry in multiple_entries]) + expected_comment_count = sum(entry.comments.count() for entry in multiple_entries) assert comment_count == expected_comment_count, "List comment count is incorrect" author_count = len( [resource for resource in included if resource["type"] == "authors"] ) expected_author_count = sum( - [ - entry.comments.filter(author__isnull=False).count() - for entry in multiple_entries - ] + entry.comments.filter(author__isnull=False).count() + for entry in multiple_entries ) assert author_count == expected_author_count, "List author count is incorrect" @@ -153,10 +151,8 @@ def test_deep_included_data_on_list(multiple_entries, client): [resource for resource in included if resource["type"] == "authorBios"] ) expected_author_bio_count = sum( - [ - entry.comments.filter(author__bio__isnull=False).count() - for entry in multiple_entries - ] + entry.comments.filter(author__bio__isnull=False).count() + for entry in multiple_entries ) assert ( author_bio_count == expected_author_bio_count @@ -166,10 +162,8 @@ def test_deep_included_data_on_list(multiple_entries, client): [resource for resource in included if resource["type"] == "writers"] ) expected_writer_count = sum( - [ - entry.comments.filter(author__isnull=False).count() - for entry in multiple_entries - ] + entry.comments.filter(author__isnull=False).count() + for entry in multiple_entries ) assert writer_count == expected_writer_count, "List writer count is incorrect" diff --git a/example/tests/integration/test_polymorphism.py b/example/tests/integration/test_polymorphism.py index bd41f203..6b1ef52f 100644 --- a/example/tests/integration/test_polymorphism.py +++ b/example/tests/integration/test_polymorphism.py @@ -24,12 +24,10 @@ def test_polymorphism_on_detail_relations(single_company, client): content["data"]["relationships"]["currentProject"]["data"]["type"] == "artProjects" ) - assert set( - [ - rel["type"] - for rel in content["data"]["relationships"]["futureProjects"]["data"] - ] - ) == set(["researchProjects", "artProjects"]) + assert { + rel["type"] + for rel in content["data"]["relationships"]["futureProjects"]["data"] + } == {"researchProjects", "artProjects"} def test_polymorphism_on_included_relations(single_company, client): @@ -47,15 +45,15 @@ def test_polymorphism_on_included_relations(single_company, client): == "artProjects" ) assert content["data"]["relationships"]["currentResearchProject"]["data"] is None - assert set( - [ - rel["type"] - for rel in content["data"]["relationships"]["futureProjects"]["data"] - ] - ) == set(["researchProjects", "artProjects"]) - assert set([x.get("type") for x in content.get("included")]) == set( - ["artProjects", "artProjects", "researchProjects"] - ), "Detail included types are incorrect" + assert { + rel["type"] + for rel in content["data"]["relationships"]["futureProjects"]["data"] + } == {"researchProjects", "artProjects"} + assert {x.get("type") for x in content.get("included")} == { + "artProjects", + "artProjects", + "researchProjects", + }, "Detail included types are incorrect" # Ensure that the child fields are present. assert content.get("included")[0].get("attributes").get("artist") is not None assert content.get("included")[1].get("attributes").get("artist") is not None diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index 10d8886a..5bb12121 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -600,7 +600,7 @@ def test_search_multiple_keywords(self): expected_ids = set.intersection(*union) expected_len = len(expected_ids) self.assertEqual(len(dja_response["data"]), expected_len) - returned_ids = set([k["id"] for k in dja_response["data"]]) + returned_ids = {k["id"] for k in dja_response["data"]} self.assertEqual(returned_ids, expected_ids) def test_param_invalid(self): From 28e553a32d446e962c4e6c1f0cbb2a325c426fb8 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 9 Dec 2021 22:50:24 +0400 Subject: [PATCH 108/252] Use assert when checking for reserved field names (#1025) This check is for development purposes to verify that the source code is well configured but not needed during production. Therefore a assert is more appropriate. --- rest_framework_json_api/serializers.py | 11 +++++------ tests/test_serializers.py | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 0b4a8dfd..1ac504ab 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -165,12 +165,11 @@ def get_fields(self): found_reserved_field_names = self._reserved_field_names.intersection( fields.keys() ) - if found_reserved_field_names: - raise AttributeError( - f"Serializer class {self.__class__.__module__}.{self.__class__.__qualname__} " - f"uses following reserved field name(s) which is not allowed: " - f"{', '.join(sorted(found_reserved_field_names))}" - ) + assert not found_reserved_field_names, ( + f"Serializer class {self.__class__.__module__}.{self.__class__.__qualname__} " + f"uses following reserved field name(s) which is not allowed: " + f"{', '.join(sorted(found_reserved_field_names))}" + ) if "type" in fields: # see https://jsonapi.org/format/#document-resource-object-fields diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 70bb140f..19ee23d6 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -37,7 +37,7 @@ class Meta: def test_reserved_field_names(): - with pytest.raises(AttributeError) as e: + with pytest.raises(AssertionError) as e: class ReservedFieldNamesSerializer(serializers.Serializer): meta = serializers.CharField() From aa06e04ca90003f07d47e85ae467803cd3b2a2a7 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 9 Dec 2021 23:55:15 +0400 Subject: [PATCH 109/252] Release 4.3.0 (#1024) --- CHANGELOG.md | 2 +- rest_framework_json_api/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fa3d92e..0ba7f5e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [4.3.0] - 2021-12-10 ### Added diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index 6add109f..b1408d0b 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,5 +1,5 @@ __title__ = "djangorestframework-jsonapi" -__version__ = "4.2.1" +__version__ = "4.3.0" __author__ = "" __license__ = "BSD" __copyright__ = "" From a9ac1566b665c514e7458f43f1064a2cf6cb18df Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 14 Dec 2021 19:01:13 +0400 Subject: [PATCH 110/252] Removed support for Django 3.0 and 3.1 (#1026) --- CHANGELOG.md | 9 +++++++++ README.rst | 2 +- docs/getting-started.md | 2 +- tox.ini | 6 ++---- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ba7f5e2..72a2f0d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## [Unreleased] + +### Removed + +* Removed support for Django 3.0. +* Removed support for Django 3.1. + ## [4.3.0] - 2021-12-10 +This is the last release supporting Django 3.0 and Django 3.1. + ### Added * Added support for Django 4.0. diff --git a/README.rst b/README.rst index 9f5c4c4b..18317bd7 100644 --- a/README.rst +++ b/README.rst @@ -89,7 +89,7 @@ Requirements ------------ 1. Python (3.6, 3.7, 3.8, 3.9, 3.10) -2. Django (2.2, 3.0, 3.1, 3.2, 4.0) +2. Django (2.2, 3.2, 4.0) 3. Django REST framework (3.12) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index 41bffc39..c473cd49 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.6, 3.7, 3.8, 3.9, 3.10) -2. Django (2.2, 3.0, 3.1, 3.2, 4.0) +2. Django (2.2, 3.2, 4.0) 3. Django REST framework (3.12) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/tox.ini b/tox.ini index ffba965a..e4baddf4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{36,37,38,39}-django{22,30,31,32}-drf{312,master}, + py{36,37,38,39}-django{22,32}-drf{312,master}, py{38,39}-django40-drf{312,master}, py310-django{32,40}-drf{312,master}, lint,docs @@ -8,8 +8,6 @@ envlist = [testenv] deps = django22: Django>=2.2,<2.3 - django30: Django>=3.0,<3.1 - django31: Django>=3.1,<3.2 django32: Django>=3.2,<3.3 django40: Django>=4.0,<5.0 drf312: djangorestframework>=3.12,<3.13 @@ -47,5 +45,5 @@ deps = commands = sphinx-build -W -b html -d docs/_build/doctrees docs docs/_build/html -[testenv:py{36,37,38,39}-django{22,30,31,32}-drfmaster] +[testenv:py{36,37,38,39,310}-django{22,32}-drfmaster] ignore_outcome = true From b37cec05a0686cba02d1febfba5ba8cebe203492 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 14 Dec 2021 19:06:54 +0400 Subject: [PATCH 111/252] Updated urls to use path and re_path as is default in Django 2.2. (#1027) django.urls.conf.url is only there for Python 3.5 support which we already deleted anyway. Co-authored-by: Alan Crosswell --- example/urls.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/example/urls.py b/example/urls.py index 867f1bd7..84ca1e9f 100644 --- a/example/urls.py +++ b/example/urls.py @@ -1,6 +1,5 @@ from django.conf import settings -from django.conf.urls import include, url -from django.urls import path +from django.urls import include, path, re_path from django.views.generic import TemplateView from rest_framework import routers from rest_framework.schemas import get_schema_view @@ -36,53 +35,53 @@ router.register(r"lab-results", LabResultViewSet) urlpatterns = [ - url(r"^", include(router.urls)), - url( + path("", include(router.urls)), + re_path( r"^entries/(?P[^/.]+)/suggested/$", EntryViewSet.as_view({"get": "list"}), name="entry-suggested", ), - url( + re_path( r"entries/(?P[^/.]+)/blog$", BlogViewSet.as_view({"get": "retrieve"}), name="entry-blog", ), - url( + re_path( r"entries/(?P[^/.]+)/comments$", CommentViewSet.as_view({"get": "list"}), name="entry-comments", ), - url( + re_path( r"entries/(?P[^/.]+)/authors$", AuthorViewSet.as_view({"get": "list"}), name="entry-authors", ), - url( + re_path( r"entries/(?P[^/.]+)/featured$", EntryViewSet.as_view({"get": "retrieve"}), name="entry-featured", ), - url( + re_path( r"^authors/(?P[^/.]+)/(?P\w+)/$", AuthorViewSet.as_view({"get": "retrieve_related"}), name="author-related", ), - url( + re_path( r"^entries/(?P[^/.]+)/relationships/(?P\w+)$", EntryRelationshipView.as_view(), name="entry-relationships", ), - url( + re_path( r"^blogs/(?P[^/.]+)/relationships/(?P\w+)$", BlogRelationshipView.as_view(), name="blog-relationships", ), - url( + re_path( r"^comments/(?P[^/.]+)/relationships/(?P\w+)$", CommentRelationshipView.as_view(), name="comment-relationships", ), - url( + re_path( r"^authors/(?P[^/.]+)/relationships/(?P\w+)$", AuthorRelationshipView.as_view(), name="author-relationships", @@ -111,5 +110,5 @@ import debug_toolbar urlpatterns = [ - url(r"^__debug__/", include(debug_toolbar.urls)), + path("__debug__/", include(debug_toolbar.urls)), ] + urlpatterns From 0c179d748f06d70b1c3699d5406dd279935d0c15 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 14 Dec 2021 20:18:48 +0400 Subject: [PATCH 112/252] Adjusted to only use f-strings for slight performance improvement (#1028) --- CHANGELOG.md | 4 ++ .../tests/integration/test_polymorphism.py | 12 ++-- example/tests/test_serializers.py | 4 +- example/tests/test_views.py | 56 +++++++++---------- .../unit/test_default_drf_serializers.py | 16 +++--- .../django_filters/backends.py | 8 +-- rest_framework_json_api/filters.py | 6 +- rest_framework_json_api/relations.py | 4 +- rest_framework_json_api/serializers.py | 2 +- rest_framework_json_api/utils.py | 8 +-- 10 files changed, 57 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72a2f0d8..c2fee4a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ any parts of the framework not mentioned in the documentation should generally b ## [Unreleased] +### Changed + +* Adjusted to only use f-strings for slight performance improvement. + ### Removed * Removed support for Django 3.0. diff --git a/example/tests/integration/test_polymorphism.py b/example/tests/integration/test_polymorphism.py index 6b1ef52f..69a97220 100644 --- a/example/tests/integration/test_polymorphism.py +++ b/example/tests/integration/test_polymorphism.py @@ -64,8 +64,8 @@ def test_polymorphism_on_polymorphic_model_detail_patch(single_art_project, clie url = reverse("project-detail", kwargs={"pk": single_art_project.pk}) response = client.get(url) content = response.json() - test_topic = "test-{}".format(random.randint(0, 999999)) - test_artist = "test-{}".format(random.randint(0, 999999)) + test_topic = f"test-{random.randint(0, 999999)}" + test_artist = f"test-{random.randint(0, 999999)}" content["data"]["attributes"]["topic"] = test_topic content["data"]["attributes"]["artist"] = test_artist response = client.patch(url, data=content) @@ -92,8 +92,8 @@ def test_patch_on_polymorphic_model_without_including_required_field( def test_polymorphism_on_polymorphic_model_list_post(client): - test_topic = "New test topic {}".format(random.randint(0, 999999)) - test_artist = "test-{}".format(random.randint(0, 999999)) + test_topic = f"New test topic {random.randint(0, 999999)}" + test_artist = f"test-{random.randint(0, 999999)}" test_project_type = ProjectTypeFactory() url = reverse("project-list") data = { @@ -152,8 +152,8 @@ def test_polymorphic_model_without_any_instance(client): def test_invalid_type_on_polymorphic_model(client): - test_topic = "New test topic {}".format(random.randint(0, 999999)) - test_artist = "test-{}".format(random.randint(0, 999999)) + test_topic = f"New test topic {random.randint(0, 999999)}" + test_artist = f"test-{random.randint(0, 999999)}" url = reverse("project-list") data = { "data": { diff --git a/example/tests/test_serializers.py b/example/tests/test_serializers.py index 8f14fed8..37f50b53 100644 --- a/example/tests/test_serializers.py +++ b/example/tests/test_serializers.py @@ -40,9 +40,9 @@ def setUp(self): rating=3, ) for i in range(1, 6): - name = "some_author{}".format(i) + name = f"some_author{i}" self.entry.authors.add( - Author.objects.create(name=name, email="{}@example.org".format(name)) + Author.objects.create(name=name, email=f"{name}@example.org") ) def test_forward_relationship_not_loaded_when_not_included(self): diff --git a/example/tests/test_views.py b/example/tests/test_views.py index d976ed56..560f7547 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -76,15 +76,13 @@ def test_get_entry_relationship_blog(self): def test_get_entry_relationship_invalid_field(self): response = self.client.get( - "/entries/{}/relationships/invalid_field".format(self.first_entry.id) + f"/entries/{self.first_entry.id}/relationships/invalid_field" ) assert response.status_code == 404 def test_get_blog_relationship_entry_set(self): - response = self.client.get( - "/blogs/{}/relationships/entry_set".format(self.blog.id) - ) + response = self.client.get(f"/blogs/{self.blog.id}/relationships/entry_set") expected_data = [ {"type": format_resource_type("Entry"), "id": str(self.first_entry.id)}, {"type": format_resource_type("Entry"), "id": str(self.second_entry.id)}, @@ -94,9 +92,7 @@ def test_get_blog_relationship_entry_set(self): @override_settings(JSON_API_FORMAT_RELATED_LINKS="dasherize") def test_get_blog_relationship_entry_set_with_formatted_link(self): - response = self.client.get( - "/blogs/{}/relationships/entry-set".format(self.blog.id) - ) + response = self.client.get(f"/blogs/{self.blog.id}/relationships/entry-set") expected_data = [ {"type": format_resource_type("Entry"), "id": str(self.first_entry.id)}, {"type": format_resource_type("Entry"), "id": str(self.second_entry.id)}, @@ -105,17 +101,17 @@ def test_get_blog_relationship_entry_set_with_formatted_link(self): assert response.data == expected_data def test_put_entry_relationship_blog_returns_405(self): - url = "/entries/{}/relationships/blog".format(self.first_entry.id) + url = f"/entries/{self.first_entry.id}/relationships/blog" response = self.client.put(url, data={}) assert response.status_code == 405 def test_patch_invalid_entry_relationship_blog_returns_400(self): - url = "/entries/{}/relationships/blog".format(self.first_entry.id) + url = f"/entries/{self.first_entry.id}/relationships/blog" response = self.client.patch(url, data={"data": {"invalid": ""}}) assert response.status_code == 400 def test_relationship_view_errors_format(self): - url = "/entries/{}/relationships/blog".format(self.first_entry.id) + url = f"/entries/{self.first_entry.id}/relationships/blog" response = self.client.patch(url, data={"data": {"invalid": ""}}) assert response.status_code == 400 @@ -125,14 +121,14 @@ def test_relationship_view_errors_format(self): assert "errors" in result def test_get_empty_to_one_relationship(self): - url = "/comments/{}/relationships/author".format(self.first_entry.id) + url = f"/comments/{self.first_entry.id}/relationships/author" response = self.client.get(url) expected_data = None assert response.data == expected_data def test_get_to_many_relationship_self_link(self): - url = "/authors/{}/relationships/comments".format(self.author.id) + url = f"/authors/{self.author.id}/relationships/comments" response = self.client.get(url) expected_data = { @@ -147,7 +143,7 @@ def test_get_to_many_relationship_self_link(self): assert json.loads(response.content.decode("utf-8")) == expected_data def test_patch_to_one_relationship(self): - url = "/entries/{}/relationships/blog".format(self.first_entry.id) + url = f"/entries/{self.first_entry.id}/relationships/blog" request_data = { "data": { "type": format_resource_type("Blog"), @@ -162,7 +158,7 @@ def test_patch_to_one_relationship(self): assert response.data == request_data["data"] def test_patch_one_to_many_relationship(self): - url = "/blogs/{}/relationships/entry_set".format(self.first_entry.id) + url = f"/blogs/{self.first_entry.id}/relationships/entry_set" request_data = { "data": [ {"type": format_resource_type("Entry"), "id": str(self.first_entry.id)}, @@ -184,7 +180,7 @@ def test_patch_one_to_many_relationship(self): assert response.data == request_data["data"] def test_patch_one_to_many_relaitonship_with_none(self): - url = "/blogs/{}/relationships/entry_set".format(self.first_entry.id) + url = f"/blogs/{self.first_entry.id}/relationships/entry_set" request_data = {"data": None} response = self.client.patch(url, data=request_data) assert response.status_code == 200, response.content.decode() @@ -194,7 +190,7 @@ def test_patch_one_to_many_relaitonship_with_none(self): assert response.data == [] def test_patch_many_to_many_relationship(self): - url = "/entries/{}/relationships/authors".format(self.first_entry.id) + url = f"/entries/{self.first_entry.id}/relationships/authors" request_data = { "data": [ {"type": format_resource_type("Author"), "id": str(self.author.id)}, @@ -216,7 +212,7 @@ def test_patch_many_to_many_relationship(self): assert response.data == request_data["data"] def test_post_to_one_relationship_should_fail(self): - url = "/entries/{}/relationships/blog".format(self.first_entry.id) + url = f"/entries/{self.first_entry.id}/relationships/blog" request_data = { "data": { "type": format_resource_type("Blog"), @@ -227,7 +223,7 @@ def test_post_to_one_relationship_should_fail(self): assert response.status_code == 405, response.content.decode() def test_post_to_many_relationship_with_no_change(self): - url = "/entries/{}/relationships/comments".format(self.first_entry.id) + url = f"/entries/{self.first_entry.id}/relationships/comments" request_data = { "data": [ { @@ -241,7 +237,7 @@ def test_post_to_many_relationship_with_no_change(self): assert len(response.rendered_content) == 0, response.rendered_content.decode() def test_post_to_many_relationship_with_change(self): - url = "/entries/{}/relationships/comments".format(self.first_entry.id) + url = f"/entries/{self.first_entry.id}/relationships/comments" request_data = { "data": [ { @@ -256,7 +252,7 @@ def test_post_to_many_relationship_with_change(self): assert request_data["data"][0] in response.data def test_delete_to_one_relationship_should_fail(self): - url = "/entries/{}/relationships/blog".format(self.first_entry.id) + url = f"/entries/{self.first_entry.id}/relationships/blog" request_data = { "data": { "type": format_resource_type("Blog"), @@ -267,7 +263,7 @@ def test_delete_to_one_relationship_should_fail(self): assert response.status_code == 405, response.content.decode() def test_delete_relationship_overriding_with_none(self): - url = "/comments/{}".format(self.second_comment.id) + url = f"/comments/{self.second_comment.id}" request_data = { "data": { "type": "comments", @@ -280,7 +276,7 @@ def test_delete_relationship_overriding_with_none(self): assert response.data["author"] is None def test_delete_to_many_relationship_with_no_change(self): - url = "/entries/{}/relationships/comments".format(self.first_entry.id) + url = f"/entries/{self.first_entry.id}/relationships/comments" request_data = { "data": [ { @@ -294,7 +290,7 @@ def test_delete_to_many_relationship_with_no_change(self): assert len(response.rendered_content) == 0, response.rendered_content.decode() def test_delete_one_to_many_relationship_with_not_null_constraint(self): - url = "/entries/{}/relationships/comments".format(self.first_entry.id) + url = f"/entries/{self.first_entry.id}/relationships/comments" request_data = { "data": [ { @@ -307,7 +303,7 @@ def test_delete_one_to_many_relationship_with_not_null_constraint(self): assert response.status_code == 409, response.content.decode() def test_delete_to_many_relationship_with_change(self): - url = "/authors/{}/relationships/comments".format(self.author.id) + url = f"/authors/{self.author.id}/relationships/comments" request_data = { "data": [ { @@ -323,7 +319,7 @@ def test_new_comment_data_patch_to_many_relationship(self): entry = EntryFactory(blog=self.blog, authors=(self.author,)) comment = CommentFactory(entry=entry) - url = "/authors/{}/relationships/comments".format(self.author.id) + url = f"/authors/{self.author.id}/relationships/comments" request_data = { "data": [ {"type": format_resource_type("Comment"), "id": str(comment.id)}, @@ -597,7 +593,7 @@ def setUp(self): self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") def test_no_content_response(self): - url = "/blogs/{}".format(self.blog.pk) + url = f"/blogs/{self.blog.pk}" response = self.client.delete(url) assert response.status_code == 204, response.rendered_content.decode() assert len(response.rendered_content) == 0, response.rendered_content.decode() @@ -618,8 +614,8 @@ def test_get_object_gives_correct_blog(self): expected = { "data": { "attributes": {"name": self.blog.name}, - "id": "{}".format(self.blog.id), - "links": {"self": "http://testserver/blogs/{}".format(self.blog.id)}, + "id": f"{self.blog.id}", + "links": {"self": f"http://testserver/blogs/{self.blog.id}"}, "meta": {"copyright": datetime.now().year}, "relationships": {"tags": {"data": [], "meta": {"count": 0}}}, "type": "blogs", @@ -656,13 +652,13 @@ def test_get_object_gives_correct_entry(self): "modDate": self.second_entry.mod_date, "pubDate": self.second_entry.pub_date, }, - "id": "{}".format(self.second_entry.id), + "id": f"{self.second_entry.id}", "meta": {"bodyFormat": "text"}, "relationships": { "authors": {"data": [], "meta": {"count": 0}}, "blog": { "data": { - "id": "{}".format(self.second_entry.blog_id), + "id": f"{self.second_entry.blog_id}", "type": "blogs", } }, diff --git a/example/tests/unit/test_default_drf_serializers.py b/example/tests/unit/test_default_drf_serializers.py index e8c78df8..31449856 100644 --- a/example/tests/unit/test_default_drf_serializers.py +++ b/example/tests/unit/test_default_drf_serializers.py @@ -93,8 +93,8 @@ def test_blog_create(client): expected = { "data": { "attributes": {"name": blog.name, "tags": []}, - "id": "{}".format(blog.id), - "links": {"self": "http://testserver/blogs/{}".format(blog.id)}, + "id": f"{blog.id}", + "links": {"self": f"http://testserver/blogs/{blog.id}"}, "meta": {"copyright": datetime.now().year}, "type": "blogs", }, @@ -113,8 +113,8 @@ def test_get_object_gives_correct_blog(client, blog, entry): expected = { "data": { "attributes": {"name": blog.name, "tags": []}, - "id": "{}".format(blog.id), - "links": {"self": "http://testserver/blogs/{}".format(blog.id)}, + "id": f"{blog.id}", + "links": {"self": f"http://testserver/blogs/{blog.id}"}, "meta": {"copyright": datetime.now().year}, "type": "blogs", }, @@ -134,8 +134,8 @@ def test_get_object_patches_correct_blog(client, blog, entry): request_data = { "data": { "attributes": {"name": new_name}, - "id": "{}".format(blog.id), - "links": {"self": "http://testserver/blogs/{}".format(blog.id)}, + "id": f"{blog.id}", + "links": {"self": f"http://testserver/blogs/{blog.id}"}, "meta": {"copyright": datetime.now().year}, "relationships": {"tags": {"data": []}}, "type": "blogs", @@ -150,8 +150,8 @@ def test_get_object_patches_correct_blog(client, blog, entry): expected = { "data": { "attributes": {"name": new_name, "tags": []}, - "id": "{}".format(blog.id), - "links": {"self": "http://testserver/blogs/{}".format(blog.id)}, + "id": f"{blog.id}", + "links": {"self": f"http://testserver/blogs/{blog.id}"}, "meta": {"copyright": datetime.now().year}, "type": "blogs", }, diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index 61518e1c..5302bf09 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -78,7 +78,7 @@ def _validate_filter(self, keys, filterset_class): """ for k in keys: if (not filterset_class) or (k not in filterset_class.base_filters): - raise ValidationError("invalid filter[{}]".format(k)) + raise ValidationError(f"invalid filter[{k}]") def get_filterset(self, request, queryset, view): """ @@ -111,12 +111,10 @@ def get_filterset_kwargs(self, request, queryset, view): or m.groupdict()["ldelim"] != "[" or m.groupdict()["rdelim"] != "]" ): - raise ValidationError("invalid query parameter: {}".format(qp)) + raise ValidationError(f"invalid query parameter: {qp}") if m and qp != self.search_param: if not all(val): - raise ValidationError( - "missing value for query parameter {}".format(qp) - ) + raise ValidationError(f"missing value for query parameter {qp}") # convert JSON:API relationship path to Django ORM's __ notation key = m.groupdict()["assoc"].replace(".", "__") key = undo_format_field_name(key) diff --git a/rest_framework_json_api/filters.py b/rest_framework_json_api/filters.py index ba3794cb..33ce240f 100644 --- a/rest_framework_json_api/filters.py +++ b/rest_framework_json_api/filters.py @@ -93,14 +93,12 @@ def validate_query_params(self, request): for qp in request.query_params.keys(): m = self.query_regex.match(qp) if not m: - raise ValidationError("invalid query parameter: {}".format(qp)) + raise ValidationError(f"invalid query parameter: {qp}") if ( not m.group("type") == "filter" and len(request.query_params.getlist(qp)) > 1 ): - raise ValidationError( - "repeated query parameter not allowed: {}".format(qp) - ) + raise ValidationError(f"repeated query parameter not allowed: {qp}") def filter_queryset(self, request, queryset, view): """ diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index f6e91cfe..6cca15c8 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -324,7 +324,7 @@ class PolymorphicResourceRelatedField(ResourceRelatedField): "Incorrect relation type. Expected one of [{relation_type}], " "received {received_type}." ), - } + }, ) def __init__(self, polymorphic_serializer, *args, **kwargs): @@ -370,7 +370,7 @@ def __init__(self, method_name=None, **kwargs): super().__init__(**kwargs) def bind(self, field_name, parent): - default_method_name = "get_{field_name}".format(field_name=field_name) + default_method_name = f"get_{field_name}" if self.method_name is None: self.method_name = default_method_name super().bind(field_name, parent) diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 1ac504ab..cfc6cf3f 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -442,7 +442,7 @@ def get_polymorphic_serializer_for_type(cls, obj_type): return cls._poly_type_serializer_map[obj_type] except KeyError: raise NotImplementedError( - "No polymorphic serializer has been found for type {}".format(obj_type) + f"No polymorphic serializer has been found for type {obj_type}" ) @classmethod diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 6189093f..b15f4c6c 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -418,7 +418,7 @@ def format_drf_errors(response, context, exc): # pointer can be determined only if there's a serializer. if has_serializer: rel = "relationships" if field in relationship_fields else "attributes" - pointer = "/data/{}/{}".format(rel, field) + pointer = f"/data/{rel}/{field}" if isinstance(exc, Http404) and isinstance(error, str): # 404 errors don't have a pointer errors.extend(format_error_object(error, None, response)) @@ -462,13 +462,11 @@ def format_error_object(message, pointer, response): errors.append(message) else: for k, v in message.items(): - errors.extend( - format_error_object(v, pointer + "/{}".format(k), response) - ) + errors.extend(format_error_object(v, pointer + f"/{k}", response)) elif isinstance(message, list): for num, error in enumerate(message): if isinstance(error, (list, dict)): - new_pointer = pointer + "/{}".format(num) + new_pointer = pointer + f"/{num}" else: new_pointer = pointer if error: From fb8f6c465b9aecf2a40b5dba58b89a52397ad2a1 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 14 Dec 2021 23:21:23 +0400 Subject: [PATCH 113/252] Added support for Django REST framework 3.13 (#1029) --- CHANGELOG.md | 4 ++++ README.rst | 2 +- docs/getting-started.md | 2 +- requirements/requirements-testing.txt | 5 ----- setup.cfg | 2 -- setup.py | 2 +- tox.ini | 8 ++++---- 7 files changed, 11 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2fee4a2..654a0363 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ any parts of the framework not mentioned in the documentation should generally b ## [Unreleased] +### Added + +* Added support for Django REST framework 3.13. + ### Changed * Adjusted to only use f-strings for slight performance improvement. diff --git a/README.rst b/README.rst index 18317bd7..0142feee 100644 --- a/README.rst +++ b/README.rst @@ -90,7 +90,7 @@ Requirements 1. Python (3.6, 3.7, 3.8, 3.9, 3.10) 2. Django (2.2, 3.2, 4.0) -3. Django REST framework (3.12) +3. Django REST framework (3.12, 3.13) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index c473cd49..bd02261c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -53,7 +53,7 @@ like the following: 1. Python (3.6, 3.7, 3.8, 3.9, 3.10) 2. Django (2.2, 3.2, 4.0) -3. Django REST framework (3.12) +3. Django REST framework (3.12, 3.13) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 1221211e..552eff1f 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -6,8 +6,3 @@ pytest-cov==3.0.0 pytest-django==4.5.1 pytest-factoryboy==2.1.0 syrupy==1.5.0 -# TODO remove pytz dep again once DRF higher than 3.12.4 is released -# Django 4.0 removed dependency on pytz and made it optional but -# DRF requires it and will define it as dependency in future versions -# only adding this to testing though as DJA does not directly use pytz -pytz==2021.3 diff --git a/setup.cfg b/setup.cfg index 2947a883..afe7faab 100644 --- a/setup.cfg +++ b/setup.cfg @@ -58,8 +58,6 @@ DJANGO_SETTINGS_MODULE=example.settings.test filterwarnings = error::DeprecationWarning error::PendingDeprecationWarning - # TODO remove again once DRF higher than 3.12.4 has been released - ignore:'rest_framework' defines default_app_config # TODO remove in next major version of DJA 5.0.0 # this deprecation warning filter needs to be added as AuthorSerializer is used in # too many tests which introduced the type field name in tests diff --git a/setup.py b/setup.py index 482d7b22..eba979e0 100755 --- a/setup.py +++ b/setup.py @@ -97,7 +97,7 @@ def get_package_data(package): ], install_requires=[ "inflection>=0.3.0", - "djangorestframework>=3.12,<3.13", + "djangorestframework>=3.12,<3.14", "django>=2.2,<4.1", ], extras_require={ diff --git a/tox.ini b/tox.ini index e4baddf4..a493b63f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,7 @@ [tox] envlist = - py{36,37,38,39}-django{22,32}-drf{312,master}, - py{38,39}-django40-drf{312,master}, - py310-django{32,40}-drf{312,master}, + py{36,37,38,39,310}-django{22,32}-drf{312,313,master}, + py{38,39,310}-django40-drf{313,master}, lint,docs [testenv] @@ -11,6 +10,7 @@ deps = django32: Django>=3.2,<3.3 django40: Django>=4.0,<5.0 drf312: djangorestframework>=3.12,<3.13 + drf313: djangorestframework>=3.13,<3.14 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip -rrequirements/requirements-testing.txt -rrequirements/requirements-optionals.txt @@ -45,5 +45,5 @@ deps = commands = sphinx-build -W -b html -d docs/_build/doctrees docs docs/_build/html -[testenv:py{36,37,38,39,310}-django{22,32}-drfmaster] +[testenv:py{36,37,38,39,310}-django{22,32,40}-drfmaster] ignore_outcome = true From b45a9c3985ea2ffe78714b207352671d755ae82f Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 15 Dec 2021 18:06:44 +0400 Subject: [PATCH 114/252] Set minimum required version of inflection to 0.5.0 (#1030) --- CHANGELOG.md | 1 + setup.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 654a0363..07ff2079 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Changed * Adjusted to only use f-strings for slight performance improvement. +* Set minimum required version of inflection to 0.5.0. ### Removed diff --git a/setup.py b/setup.py index eba979e0..97ca29a6 100755 --- a/setup.py +++ b/setup.py @@ -96,7 +96,7 @@ def get_package_data(package): "Topic :: Software Development :: Libraries :: Python Modules", ], install_requires=[ - "inflection>=0.3.0", + "inflection>=0.5.0", "djangorestframework>=3.12,<3.14", "django>=2.2,<4.1", ], From c46d495e38bf46c09277ce34230b966a4cd0ec1d Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 15 Dec 2021 22:22:26 +0400 Subject: [PATCH 115/252] Update minimum required version of optional dependencies (#1031) --- CHANGELOG.md | 5 ++++- README.rst | 2 ++ docs/getting-started.md | 2 ++ setup.py | 6 +++--- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07ff2079..b660b2b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,10 @@ any parts of the framework not mentioned in the documentation should generally b ### Changed * Adjusted to only use f-strings for slight performance improvement. -* Set minimum required version of inflection to 0.5.0. +* Set minimum required version of inflection to 0.5. +* Set minimum required version of Django Filter to 2.4. +* Set minimum required version of Polymorphic Models for Django to 3.0. +* Set minimum required version of PyYAML to 5.4. ### Removed diff --git a/README.rst b/README.rst index 0142feee..32fe36f4 100644 --- a/README.rst +++ b/README.rst @@ -96,6 +96,8 @@ We **highly** recommend and only officially support the latest patch release of Generally Python and Django series are supported till the official end of life. For Django REST framework the last two series are supported. +For optional dependencies such as Django Filter only the latest release is officially supported even though lower versions should work as well. + ------------ Installation ------------ diff --git a/docs/getting-started.md b/docs/getting-started.md index bd02261c..fa49fa4b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -59,6 +59,8 @@ We **highly** recommend and only officially support the latest patch release of Generally Python and Django series are supported till the official end of life. For Django REST framework the last two series are supported. +For optional dependencies such as Django Filter only the latest release is officially supported even though lower versions should work as well. + ## Installation Install using `pip`... diff --git a/setup.py b/setup.py index 97ca29a6..05383a12 100755 --- a/setup.py +++ b/setup.py @@ -101,9 +101,9 @@ def get_package_data(package): "django>=2.2,<4.1", ], extras_require={ - "django-polymorphic": ["django-polymorphic>=2.0"], - "django-filter": ["django-filter>=2.0"], - "openapi": ["pyyaml>=5.3", "uritemplate>=3.0.1"], + "django-polymorphic": ["django-polymorphic>=3.0"], + "django-filter": ["django-filter>=2.4"], + "openapi": ["pyyaml>=5.4", "uritemplate>=3.0.1"], }, setup_requires=wheel, python_requires=">=3.6", From 6bda8f1d04b0aaddf4498f3ab38ad16f5946b373 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 15 Dec 2021 22:37:03 +0400 Subject: [PATCH 116/252] Rename primary branch from master to main (#1032) --- .github/workflows/codeql-analysis.yml | 4 ++-- docs/usage.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 89e9f974..9b429fa7 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -13,10 +13,10 @@ name: "CodeQL" on: push: - branches: [ master ] + branches: [ main ] pull_request: # The branches below must be a subset of the branches above - branches: [ master ] + branches: [ main ] schedule: - cron: '33 5 * * 6' diff --git a/docs/usage.md b/docs/usage.md index d0bd07f4..939e4e6a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1054,7 +1054,7 @@ class MySchemaGenerator(JSONAPISchemaGenerator): }, 'license': { 'name': 'BSD 2 clause', - 'url': 'https://github.com/django-json-api/django-rest-framework-json-api/blob/master/LICENSE', + 'url': 'https://github.com/django-json-api/django-rest-framework-json-api/blob/main/LICENSE', } } schema['servers'] = [ From cb495a57b0a26451292846cfe43f8dd527f5d621 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 29 Dec 2021 16:58:28 +0400 Subject: [PATCH 117/252] Removed support for Python 3.6 (#1039) --- .github/workflows/tests.yml | 6 +++--- CHANGELOG.md | 3 ++- README.rst | 2 +- docs/getting-started.md | 2 +- setup.py | 5 ++--- tox.ini | 10 +++++----- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3d085c5c..2a77e701 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] + python-version: ["3.7", "3.8", "3.9", "3.10"] env: PYTHON: ${{ matrix.python-version }} steps: @@ -36,10 +36,10 @@ jobs: tox-env: ["black", "lint", "docs"] steps: - uses: actions/checkout@v2 - - name: Set up Python 3.6 + - name: Set up Python 3.7 uses: actions/setup-python@v2 with: - python-version: 3.6 + python-version: 3.7 - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/CHANGELOG.md b/CHANGELOG.md index b660b2b5..bcbce7ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,10 +26,11 @@ any parts of the framework not mentioned in the documentation should generally b * Removed support for Django 3.0. * Removed support for Django 3.1. +* Removed support for Python 3.6. ## [4.3.0] - 2021-12-10 -This is the last release supporting Django 3.0 and Django 3.1. +This is the last release supporting Django 3.0, Django 3.1 and Python 3.6. ### Added diff --git a/README.rst b/README.rst index 32fe36f4..64984627 100644 --- a/README.rst +++ b/README.rst @@ -88,7 +88,7 @@ As a Django REST framework JSON:API (short DJA) we are trying to address followi Requirements ------------ -1. Python (3.6, 3.7, 3.8, 3.9, 3.10) +1. Python (3.7, 3.8, 3.9, 3.10) 2. Django (2.2, 3.2, 4.0) 3. Django REST framework (3.12, 3.13) diff --git a/docs/getting-started.md b/docs/getting-started.md index fa49fa4b..c838cb22 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -51,7 +51,7 @@ like the following: ## Requirements -1. Python (3.6, 3.7, 3.8, 3.9, 3.10) +1. Python (3.7, 3.8, 3.9, 3.10) 2. Django (2.2, 3.2, 4.0) 3. Django REST framework (3.12, 3.13) diff --git a/setup.py b/setup.py index 05383a12..b0f912f9 100755 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ def read(*paths): """ Build a file path from paths and return the contents. """ - with open(os.path.join(*paths), "r") as f: + with open(os.path.join(*paths)) as f: return f.read() @@ -86,7 +86,6 @@ def get_package_data(package): "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", @@ -106,6 +105,6 @@ def get_package_data(package): "openapi": ["pyyaml>=5.4", "uritemplate>=3.0.1"], }, setup_requires=wheel, - python_requires=">=3.6", + python_requires=">=3.7", zip_safe=False, ) diff --git a/tox.ini b/tox.ini index a493b63f..048d91d3 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{36,37,38,39,310}-django{22,32}-drf{312,313,master}, + py{37,38,39,310}-django{22,32}-drf{312,313,master}, py{38,39,310}-django40-drf{313,master}, lint,docs @@ -23,13 +23,13 @@ commands = pytest --cov --no-cov-on-fail --cov-report xml {posargs} [testenv:black] -basepython = python3.6 +basepython = python3.7 deps = -rrequirements/requirements-codestyle.txt commands = black --check . [testenv:lint] -basepython = python3.6 +basepython = python3.7 deps = -rrequirements/requirements-codestyle.txt -rrequirements/requirements-testing.txt @@ -37,7 +37,7 @@ deps = commands = flake8 [testenv:docs] -basepython = python3.6 +basepython = python3.7 deps = -rrequirements/requirements-testing.txt -rrequirements/requirements-optionals.txt @@ -45,5 +45,5 @@ deps = commands = sphinx-build -W -b html -d docs/_build/doctrees docs docs/_build/html -[testenv:py{36,37,38,39,310}-django{22,32,40}-drfmaster] +[testenv:py{37,38,39,310}-django{22,32,40}-drfmaster] ignore_outcome = true From 8cd79ae8188da8414418ba38d8825201fccf55e8 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 30 Dec 2021 08:26:54 -0500 Subject: [PATCH 118/252] Scheduled biweekly dependency update for week 51 (#1035) * Update sphinx from 4.3.1 to 4.3.2 * Update twine from 3.7.0 to 3.7.1 * Update django-debug-toolbar from 3.2.2 to 3.2.4 * Update faker from 9.9.0 to 10.0.0 * Update pytest-django from 4.5.1 to 4.5.2 Co-authored-by: Alan Crosswell --- requirements/requirements-documentation.txt | 2 +- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index e381bed3..3c5c5377 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==4.3.1 +Sphinx==4.3.2 sphinx_rtd_theme==1.0.0 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index 4cc9f1eb..f6cd12a1 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==3.7.0 +twine==3.7.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 552eff1f..c101a337 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,8 +1,8 @@ -django-debug-toolbar==3.2.2 +django-debug-toolbar==3.2.4 factory-boy==3.2.1 -Faker==9.9.0 +Faker==10.0.0 pytest==6.2.5 pytest-cov==3.0.0 -pytest-django==4.5.1 +pytest-django==4.5.2 pytest-factoryboy==2.1.0 syrupy==1.5.0 From aa33959b05edad8441f4cdf549bc30f2b3ba16cf Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 30 Dec 2021 17:43:12 +0400 Subject: [PATCH 119/252] Removed all deprecations (#1040) Co-authored-by: Alan Crosswell --- CHANGELOG.md | 3 ++ example/factories.py | 2 +- example/fixtures/blogentry.json | 4 +- ...rename_type_author_author_type_and_more.py | 31 ++++++++++++ example/models.py | 2 +- example/serializers.py | 9 ++-- example/tests/__snapshots__/test_openapi.ambr | 12 ++--- example/tests/test_format_keys.py | 2 +- example/tests/test_model_viewsets.py | 26 ---------- example/tests/test_views.py | 8 +-- rest_framework_json_api/parsers.py | 8 +-- rest_framework_json_api/serializers.py | 15 +----- rest_framework_json_api/utils.py | 35 ++----------- setup.cfg | 4 -- tests/test_parsers.py | 3 +- tests/test_serializers.py | 9 ---- tests/test_utils.py | 49 +------------------ 17 files changed, 64 insertions(+), 158 deletions(-) create mode 100644 example/migrations/0011_rename_type_author_author_type_and_more.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bcbce7ce..43919ce2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ any parts of the framework not mentioned in the documentation should generally b * Removed support for Django 3.0. * Removed support for Django 3.1. * Removed support for Python 3.6. +* Removed obsolete method `utils.get_included_serializers`. +* Removed optional `format_type` argument of `utils.format_link_segment`. +* Removed `format_type`s default argument of `utils.format_value`. `format_type` is now required. ## [4.3.0] - 2021-12-10 diff --git a/example/factories.py b/example/factories.py index 96b85cad..4ca1e0b1 100644 --- a/example/factories.py +++ b/example/factories.py @@ -42,7 +42,7 @@ class Meta: email = factory.LazyAttribute(lambda x: faker.email()) bio = factory.RelatedFactory("example.factories.AuthorBioFactory", "author") - type = factory.SubFactory(AuthorTypeFactory) + author_type = factory.SubFactory(AuthorTypeFactory) class AuthorBioFactory(factory.django.DjangoModelFactory): diff --git a/example/fixtures/blogentry.json b/example/fixtures/blogentry.json index 464573e4..ceb4a9fe 100644 --- a/example/fixtures/blogentry.json +++ b/example/fixtures/blogentry.json @@ -77,7 +77,7 @@ "modified_at": "2016-05-02T10:09:48.277", "name": "Alice", "email": "alice@example.com", - "type": null + "author_type": null } }, { @@ -88,7 +88,7 @@ "modified_at": "2016-05-02T10:09:57.133", "name": "Bob", "email": "bob@example.com", - "type": null + "author_type": null } }, { diff --git a/example/migrations/0011_rename_type_author_author_type_and_more.py b/example/migrations/0011_rename_type_author_author_type_and_more.py new file mode 100644 index 00000000..191e901c --- /dev/null +++ b/example/migrations/0011_rename_type_author_author_type_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 4.0 on 2021-12-29 13:07 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("example", "0010_auto_20210714_0809"), + ] + + operations = [ + migrations.RenameField( + model_name="author", + old_name="type", + new_name="author_type", + ), + migrations.AlterField( + model_name="project", + name="polymorphic_ctype", + field=models.ForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="polymorphic_%(app_label)s.%(class)s_set+", + to="contenttypes.contenttype", + ), + ), + ] diff --git a/example/models.py b/example/models.py index 63d93e33..c3850076 100644 --- a/example/models.py +++ b/example/models.py @@ -54,7 +54,7 @@ class Meta: class Author(BaseModel): name = models.CharField(max_length=50) email = models.EmailField() - type = models.ForeignKey(AuthorType, null=True, on_delete=models.CASCADE) + author_type = models.ForeignKey(AuthorType, null=True, on_delete=models.CASCADE) def __str__(self): return self.name diff --git a/example/serializers.py b/example/serializers.py index 0e1022cc..b9bf71d6 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -252,10 +252,13 @@ class AuthorSerializer(serializers.ModelSerializer): help_text="help for defaults", ) initials = serializers.SerializerMethodField() - included_serializers = {"bio": AuthorBioSerializer, "type": AuthorTypeSerializer} + included_serializers = { + "bio": AuthorBioSerializer, + "author_type": AuthorTypeSerializer, + } related_serializers = { "bio": "example.serializers.AuthorBioSerializer", - "type": "example.serializers.AuthorTypeSerializer", + "author_type": "example.serializers.AuthorTypeSerializer", "comments": "example.serializers.CommentSerializer", "entries": "example.serializers.EntrySerializer", "first_entry": "example.serializers.EntrySerializer", @@ -270,7 +273,7 @@ class Meta: "entries", "comments", "first_entry", - "type", + "author_type", "secrets", "defaults", "initials", diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr index 48aa21fa..cb03ca73 100644 --- a/example/tests/__snapshots__/test_openapi.ambr +++ b/example/tests/__snapshots__/test_openapi.ambr @@ -124,6 +124,9 @@ }, "relationships": { "properties": { + "authorType": { + "$ref": "#/components/schemas/reltoone" + }, "bio": { "$ref": "#/components/schemas/reltoone" }, @@ -135,9 +138,6 @@ }, "firstEntry": { "$ref": "#/components/schemas/reltoone" - }, - "type": { - "$ref": "#/components/schemas/reltoone" } }, "type": "object" @@ -532,6 +532,9 @@ }, "relationships": { "properties": { + "authorType": { + "$ref": "#/components/schemas/reltoone" + }, "bio": { "$ref": "#/components/schemas/reltoone" }, @@ -543,9 +546,6 @@ }, "firstEntry": { "$ref": "#/components/schemas/reltoone" - }, - "type": { - "$ref": "#/components/schemas/reltoone" } }, "type": "object" diff --git a/example/tests/test_format_keys.py b/example/tests/test_format_keys.py index 8d99eec7..b91cf595 100644 --- a/example/tests/test_format_keys.py +++ b/example/tests/test_format_keys.py @@ -59,7 +59,7 @@ def test_options_format_field_names(db, client): "bio", "entries", "firstEntry", - "type", + "authorType", "comments", "secrets", "defaults", diff --git a/example/tests/test_model_viewsets.py b/example/tests/test_model_viewsets.py index c58b5131..21a641f8 100644 --- a/example/tests/test_model_viewsets.py +++ b/example/tests/test_model_viewsets.py @@ -1,4 +1,3 @@ -import pytest from django.contrib.auth import get_user_model from django.test import override_settings from django.urls import reverse @@ -216,28 +215,3 @@ def test_404_error_pointer(self): response = self.client.get(not_found_url) assert 404 == response.status_code assert errors == response.json() - - -@pytest.mark.django_db -def test_patch_allow_field_type(author, author_type_factory, client): - """ - Verify that type field may be updated. - """ - # TODO remove in next major version 5.0.0 see serializers.ReservedFieldNamesMixin - with pytest.deprecated_call(): - author_type = author_type_factory() - url = reverse("author-detail", args=[author.id]) - - data = { - "data": { - "id": author.id, - "type": "authors", - "relationships": { - "data": {"id": author_type.id, "type": "author-type"} - }, - } - } - - response = client.patch(url, data=data) - - assert response.status_code == 200 diff --git a/example/tests/test_views.py b/example/tests/test_views.py index 560f7547..a3aa1444 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -423,7 +423,7 @@ def test_get_related_serializer_class_many(self): self.assertEqual(got, EntrySerializer) def test_get_serializer_comes_from_included_serializers(self): - kwargs = {"pk": self.author.id, "related_field": "type"} + kwargs = {"pk": self.author.id, "related_field": "author_type"} view = self._get_view(kwargs) related_serializers = view.get_serializer_class().related_serializers delattr(view.get_serializer_class(), "related_serializers") @@ -470,14 +470,14 @@ def test_retrieve_related_single_reverse_lookup(self): def test_retrieve_related_single(self): url = reverse( "author-related", - kwargs={"pk": self.author.type.pk, "related_field": "type"}, + kwargs={"pk": self.author.author_type.pk, "related_field": "author_type"}, ) resp = self.client.get(url) expected = { "data": { "type": "authorTypes", - "id": str(self.author.type.id), - "attributes": {"name": str(self.author.type.name)}, + "id": str(self.author.author_type.id), + "attributes": {"name": str(self.author.author_type.name)}, } } self.assertEqual(resp.status_code, 200) diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index 61824749..f77a6501 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -4,7 +4,7 @@ from rest_framework import parsers from rest_framework.exceptions import ParseError -from rest_framework_json_api import exceptions, renderers, serializers +from rest_framework_json_api import exceptions, renderers from rest_framework_json_api.utils import get_resource_name, undo_format_field_names @@ -160,12 +160,8 @@ def parse(self, stream, media_type=None, parser_context=None): ) # Construct the return data - serializer_class = getattr(view, "serializer_class", None) parsed_data = {"id": data.get("id")} if "id" in data else {} - # TODO remove in next major version 5.0.0 see serializers.ReservedFieldNamesMixin - if serializer_class is not None: - if issubclass(serializer_class, serializers.PolymorphicModelSerializer): - parsed_data["type"] = data.get("type") + parsed_data["type"] = data.get("type") parsed_data.update(self.parse_attributes(data)) parsed_data.update(self.parse_relationships(data)) parsed_data.update(self.parse_metadata(result)) diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index cfc6cf3f..bfdfec71 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -1,4 +1,3 @@ -import warnings from collections import OrderedDict from collections.abc import Mapping @@ -157,7 +156,7 @@ def validate_path(serializer_class, field_path, path): class ReservedFieldNamesMixin: """Ensures that reserved field names are not used and an error raised instead.""" - _reserved_field_names = {"meta", "results"} + _reserved_field_names = {"meta", "results", "type"} def get_fields(self): fields = super().get_fields() @@ -171,18 +170,6 @@ def get_fields(self): f"{', '.join(sorted(found_reserved_field_names))}" ) - if "type" in fields: - # see https://jsonapi.org/format/#document-resource-object-fields - warnings.warn( - DeprecationWarning( - f"Field name 'type' found in serializer class " - f"{self.__class__.__module__}.{self.__class__.__qualname__} " - f"which is not allowed according to the JSON:API spec and " - f"won't be supported anymore in the next major DJA release. " - f"Rename 'type' field to something else. " - ) - ) - return fields diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index b15f4c6c..aba9de5a 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -1,6 +1,5 @@ import inspect import operator -import warnings from collections import OrderedDict import inflection @@ -147,23 +146,14 @@ def undo_format_field_name(field_name): return field_name -def format_link_segment(value, format_type=None): +def format_link_segment(value): """ Takes a string value and returns it with formatted keys as set in `format_type` or `JSON_API_FORMAT_RELATED_LINKS`. :format_type: Either 'dasherize', 'camelize', 'capitalize' or 'underscore' """ - if format_type is None: - format_type = json_api_settings.FORMAT_RELATED_LINKS - else: - warnings.warn( - DeprecationWarning( - "Using `format_type` argument is deprecated." - "Use `format_value` instead." - ) - ) - + format_type = json_api_settings.FORMAT_RELATED_LINKS return format_value(value, format_type) @@ -179,15 +169,7 @@ def undo_format_link_segment(value): return value -def format_value(value, format_type=None): - if format_type is None: - warnings.warn( - DeprecationWarning( - "Using `format_value` without passing on `format_type` argument is deprecated." - "Use `format_field_name` instead." - ) - ) - format_type = json_api_settings.FORMAT_FIELD_NAMES +def format_value(value, format_type): if format_type == "dasherize": # inflection can't dasherize camelCase value = inflection.underscore(value) @@ -342,17 +324,6 @@ def get_default_included_resources_from_serializer(serializer): return list(getattr(meta, "included_resources", [])) -def get_included_serializers(serializer): - warnings.warn( - DeprecationWarning( - "Using of `get_included_serializers(serializer)` function is deprecated." - "Use `serializer.included_serializers` instead." - ) - ) - - return getattr(serializer, "included_serializers", dict()) - - def get_relation_instance(resource_instance, source, serializer): try: relation_instance = operator.attrgetter(source)(resource_instance) diff --git a/setup.cfg b/setup.cfg index afe7faab..527ddd6b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -58,10 +58,6 @@ DJANGO_SETTINGS_MODULE=example.settings.test filterwarnings = error::DeprecationWarning error::PendingDeprecationWarning - # TODO remove in next major version of DJA 5.0.0 - # this deprecation warning filter needs to be added as AuthorSerializer is used in - # too many tests which introduced the type field name in tests - ignore:Field name 'type' testpaths = example tests diff --git a/tests/test_parsers.py b/tests/test_parsers.py index f1207757..45ac5232 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -63,6 +63,7 @@ def test_parse_formats_field_names( result = parse(data, parser_context) assert result == { "id": "123", + "type": "BasicModel", "test_attribute": "test-value", "test_relationship": {"id": "123", "type": "TestRelationship"}, } @@ -85,7 +86,7 @@ def test_parse_with_default_arguments(self, parse): }, } result = parse(data, None) - assert result == {} + assert result == {"type": "BasicModel"} def test_parse_preserves_json_value_field_names( self, settings, parse, parser_context diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 19ee23d6..e1b14ed8 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -50,12 +50,3 @@ class ReservedFieldNamesSerializer(serializers.Serializer): "ReservedFieldNamesSerializer uses following reserved field name(s) which is " "not allowed: meta, results" ) - - -def test_serializer_fields_deprecated_field_name_type(): - with pytest.deprecated_call(): - - class TypeFieldNameSerializer(serializers.Serializer): - type = serializers.CharField() - - TypeFieldNameSerializer().fields diff --git a/tests/test_utils.py b/tests/test_utils.py index d8810c0b..efb1325d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,4 @@ import pytest -from django.db import models from rest_framework import status from rest_framework.fields import Field from rest_framework.generics import GenericAPIView @@ -13,7 +12,6 @@ format_link_segment, format_resource_type, format_value, - get_included_serializers, get_related_resource_type, get_resource_name, get_resource_type_from_serializer, @@ -23,13 +21,12 @@ ) from tests.models import ( BasicModel, - DJAModel, ForeignKeySource, ForeignKeyTarget, ManyToManySource, ManyToManyTarget, ) -from tests.serializers import BasicModelSerializer, ManyToManyTargetSerializer +from tests.serializers import BasicModelSerializer def test_get_resource_name_no_view(): @@ -256,11 +253,6 @@ def test_format_link_segment(settings, format_type, output): assert format_link_segment("first_Name") == output -def test_format_link_segment_deprecates_format_type_argument(): - with pytest.deprecated_call(): - assert "first-name" == format_link_segment("first_name", "dasherize") - - @pytest.mark.parametrize( "format_links,output", [ @@ -289,11 +281,6 @@ def test_format_value(settings, format_type, output): assert format_value("first_name", format_type) == output -def test_format_value_deprecates_default_format_type_argument(): - with pytest.deprecated_call(): - assert "first_name" == format_value("first_name") - - @pytest.mark.parametrize( "resource_type,pluralize,output", [ @@ -347,40 +334,6 @@ class PlainRelatedResourceTypeSerializer(serializers.Serializer): assert get_related_resource_type(field) == output -def test_get_included_serializers(): - class DeprecatedIncludedSerializersModel(DJAModel): - self = models.ForeignKey("self", on_delete=models.CASCADE) - target = models.ForeignKey(ManyToManyTarget, on_delete=models.CASCADE) - other_target = models.ForeignKey(ManyToManyTarget, on_delete=models.CASCADE) - - class Meta: - app_label = "tests" - - class DeprecatedIncludedSerializersSerializer(serializers.ModelSerializer): - included_serializers = { - "self": "self", - "target": ManyToManyTargetSerializer, - "other_target": "tests.serializers.ManyToManyTargetSerializer", - } - - class Meta: - model = DeprecatedIncludedSerializersModel - fields = ("self", "other_target", "target") - - with pytest.deprecated_call(): - included_serializers = get_included_serializers( - DeprecatedIncludedSerializersSerializer - ) - - expected_included_serializers = { - "self": DeprecatedIncludedSerializersSerializer, - "target": ManyToManyTargetSerializer, - "other_target": ManyToManyTargetSerializer, - } - - assert included_serializers == expected_included_serializers - - def test_get_resource_type_from_serializer_without_resource_name_raises_error(): class SerializerWithoutResourceName(serializers.Serializer): something = Field() From e9ca8d9348dc71eda0a48e5c71d83a6526ca2711 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 3 Jan 2022 21:58:41 +0400 Subject: [PATCH 120/252] Release 5.0.0 (#1042) --- CHANGELOG.md | 5 ++++- rest_framework_json_api/__init__.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43919ce2..e6b257d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [5.0.0] - 2022-01-03 + +This release is not backwards compatible. For easy migration best upgrade first to version +4.3.0 and resolve all deprecation warnings before updating to 5.0.0 ### Added diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index b1408d0b..e8cac7c0 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,5 +1,5 @@ __title__ = "djangorestframework-jsonapi" -__version__ = "4.3.0" +__version__ = "5.0.0" __author__ = "" __license__ = "BSD" __copyright__ = "" From a1fd85d8a88d1aa42e5cbb6b3f9c0dc620214f5a Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 21 Feb 2022 11:17:55 -0500 Subject: [PATCH 121/252] Scheduled biweekly dependency update for week 08 (#1054) * Update black from 21.12b0 to 22.1.0 * Update sphinx from 4.3.2 to 4.4.0 * Update twine from 3.7.1 to 3.8.0 * Update faker from 10.0.0 to 13.0.0 * Update pytest from 6.2.5 to 7.0.1 * Update syrupy from 1.5.0 to 1.7.4 --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-documentation.txt | 2 +- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 99c23c93..c5664ddb 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==21.12b0 +black==22.1.0 flake8==4.0.1 flake8-isort==4.1.1 isort==5.10.1 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 3c5c5377..9c9e2db0 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==4.3.2 +Sphinx==4.4.0 sphinx_rtd_theme==1.0.0 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index f6cd12a1..f9d2c8fd 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==3.7.1 +twine==3.8.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index c101a337..934e5ad6 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,8 +1,8 @@ django-debug-toolbar==3.2.4 factory-boy==3.2.1 -Faker==10.0.0 -pytest==6.2.5 +Faker==13.0.0 +pytest==7.0.1 pytest-cov==3.0.0 pytest-django==4.5.2 pytest-factoryboy==2.1.0 -syrupy==1.5.0 +syrupy==1.7.4 From 646d2a14df4303d720fe1d0e5d9d38d1c2c23412 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 21 Feb 2022 22:42:56 +0400 Subject: [PATCH 122/252] Added requirements.txt for ReadTheDocs (#1055) Flake8 and Spinx cannot be installed together anymore as of conflict with importlib-metadata package. As RTD only supports one requirements.txt file we need a separate one where only the dependencies for creation of documentation are installed. --- docs/requirements.txt | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 docs/requirements.txt diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..4ffe63ef --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,6 @@ +# RTD only supports one requirements.txt file, therefore this has been created. +# This file needs to keep in sync with tox testenv docs dependencies. + +-r ../requirements/requirements-optionals.txt +-r ../requirements/requirements-testing.txt +-r ../requirements/requirements-documentation.txt From 55087bc6a4e7c75ffe0db3c312b7151108b03dd4 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sat, 12 Mar 2022 22:20:27 +0400 Subject: [PATCH 123/252] Convert ReadOnlyModelViewSet to pytest style (#1058) --- example/tests/test_views.py | 81 ------------------------------------- tests/test_views.py | 41 ++++++++++++++++++- 2 files changed, 40 insertions(+), 82 deletions(-) diff --git a/example/tests/test_views.py b/example/tests/test_views.py index a3aa1444..592a72bc 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -3,15 +3,11 @@ from django.test import RequestFactory, override_settings from django.utils import timezone -from rest_framework import status -from rest_framework.decorators import action from rest_framework.exceptions import NotFound from rest_framework.request import Request -from rest_framework.response import Response from rest_framework.reverse import reverse from rest_framework.test import APIRequestFactory, APITestCase, force_authenticate -from rest_framework_json_api import serializers, views from rest_framework_json_api.utils import format_resource_type from example.factories import AuthorFactory, CommentFactory, EntryFactory @@ -713,80 +709,3 @@ def test_get_object_gives_correct_entry(self): } got = resp.json() self.assertEqual(got, expected) - - -class BasicAuthorSerializer(serializers.ModelSerializer): - class Meta: - model = Author - fields = ("name",) - - -class ReadOnlyViewSetWithCustomActions(views.ReadOnlyModelViewSet): - queryset = Author.objects.all() - serializer_class = BasicAuthorSerializer - - @action(detail=False, methods=["get", "post", "patch", "delete"]) - def group_action(self, request): - return Response(status=status.HTTP_204_NO_CONTENT) - - @action(detail=True, methods=["get", "post", "patch", "delete"]) - def item_action(self, request, pk): - return Response(status=status.HTTP_204_NO_CONTENT) - - -class TestReadonlyModelViewSet(TestBase): - """ - Test if ReadOnlyModelViewSet allows to have custom actions with POST, PATCH, DELETE methods - """ - - factory = RequestFactory() - viewset_class = ReadOnlyViewSetWithCustomActions - media_type = "application/vnd.api+json" - - def test_group_action_allows_get(self): - view = self.viewset_class.as_view({"get": "group_action"}) - request = self.factory.get("/") - response = view(request) - self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) - - def test_group_action_allows_post(self): - view = self.viewset_class.as_view({"post": "group_action"}) - request = self.factory.post("/", "{}", content_type=self.media_type) - response = view(request) - self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) - - def test_group_action_allows_patch(self): - view = self.viewset_class.as_view({"patch": "group_action"}) - request = self.factory.patch("/", "{}", content_type=self.media_type) - response = view(request) - self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) - - def test_group_action_allows_delete(self): - view = self.viewset_class.as_view({"delete": "group_action"}) - request = self.factory.delete("/", "{}", content_type=self.media_type) - response = view(request) - self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) - - def test_item_action_allows_get(self): - view = self.viewset_class.as_view({"get": "item_action"}) - request = self.factory.get("/") - response = view(request, pk="1") - self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) - - def test_item_action_allows_post(self): - view = self.viewset_class.as_view({"post": "item_action"}) - request = self.factory.post("/", "{}", content_type=self.media_type) - response = view(request, pk="1") - self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) - - def test_item_action_allows_patch(self): - view = self.viewset_class.as_view({"patch": "item_action"}) - request = self.factory.patch("/", "{}", content_type=self.media_type) - response = view(request, pk="1") - self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) - - def test_item_action_allows_delete(self): - view = self.viewset_class.as_view({"delete": "item_action"}) - request = self.factory.delete("/", "{}", content_type=self.media_type) - response = view(request, pk="1") - self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code) diff --git a/tests/test_views.py b/tests/test_views.py index f8967982..c4466f72 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,6 +1,7 @@ import pytest from django.urls import path, reverse from rest_framework import status +from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.views import APIView @@ -9,8 +10,9 @@ from rest_framework_json_api.relations import ResourceRelatedField from rest_framework_json_api.renderers import JSONRenderer from rest_framework_json_api.utils import format_link_segment -from rest_framework_json_api.views import ModelViewSet +from rest_framework_json_api.views import ModelViewSet, ReadOnlyModelViewSet from tests.models import BasicModel +from tests.serializers import BasicModelSerializer class TestModelViewSet: @@ -55,6 +57,43 @@ class RelatedFieldNameView(ModelViewSet): assert view.get_related_field_name() == related_model_field_name +class TestReadonlyModelViewSet: + @pytest.mark.parametrize( + "method", + ["get", "post", "patch", "delete"], + ) + @pytest.mark.parametrize( + "custom_action,action_kwargs", + [("list_action", {}), ("detail_action", {"pk": 1})], + ) + def test_custom_action_allows_all_methods( + self, rf, method, custom_action, action_kwargs + ): + """ + Test that write methods are allowed on custom list actions. + + Even though a read only view only allows reading, custom actions + should be allowed to define other methods which are allowed. + """ + + class ReadOnlyModelViewSetWithCustomActions(ReadOnlyModelViewSet): + serializer_class = BasicModelSerializer + queryset = BasicModel.objects.all() + + @action(detail=False, methods=["get", "post", "patch", "delete"]) + def list_action(self, request): + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(detail=True, methods=["get", "post", "patch", "delete"]) + def detail_action(self, request, pk): + return Response(status=status.HTTP_204_NO_CONTENT) + + view = ReadOnlyModelViewSetWithCustomActions.as_view({method: custom_action}) + request = getattr(rf, method)("/", data={}) + response = view(request, **action_kwargs) + assert response.status_code == status.HTTP_204_NO_CONTENT + + class TestAPIView: @pytest.mark.urls(__name__) def test_patch(self, client): From 73f7ed63c07694f74fb7e76efaf98a77917b8e4a Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 28 Mar 2022 15:07:50 +0400 Subject: [PATCH 124/252] Added TestModelViewSet action tests in pytest style (#1060) --- example/tests/test_views.py | 131 ------------------------------------ tests/models.py | 3 + tests/test_views.py | 84 +++++++++++++++++++++-- tests/views.py | 4 +- 4 files changed, 84 insertions(+), 138 deletions(-) diff --git a/example/tests/test_views.py b/example/tests/test_views.py index 592a72bc..f6947fef 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -1,5 +1,4 @@ import json -from datetime import datetime from django.test import RequestFactory, override_settings from django.utils import timezone @@ -579,133 +578,3 @@ def _get_create_response(self, data, view): user = self.create_user("user", "pass") force_authenticate(request, user) return view(request) - - -class TestModelViewSet(TestBase): - def setUp(self): - self.author = Author.objects.create( - name="Super powerful superhero", email="i.am@lost.com" - ) - self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") - - def test_no_content_response(self): - url = f"/blogs/{self.blog.pk}" - response = self.client.delete(url) - assert response.status_code == 204, response.rendered_content.decode() - assert len(response.rendered_content) == 0, response.rendered_content.decode() - - -class TestBlogViewSet(APITestCase): - def setUp(self): - self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") - self.entry = Entry.objects.create( - blog=self.blog, - headline="headline one", - body_text="body_text two", - ) - - def test_get_object_gives_correct_blog(self): - url = reverse("entry-blog", kwargs={"entry_pk": self.entry.id}) - resp = self.client.get(url) - expected = { - "data": { - "attributes": {"name": self.blog.name}, - "id": f"{self.blog.id}", - "links": {"self": f"http://testserver/blogs/{self.blog.id}"}, - "meta": {"copyright": datetime.now().year}, - "relationships": {"tags": {"data": [], "meta": {"count": 0}}}, - "type": "blogs", - }, - "meta": {"apiDocs": "/docs/api/blogs"}, - } - got = resp.json() - self.assertEqual(got, expected) - - -class TestEntryViewSet(APITestCase): - def setUp(self): - self.blog = Blog.objects.create(name="Some Blog", tagline="It's a blog") - self.first_entry = Entry.objects.create( - blog=self.blog, - headline="headline two", - body_text="body_text two", - ) - self.second_entry = Entry.objects.create( - blog=self.blog, - headline="headline two", - body_text="body_text two", - ) - self.maxDiff = None - - def test_get_object_gives_correct_entry(self): - url = reverse("entry-featured", kwargs={"entry_pk": self.first_entry.id}) - resp = self.client.get(url) - expected = { - "data": { - "attributes": { - "bodyText": self.second_entry.body_text, - "headline": self.second_entry.headline, - "modDate": self.second_entry.mod_date, - "pubDate": self.second_entry.pub_date, - }, - "id": f"{self.second_entry.id}", - "meta": {"bodyFormat": "text"}, - "relationships": { - "authors": {"data": [], "meta": {"count": 0}}, - "blog": { - "data": { - "id": f"{self.second_entry.blog_id}", - "type": "blogs", - } - }, - "blogHyperlinked": { - "links": { - "related": "http://testserver/entries/{}" - "/blog".format(self.second_entry.id), - "self": "http://testserver/entries/{}" - "/relationships/blog_hyperlinked".format( - self.second_entry.id - ), - } - }, - "comments": {"data": [], "meta": {"count": 0}}, - "commentsHyperlinked": { - "links": { - "related": "http://testserver/entries/{}" - "/comments".format(self.second_entry.id), - "self": "http://testserver/entries/{}/relationships" - "/comments_hyperlinked".format(self.second_entry.id), - } - }, - "featuredHyperlinked": { - "links": { - "related": "http://testserver/entries/{}" - "/featured".format(self.second_entry.id), - "self": "http://testserver/entries/{}/relationships" - "/featured_hyperlinked".format(self.second_entry.id), - } - }, - "suggested": { - "data": [{"id": "1", "type": "entries"}], - "links": { - "related": "http://testserver/entries/{}" - "/suggested/".format(self.second_entry.id), - "self": "http://testserver/entries/{}" - "/relationships/suggested".format(self.second_entry.id), - }, - }, - "suggestedHyperlinked": { - "links": { - "related": "http://testserver/entries/{}" - "/suggested/".format(self.second_entry.id), - "self": "http://testserver/entries/{}/relationships" - "/suggested_hyperlinked".format(self.second_entry.id), - } - }, - "tags": {"data": [], "meta": {"count": 0}}, - }, - "type": "posts", - } - } - got = resp.json() - self.assertEqual(got, expected) diff --git a/tests/models.py b/tests/models.py index 3c3e6146..6ad9ad6a 100644 --- a/tests/models.py +++ b/tests/models.py @@ -14,6 +14,9 @@ class Meta: class BasicModel(DJAModel): text = models.CharField(max_length=100) + class Meta: + ordering = ("id",) + # Models for relations tests # ManyToMany diff --git a/tests/test_views.py b/tests/test_views.py index c4466f72..42680d6a 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -3,6 +3,7 @@ from rest_framework import status from rest_framework.decorators import action from rest_framework.response import Response +from rest_framework.routers import SimpleRouter from rest_framework.views import APIView from rest_framework_json_api import serializers @@ -13,6 +14,7 @@ from rest_framework_json_api.views import ModelViewSet, ReadOnlyModelViewSet from tests.models import BasicModel from tests.serializers import BasicModelSerializer +from tests.views import BasicModelViewSet class TestModelViewSet: @@ -56,6 +58,70 @@ class RelatedFieldNameView(ModelViewSet): assert view.get_related_field_name() == related_model_field_name + @pytest.mark.urls(__name__) + def test_list(self, client, model): + url = reverse("basic-model-list") + response = client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "data": [ + { + "type": "BasicModel", + "id": str(model.pk), + "attributes": {"text": "Model"}, + } + ], + "links": { + "first": "http://testserver/basic_models/?page%5Bnumber%5D=1", + "last": "http://testserver/basic_models/?page%5Bnumber%5D=1", + "next": None, + "prev": None, + }, + "meta": {"pagination": {"count": 1, "page": 1, "pages": 1}}, + } + + @pytest.mark.urls(__name__) + def test_retrieve(self, client, model): + url = reverse("basic-model-detail", kwargs={"pk": model.pk}) + response = client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "data": { + "type": "BasicModel", + "id": str(model.pk), + "attributes": {"text": "Model"}, + } + } + + @pytest.mark.urls(__name__) + def test_patch(self, client, model): + data = { + "data": { + "id": str(model.pk), + "type": "BasicModel", + "attributes": {"text": "changed"}, + } + } + + url = reverse("basic-model-detail", kwargs={"pk": model.pk}) + response = client.patch(url, data=data) + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "data": { + "type": "BasicModel", + "id": str(model.pk), + "attributes": {"text": "changed"}, + } + } + + @pytest.mark.urls(__name__) + def test_delete(self, client, model): + url = reverse("basic-model-detail", kwargs={"pk": model.pk}) + response = client.delete(url) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert BasicModel.objects.count() == 0 + assert len(response.rendered_content) == 0 + class TestReadonlyModelViewSet: @pytest.mark.parametrize( @@ -108,11 +174,17 @@ def test_patch(self, client): url = reverse("custom") response = client.patch(url, data=data) - result = response.json() + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "data": { + "type": "custom", + "id": "123", + "attributes": {"body": "hello"}, + } + } + - assert result["data"]["id"] == str(123) - assert result["data"]["type"] == "custom" - assert result["data"]["attributes"]["body"] == "hello" +# Routing setup class CustomModel: @@ -140,6 +212,10 @@ def patch(self, request, *args, **kwargs): return Response(status=status.HTTP_200_OK, data=serializer.data) +router = SimpleRouter() +router.register(r"basic_models", BasicModelViewSet, basename="basic-model") + urlpatterns = [ path("custom", CustomAPIView.as_view(), name="custom"), ] +urlpatterns += router.urls diff --git a/tests/views.py b/tests/views.py index e7046a52..42d8a0b0 100644 --- a/tests/views.py +++ b/tests/views.py @@ -5,6 +5,4 @@ class BasicModelViewSet(ModelViewSet): serializer_class = BasicModelSerializer - - class Meta: - model = BasicModel + queryset = BasicModel.objects.all() From 0e93808a748185434f4caa728dabb4f3e9c2cbd7 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 22 Apr 2022 11:37:07 -0700 Subject: [PATCH 125/252] Scheduled biweekly dependency update for week 16 (#1062) * Update black from 22.1.0 to 22.3.0 * Update sphinx from 4.4.0 to 4.5.0 * Update twine from 3.8.0 to 4.0.0 * Update faker from 13.0.0 to 13.3.4 * Update pytest from 7.0.1 to 7.1.1 * Update syrupy from 1.7.4 to 2.0.0 * Updated snapshots to newest amber format Co-authored-by: Oliver Sauder --- example/tests/__snapshots__/test_errors.ambr | 170 +++++++++--------- example/tests/__snapshots__/test_openapi.ambr | 30 ++-- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-documentation.txt | 2 +- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 6 +- 6 files changed, 106 insertions(+), 106 deletions(-) diff --git a/example/tests/__snapshots__/test_errors.ambr b/example/tests/__snapshots__/test_errors.ambr index d025fd4e..1ef85ab8 100644 --- a/example/tests/__snapshots__/test_errors.ambr +++ b/example/tests/__snapshots__/test_errors.ambr @@ -1,133 +1,133 @@ # name: test_first_level_attribute_error - { - 'errors': [ - { + dict({ + 'errors': list([ + dict({ 'code': 'required', 'detail': 'This field is required.', - 'source': { + 'source': dict({ 'pointer': '/data/attributes/headline', - }, + }), 'status': '400', - }, - ], - } ---- + }), + ]), + }) +# --- # name: test_first_level_custom_attribute_error - { - 'errors': [ - { + dict({ + 'errors': list([ + dict({ 'detail': 'Too short', - 'source': { + 'source': dict({ 'pointer': '/data/attributes/body-text', - }, + }), 'title': 'Too Short title', - }, - ], - } ---- + }), + ]), + }) +# --- # name: test_many_third_level_dict_errors - { - 'errors': [ - { + dict({ + 'errors': list([ + dict({ 'code': 'required', 'detail': 'This field is required.', - 'source': { + 'source': dict({ 'pointer': '/data/attributes/comments/0/attachment/data', - }, + }), 'status': '400', - }, - { + }), + dict({ 'code': 'required', 'detail': 'This field is required.', - 'source': { + 'source': dict({ 'pointer': '/data/attributes/comments/0/body', - }, + }), 'status': '400', - }, - ], - } ---- + }), + ]), + }) +# --- # name: test_relationship_errors_has_correct_pointers - { - 'errors': [ - { + dict({ + 'errors': list([ + dict({ 'code': 'incorrect_type', 'detail': 'Incorrect type. Expected resource identifier object, received str.', - 'source': { + 'source': dict({ 'pointer': '/data/relationships/author', - }, + }), 'status': '400', - }, - ], - } ---- + }), + ]), + }) +# --- # name: test_second_level_array_error - { - 'errors': [ - { + dict({ + 'errors': list([ + dict({ 'code': 'required', 'detail': 'This field is required.', - 'source': { + 'source': dict({ 'pointer': '/data/attributes/comments/0/body', - }, + }), 'status': '400', - }, - ], - } ---- + }), + ]), + }) +# --- # name: test_second_level_dict_error - { - 'errors': [ - { + dict({ + 'errors': list([ + dict({ 'code': 'required', 'detail': 'This field is required.', - 'source': { + 'source': dict({ 'pointer': '/data/attributes/comment/body', - }, + }), 'status': '400', - }, - ], - } ---- + }), + ]), + }) +# --- # name: test_third_level_array_error - { - 'errors': [ - { + dict({ + 'errors': list([ + dict({ 'code': 'required', 'detail': 'This field is required.', - 'source': { + 'source': dict({ 'pointer': '/data/attributes/comments/0/attachments/0/data', - }, + }), 'status': '400', - }, - ], - } ---- + }), + ]), + }) +# --- # name: test_third_level_custom_array_error - { - 'errors': [ - { + dict({ + 'errors': list([ + dict({ 'code': 'invalid', 'detail': 'Too short data', - 'source': { + 'source': dict({ 'pointer': '/data/attributes/comments/0/attachments/0/data', - }, + }), 'status': '400', - }, - ], - } ---- + }), + ]), + }) +# --- # name: test_third_level_dict_error - { - 'errors': [ - { + dict({ + 'errors': list([ + dict({ 'code': 'required', 'detail': 'This field is required.', - 'source': { + 'source': dict({ 'pointer': '/data/attributes/comments/0/attachment/data', - }, + }), 'status': '400', - }, - ], - } ---- + }), + ]), + }) +# --- diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr index cb03ca73..fb8c2abe 100644 --- a/example/tests/__snapshots__/test_openapi.ambr +++ b/example/tests/__snapshots__/test_openapi.ambr @@ -1,5 +1,5 @@ # name: test_delete_request - ' + ''' { "description": "", "operationId": "destroy/authors/{id}", @@ -63,10 +63,10 @@ "authors" ] } - ' ---- + ''' +# --- # name: test_patch_request - ' + ''' { "description": "", "operationId": "update/authors/{id}", @@ -245,10 +245,10 @@ "authors" ] } - ' ---- + ''' +# --- # name: test_path_with_id_parameter - ' + ''' { "description": "", "operationId": "retrieve/authors/{id}/", @@ -355,10 +355,10 @@ "authors" ] } - ' ---- + ''' +# --- # name: test_path_without_parameters - ' + ''' { "description": "", "operationId": "List/authors/", @@ -477,10 +477,10 @@ "authors" ] } - ' ---- + ''' +# --- # name: test_post_request - ' + ''' { "description": "", "operationId": "create/authors/", @@ -665,5 +665,5 @@ "authors" ] } - ' ---- + ''' +# --- diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index c5664ddb..e0660b38 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==22.1.0 +black==22.3.0 flake8==4.0.1 flake8-isort==4.1.1 isort==5.10.1 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 9c9e2db0..8b4ae902 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==4.4.0 +Sphinx==4.5.0 sphinx_rtd_theme==1.0.0 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index f9d2c8fd..4d384665 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==3.8.0 +twine==4.0.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 934e5ad6..81186b70 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,8 +1,8 @@ django-debug-toolbar==3.2.4 factory-boy==3.2.1 -Faker==13.0.0 -pytest==7.0.1 +Faker==13.3.4 +pytest==7.1.1 pytest-cov==3.0.0 pytest-django==4.5.2 pytest-factoryboy==2.1.0 -syrupy==1.7.4 +syrupy==2.0.0 From e49af3fb52a39d507e1d013ab44f93d94dca1438 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 17 May 2022 23:48:29 -0700 Subject: [PATCH 126/252] Scheduled biweekly dependency update for week 20 (#1064) * Update django-debug-toolbar from 3.2.4 to 3.4.0 * Update faker from 13.3.4 to 13.11.1 * Update pytest from 7.1.1 to 7.1.2 * Update pytest-factoryboy from 2.1.0 to 2.3.0 * Update syrupy from 2.0.0 to 2.3.0 * Removed for DJA not directly related debug toolbar debug toolbar caused issues in newer version preventing from updating deps therefore removing it. Co-authored-by: Oliver Sauder --- example/settings/dev.py | 3 --- example/urls.py | 8 -------- requirements/requirements-testing.txt | 9 ++++----- 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/example/settings/dev.py b/example/settings/dev.py index c02180eb..80482ffa 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -26,7 +26,6 @@ "rest_framework", "polymorphic", "example", - "debug_toolbar", "django_filters", "tests", ] @@ -62,8 +61,6 @@ PASSWORD_HASHERS = ("django.contrib.auth.hashers.UnsaltedMD5PasswordHasher",) -MIDDLEWARE = ("debug_toolbar.middleware.DebugToolbarMiddleware",) - INTERNAL_IPS = ("127.0.0.1",) JSON_API_FORMAT_FIELD_NAMES = "camelize" diff --git a/example/urls.py b/example/urls.py index 84ca1e9f..3d1cf2fa 100644 --- a/example/urls.py +++ b/example/urls.py @@ -1,4 +1,3 @@ -from django.conf import settings from django.urls import include, path, re_path from django.views.generic import TemplateView from rest_framework import routers @@ -105,10 +104,3 @@ name="swagger-ui", ), ] - -if settings.DEBUG: - import debug_toolbar - - urlpatterns = [ - path("__debug__/", include(debug_toolbar.urls)), - ] + urlpatterns diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 81186b70..c0fdf712 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,8 +1,7 @@ -django-debug-toolbar==3.2.4 factory-boy==3.2.1 -Faker==13.3.4 -pytest==7.1.1 +Faker==13.11.1 +pytest==7.1.2 pytest-cov==3.0.0 pytest-django==4.5.2 -pytest-factoryboy==2.1.0 -syrupy==2.0.0 +pytest-factoryboy==2.3.0 +syrupy==2.3.0 From f9eb27750fc813b4e4fa1a46dc377e232ef82975 Mon Sep 17 00:00:00 2001 From: leo-naeka Date: Wed, 29 Jun 2022 12:07:27 +0200 Subject: [PATCH 127/252] Fixed invalid relationship pointer in error object (#1070) Co-authored-by: Oliver Sauder --- CHANGELOG.md | 6 +++ example/tests/__snapshots__/test_errors.ambr | 56 +++++++++++++++++++- example/tests/test_errors.py | 56 ++++++++++++++++++-- rest_framework_json_api/utils.py | 4 +- 4 files changed, 116 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6b257d4..a1b72009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## [Unreleased] + +### Fixed + +* Fixed invalid relationship pointer in error objects when field naming formatting is used. + ## [5.0.0] - 2022-01-03 This release is not backwards compatible. For easy migration best upgrade first to version diff --git a/example/tests/__snapshots__/test_errors.ambr b/example/tests/__snapshots__/test_errors.ambr index 1ef85ab8..fe872b46 100644 --- a/example/tests/__snapshots__/test_errors.ambr +++ b/example/tests/__snapshots__/test_errors.ambr @@ -47,14 +47,66 @@ ]), }) # --- -# name: test_relationship_errors_has_correct_pointers +# name: test_relationship_errors_has_correct_pointers_with_camelize dict({ 'errors': list([ dict({ 'code': 'incorrect_type', 'detail': 'Incorrect type. Expected resource identifier object, received str.', 'source': dict({ - 'pointer': '/data/relationships/author', + 'pointer': '/data/relationships/authors', + }), + 'status': '400', + }), + dict({ + 'code': 'incorrect_type', + 'detail': 'Incorrect type. Expected resource identifier object, received str.', + 'source': dict({ + 'pointer': '/data/relationships/mainAuthor', + }), + 'status': '400', + }), + ]), + }) +# --- +# name: test_relationship_errors_has_correct_pointers_with_dasherize + dict({ + 'errors': list([ + dict({ + 'code': 'incorrect_type', + 'detail': 'Incorrect type. Expected resource identifier object, received str.', + 'source': dict({ + 'pointer': '/data/relationships/authors', + }), + 'status': '400', + }), + dict({ + 'code': 'incorrect_type', + 'detail': 'Incorrect type. Expected resource identifier object, received str.', + 'source': dict({ + 'pointer': '/data/relationships/main-author', + }), + 'status': '400', + }), + ]), + }) +# --- +# name: test_relationship_errors_has_correct_pointers_with_no_formatting + dict({ + 'errors': list([ + dict({ + 'code': 'incorrect_type', + 'detail': 'Incorrect type. Expected resource identifier object, received str.', + 'source': dict({ + 'pointer': '/data/relationships/authors', + }), + 'status': '400', + }), + dict({ + 'code': 'incorrect_type', + 'detail': 'Incorrect type. Expected resource identifier object, received str.', + 'source': dict({ + 'pointer': '/data/relationships/main_author', }), 'status': '400', }), diff --git a/example/tests/test_errors.py b/example/tests/test_errors.py index 2267ec6e..72e742c7 100644 --- a/example/tests/test_errors.py +++ b/example/tests/test_errors.py @@ -30,9 +30,12 @@ class EntrySerializer(serializers.Serializer): comment = CommentSerializer(required=False) headline = serializers.CharField(allow_null=True, required=True) body_text = serializers.CharField() - author = serializers.ResourceRelatedField( + main_author = serializers.ResourceRelatedField( queryset=Author.objects.all(), required=False ) + authors = serializers.ResourceRelatedField( + queryset=Author.objects.all(), required=False, many=True + ) def validate(self, attrs): body_text = attrs["body_text"] @@ -195,7 +198,31 @@ def test_many_third_level_dict_errors(client, some_blog, snapshot): assert snapshot == perform_error_test(client, data) -def test_relationship_errors_has_correct_pointers(client, some_blog, snapshot): +def test_relationship_errors_has_correct_pointers_with_camelize( + client, some_blog, snapshot +): + data = { + "data": { + "type": "entries", + "attributes": { + "blog": some_blog.pk, + "bodyText": "body_text", + "headline": "headline", + }, + "relationships": { + "mainAuthor": {"data": {"id": "INVALID_ID", "type": "authors"}}, + "authors": {"data": [{"id": "INVALID_ID", "type": "authors"}]}, + }, + } + } + + assert snapshot == perform_error_test(client, data) + + +@override_settings(JSON_API_FORMAT_FIELD_NAMES="dasherize") +def test_relationship_errors_has_correct_pointers_with_dasherize( + client, some_blog, snapshot +): data = { "data": { "type": "entries", @@ -205,7 +232,30 @@ def test_relationship_errors_has_correct_pointers(client, some_blog, snapshot): "headline": "headline", }, "relationships": { - "author": {"data": {"id": "INVALID_ID", "type": "authors"}} + "main-author": {"data": {"id": "INVALID_ID", "type": "authors"}}, + "authors": {"data": [{"id": "INVALID_ID", "type": "authors"}]}, + }, + } + } + + assert snapshot == perform_error_test(client, data) + + +@override_settings(JSON_API_FORMAT_FIELD_NAMES=None) +def test_relationship_errors_has_correct_pointers_with_no_formatting( + client, some_blog, snapshot +): + data = { + "data": { + "type": "entries", + "attributes": { + "blog": some_blog.pk, + "body_text": "body_text", + "headline": "headline", + }, + "relationships": { + "main_author": {"data": {"id": "INVALID_ID", "type": "authors"}}, + "authors": {"data": [{"id": "INVALID_ID", "type": "authors"}]}, }, } } diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index aba9de5a..8d2dfa73 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -380,7 +380,9 @@ def format_drf_errors(response, context, exc): serializer = context["view"].get_serializer() fields = get_serializer_fields(serializer) or dict() relationship_fields = [ - name for name, field in fields.items() if is_relationship_field(field) + format_field_name(name) + for name, field in fields.items() + if is_relationship_field(field) ] for field, error in response.data.items(): From bbeb13ada97cee3e86d3943015d317e84bf2b6c4 Mon Sep 17 00:00:00 2001 From: Ashley Loewen Date: Wed, 13 Jul 2022 16:59:47 -0400 Subject: [PATCH 128/252] Support many related field (#1065) Co-authored-by: Oliver Sauder --- AUTHORS | 1 + CHANGELOG.md | 1 + rest_framework_json_api/utils.py | 18 ++++++------- tests/models.py | 11 ++++++++ tests/test_utils.py | 43 ++++++++++++++++++++++++++++++++ 5 files changed, 65 insertions(+), 9 deletions(-) diff --git a/AUTHORS b/AUTHORS index fa91e9b2..419144b5 100644 --- a/AUTHORS +++ b/AUTHORS @@ -2,6 +2,7 @@ Adam Wróbel Adam Ziolkowski Alan Crosswell Anton Shutik +Ashley Loewen Asif Saif Uddin Beni Keller Boris Pleshakov diff --git a/CHANGELOG.md b/CHANGELOG.md index a1b72009..bb0ec748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Fixed * Fixed invalid relationship pointer in error objects when field naming formatting is used. +* Properly resolved related resource type when nested source field is defined. ## [5.0.0] - 2022-01-03 diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 8d2dfa73..8317c10f 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -211,15 +211,15 @@ def get_related_resource_type(relation): relation_model = relation.model elif hasattr(relation, "get_queryset") and relation.get_queryset() is not None: relation_model = relation.get_queryset().model - elif ( - getattr(relation, "many", False) - and hasattr(relation.child, "Meta") - and hasattr(relation.child.Meta, "model") - ): - # For ManyToMany relationships, get the model from the child - # serializer of the list serializer - relation_model = relation.child.Meta.model - else: + elif hasattr(relation, "child_relation"): + # For ManyRelatedField relationships, get the model from the child relationship + try: + return get_related_resource_type(relation.child_relation) + except AttributeError: + # Some read only relationships fail to get it directly, fall through to + # get via the parent + pass + if not relation_model: parent_serializer = relation.parent parent_model = None if isinstance(parent_serializer, PolymorphicModelSerializer): diff --git a/tests/models.py b/tests/models.py index 6ad9ad6a..63718fc6 100644 --- a/tests/models.py +++ b/tests/models.py @@ -39,3 +39,14 @@ class ForeignKeySource(DJAModel): target = models.ForeignKey( ForeignKeyTarget, related_name="sources", on_delete=models.CASCADE ) + + +class NestedRelatedSource(DJAModel): + m2m_source = models.ManyToManyField(ManyToManySource, related_name="nested_source") + fk_source = models.ForeignKey( + ForeignKeySource, related_name="nested_source", on_delete=models.CASCADE + ) + m2m_target = models.ManyToManyField(ManyToManySource, related_name="nested_source") + fk_target = models.ForeignKey( + ForeignKeySource, related_name="nested_source", on_delete=models.CASCADE + ) diff --git a/tests/test_utils.py b/tests/test_utils.py index efb1325d..9b47e9ff 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -25,6 +25,7 @@ ForeignKeyTarget, ManyToManySource, ManyToManyTarget, + NestedRelatedSource, ) from tests.serializers import BasicModelSerializer @@ -313,6 +314,48 @@ class Meta: assert get_related_resource_type(field) == output +@pytest.mark.parametrize( + "field,output,related_field_kwargs", + [ + ( + "m2m_source.targets", + "ManyToManyTarget", + {"many": True, "queryset": ManyToManyTarget.objects.all()}, + ), + ( + "m2m_target.sources.", + "ManyToManySource", + {"many": True, "queryset": ManyToManySource.objects.all()}, + ), + ( + "fk_source.target", + "ForeignKeyTarget", + {"many": True, "queryset": ForeignKeyTarget.objects.all()}, + ), + ( + "fk_target.source", + "ForeignKeySource", + {"many": True, "queryset": ForeignKeySource.objects.all()}, + ), + ], +) +def test_get_related_resource_type_from_nested_source( + db, field, output, related_field_kwargs +): + class RelatedResourceTypeSerializer(serializers.ModelSerializer): + relation = serializers.ResourceRelatedField( + source=field, **related_field_kwargs + ) + + class Meta: + model = NestedRelatedSource + fields = ("relation",) + + serializer = RelatedResourceTypeSerializer() + field = serializer.fields["relation"] + assert get_related_resource_type(field) == output + + @pytest.mark.parametrize( "related_field_kwargs,output", [ From d7f2cc326509178effd11c643f094a8267055661 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 14 Jul 2022 21:44:52 +0200 Subject: [PATCH 129/252] Removed support for Django 2.2 (#1072) Co-authored-by: Alan Crosswell --- CHANGELOG.md | 6 ++++++ README.rst | 2 +- docs/getting-started.md | 2 +- setup.py | 2 +- tox.ini | 5 ++--- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb0ec748..e871249e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,11 +15,17 @@ any parts of the framework not mentioned in the documentation should generally b * Fixed invalid relationship pointer in error objects when field naming formatting is used. * Properly resolved related resource type when nested source field is defined. +### Removed + +* Removed support for Django 2.2. + ## [5.0.0] - 2022-01-03 This release is not backwards compatible. For easy migration best upgrade first to version 4.3.0 and resolve all deprecation warnings before updating to 5.0.0 +This is the last release supporting Django 2.2. + ### Added * Added support for Django REST framework 3.13. diff --git a/README.rst b/README.rst index 64984627..d7309745 100644 --- a/README.rst +++ b/README.rst @@ -89,7 +89,7 @@ Requirements ------------ 1. Python (3.7, 3.8, 3.9, 3.10) -2. Django (2.2, 3.2, 4.0) +2. Django (3.2, 4.0) 3. Django REST framework (3.12, 3.13) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index c838cb22..1a9b5cc2 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.7, 3.8, 3.9, 3.10) -2. Django (2.2, 3.2, 4.0) +2. Django (3.2, 4.0) 3. Django REST framework (3.12, 3.13) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/setup.py b/setup.py index b0f912f9..b8b68b52 100755 --- a/setup.py +++ b/setup.py @@ -97,7 +97,7 @@ def get_package_data(package): install_requires=[ "inflection>=0.5.0", "djangorestframework>=3.12,<3.14", - "django>=2.2,<4.1", + "django>=3.2,<4.1", ], extras_require={ "django-polymorphic": ["django-polymorphic>=3.0"], diff --git a/tox.ini b/tox.ini index 048d91d3..cee02210 100644 --- a/tox.ini +++ b/tox.ini @@ -1,12 +1,11 @@ [tox] envlist = - py{37,38,39,310}-django{22,32}-drf{312,313,master}, + py{37,38,39,310}-django32-drf{312,313,master}, py{38,39,310}-django40-drf{313,master}, lint,docs [testenv] deps = - django22: Django>=2.2,<2.3 django32: Django>=3.2,<3.3 django40: Django>=4.0,<5.0 drf312: djangorestframework>=3.12,<3.13 @@ -45,5 +44,5 @@ deps = commands = sphinx-build -W -b html -d docs/_build/doctrees docs docs/_build/html -[testenv:py{37,38,39,310}-django{22,32,40}-drfmaster] +[testenv:py{37,38,39,310}-django{32,40}-drfmaster] ignore_outcome = true From db5cf1c7b1a2a404a8fea41122293a8b7967c9a1 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 18 Jul 2022 13:48:14 -0500 Subject: [PATCH 130/252] Scheduled biweekly dependency update for week 29 (#1073) * Update black from 22.3.0 to 22.6.0 * Update sphinx from 4.5.0 to 5.0.2 * Update django-filter from 21.1 to 22.1 * Update twine from 4.0.0 to 4.0.1 * Update faker from 13.11.1 to 13.15.0 * Update pytest-factoryboy from 2.3.0 to 2.5.0 * Update syrupy from 2.3.0 to 2.3.1 * Set language code to en in docs Co-authored-by: Oliver Sauder --- docs/conf.py | 2 +- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-documentation.txt | 2 +- requirements/requirements-optionals.txt | 2 +- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 6 +++--- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 9038cd2b..ea67682f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -78,7 +78,7 @@ # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = "en" # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index e0660b38..d9cd493b 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==22.3.0 +black==22.6.0 flake8==4.0.1 flake8-isort==4.1.1 isort==5.10.1 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 8b4ae902..77309503 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==4.5.0 +Sphinx==5.0.2 sphinx_rtd_theme==1.0.0 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 0fcfcbbf..9d274a6d 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,4 +1,4 @@ -django-filter==21.1 +django-filter==22.1 django-polymorphic==3.1.0 pyyaml==6.0 uritemplate==4.1.1 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index 4d384665..d76bde9d 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==4.0.0 +twine==4.0.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index c0fdf712..2a24638a 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.2.1 -Faker==13.11.1 +Faker==13.15.0 pytest==7.1.2 pytest-cov==3.0.0 pytest-django==4.5.2 -pytest-factoryboy==2.3.0 -syrupy==2.3.0 +pytest-factoryboy==2.5.0 +syrupy==2.3.1 From 46551a9aada19c503e363b20b47765009038d996 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 4 Aug 2022 14:53:57 +0200 Subject: [PATCH 131/252] Added Django support for version 4.1. (#1077) --- CHANGELOG.md | 4 ++++ README.rst | 2 +- docs/getting-started.md | 2 +- example/tests/unit/test_default_drf_serializers.py | 12 ++++++------ example/tests/unit/test_renderers.py | 12 ++++++------ setup.cfg | 2 ++ setup.py | 2 +- tox.ini | 7 ++++--- 8 files changed, 25 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e871249e..91faffde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ any parts of the framework not mentioned in the documentation should generally b * Fixed invalid relationship pointer in error objects when field naming formatting is used. * Properly resolved related resource type when nested source field is defined. +### Added + +* Added support for Django 4.1. + ### Removed * Removed support for Django 2.2. diff --git a/README.rst b/README.rst index d7309745..c82ebb2d 100644 --- a/README.rst +++ b/README.rst @@ -89,7 +89,7 @@ Requirements ------------ 1. Python (3.7, 3.8, 3.9, 3.10) -2. Django (3.2, 4.0) +2. Django (3.2, 4.0, 4.1) 3. Django REST framework (3.12, 3.13) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index 1a9b5cc2..e03b415b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.7, 3.8, 3.9, 3.10) -2. Django (3.2, 4.0) +2. Django (3.2, 4.0, 4.1) 3. Django REST framework (3.12, 3.13) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/example/tests/unit/test_default_drf_serializers.py b/example/tests/unit/test_default_drf_serializers.py index 31449856..d74edac7 100644 --- a/example/tests/unit/test_default_drf_serializers.py +++ b/example/tests/unit/test_default_drf_serializers.py @@ -44,26 +44,26 @@ class DummyTestViewSet(viewsets.ModelViewSet): serializer_class = DummyTestSerializer -def render_dummy_test_serialized_view(view_class): - serializer = DummyTestSerializer(instance=Entry()) +def render_dummy_test_serialized_view(view_class, entry): + serializer = DummyTestSerializer(instance=entry) renderer = JSONRenderer() return renderer.render(serializer.data, renderer_context={"view": view_class()}) # tests -def test_simple_reverse_relation_included_renderer(): +def test_simple_reverse_relation_included_renderer(db, entry): """ Test renderer when a single reverse fk relation is passed. """ - rendered = render_dummy_test_serialized_view(DummyTestViewSet) + rendered = render_dummy_test_serialized_view(DummyTestViewSet, entry) assert rendered -def test_render_format_field_names(settings): +def test_render_format_field_names(db, settings, entry): """Test that json field is kept untouched.""" settings.JSON_API_FORMAT_FIELD_NAMES = "dasherize" - rendered = render_dummy_test_serialized_view(DummyTestViewSet) + rendered = render_dummy_test_serialized_view(DummyTestViewSet, entry) result = json.loads(rendered.decode()) assert result["data"]["attributes"]["json-field"] == {"JsonKey": "JsonValue"} diff --git a/example/tests/unit/test_renderers.py b/example/tests/unit/test_renderers.py index 47d37b5b..00eaf28b 100644 --- a/example/tests/unit/test_renderers.py +++ b/example/tests/unit/test_renderers.py @@ -88,25 +88,25 @@ def render_dummy_test_serialized_view(view_class, instance): return renderer.render(serializer.data, renderer_context={"view": view_class()}) -def test_simple_reverse_relation_included_renderer(): +def test_simple_reverse_relation_included_renderer(db, entry): """ Test renderer when a single reverse fk relation is passed. """ - rendered = render_dummy_test_serialized_view(DummyTestViewSet, Entry()) + rendered = render_dummy_test_serialized_view(DummyTestViewSet, entry) assert rendered -def test_simple_reverse_relation_included_read_only_viewset(): - rendered = render_dummy_test_serialized_view(ReadOnlyDummyTestViewSet, Entry()) +def test_simple_reverse_relation_included_read_only_viewset(db, entry): + rendered = render_dummy_test_serialized_view(ReadOnlyDummyTestViewSet, entry) assert rendered -def test_render_format_field_names(settings): +def test_render_format_field_names(db, entry, settings): """Test that json field is kept untouched.""" settings.JSON_API_FORMAT_FIELD_NAMES = "dasherize" - rendered = render_dummy_test_serialized_view(DummyTestViewSet, Entry()) + rendered = render_dummy_test_serialized_view(DummyTestViewSet, entry) result = json.loads(rendered.decode()) assert result["data"]["attributes"]["json-field"] == {"JsonKey": "JsonValue"} diff --git a/setup.cfg b/setup.cfg index 527ddd6b..2eb90b9b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -58,6 +58,8 @@ DJANGO_SETTINGS_MODULE=example.settings.test filterwarnings = error::DeprecationWarning error::PendingDeprecationWarning + # Remove when DRF is not depending on it anymore + ignore:The django.utils.timezone.utc alias is deprecated. testpaths = example tests diff --git a/setup.py b/setup.py index b8b68b52..6ac4d9f3 100755 --- a/setup.py +++ b/setup.py @@ -97,7 +97,7 @@ def get_package_data(package): install_requires=[ "inflection>=0.5.0", "djangorestframework>=3.12,<3.14", - "django>=3.2,<4.1", + "django>=3.2,<4.2", ], extras_require={ "django-polymorphic": ["django-polymorphic>=3.0"], diff --git a/tox.ini b/tox.ini index cee02210..4d52ce48 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,14 @@ [tox] envlist = py{37,38,39,310}-django32-drf{312,313,master}, - py{38,39,310}-django40-drf{313,master}, + py{38,39,310}-django{40,41}-drf{313,master}, lint,docs [testenv] deps = django32: Django>=3.2,<3.3 - django40: Django>=4.0,<5.0 + django40: Django>=4.0,<4.1 + django41: Django>=4.1,<4.2 drf312: djangorestframework>=3.12,<3.13 drf313: djangorestframework>=3.13,<3.14 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip @@ -44,5 +45,5 @@ deps = commands = sphinx-build -W -b html -d docs/_build/doctrees docs docs/_build/html -[testenv:py{37,38,39,310}-django{32,40}-drfmaster] +[testenv:py{37,38,39,310}-django{32,40,41}-drfmaster] ignore_outcome = true From c695c31469ed11a2aed945a49e969eab29545606 Mon Sep 17 00:00:00 2001 From: Arkadiy Korotaev <131850+koriaf@users.noreply.github.com> Date: Fri, 5 Aug 2022 17:21:57 +0200 Subject: [PATCH 132/252] Avoid overwriting of pointer when formatting error object (#1074) This enables to use custom error object with custom source pointers. --- CHANGELOG.md | 1 + docs/usage.md | 17 ++++++++++++- rest_framework_json_api/utils.py | 9 +++---- tests/test_utils.py | 43 ++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91faffde..91551dca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ any parts of the framework not mentioned in the documentation should generally b * Fixed invalid relationship pointer in error objects when field naming formatting is used. * Properly resolved related resource type when nested source field is defined. +* Prevented overwriting of pointer in custom error object ### Added diff --git a/docs/usage.md b/docs/usage.md index 939e4e6a..952fe80c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -237,7 +237,7 @@ class MyViewset(ModelViewSet): ``` -### Exception handling +### Error objects / Exception handling For the `exception_handler` class, if the optional `JSON_API_UNIFORM_EXCEPTIONS` is set to True, all exceptions will respond with the JSON:API [error format](https://jsonapi.org/format/#error-objects). @@ -245,6 +245,21 @@ all exceptions will respond with the JSON:API [error format](https://jsonapi.org When `JSON_API_UNIFORM_EXCEPTIONS` is False (the default), non-JSON:API views will respond with the normal DRF error format. +In case you need a custom error object you can simply raise an `rest_framework.serializers.ValidationError` like the following: + +```python +raise serializers.ValidationError( + { + "id": "your-id", + "detail": "your detail message", + "source": { + "pointer": "/data/attributes/your-pointer", + } + + } +) +``` + ### Performance Testing If you are trying to see if your viewsets are configured properly to optimize performance, diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 8317c10f..2e86e762 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -417,21 +417,20 @@ def format_error_object(message, pointer, response): if isinstance(message, dict): # as there is no required field in error object we check that all fields are string - # except links and source which might be a dict + # except links, source or meta which might be a dict is_custom_error = all( [ isinstance(value, str) for key, value in message.items() - if key not in ["links", "source"] + if key not in ["links", "source", "meta"] ] ) if is_custom_error: if "source" not in message: message["source"] = {} - message["source"] = { - "pointer": pointer, - } + if "pointer" not in message["source"]: + message["source"]["pointer"] = pointer errors.append(message) else: for k, v in message.items(): diff --git a/tests/test_utils.py b/tests/test_utils.py index 9b47e9ff..038e8ce9 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -7,6 +7,7 @@ from rest_framework_json_api import serializers from rest_framework_json_api.utils import ( + format_error_object, format_field_name, format_field_names, format_link_segment, @@ -389,3 +390,45 @@ class SerializerWithoutResourceName(serializers.Serializer): "can not detect 'resource_name' on serializer " "'SerializerWithoutResourceName' in module 'tests.test_utils'" ) + + +@pytest.mark.parametrize( + "message,pointer,response,result", + [ + # Test that pointer does not get overridden in custom error + ( + { + "status": "400", + "source": { + "pointer": "/data/custom-pointer", + }, + "meta": {"key": "value"}, + }, + "/data/default-pointer", + Response(status.HTTP_400_BAD_REQUEST), + [ + { + "status": "400", + "source": {"pointer": "/data/custom-pointer"}, + "meta": {"key": "value"}, + } + ], + ), + # Test that pointer gets added to custom error + ( + { + "detail": "custom message", + }, + "/data/default-pointer", + Response(status.HTTP_400_BAD_REQUEST), + [ + { + "detail": "custom message", + "source": {"pointer": "/data/default-pointer"}, + } + ], + ), + ], +) +def test_format_error_object(message, pointer, response, result): + assert result == format_error_object(message, pointer, response) From 38cf7f01339afd35e225125e34a6ba0e0690dfd0 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 15 Aug 2022 15:25:47 -0500 Subject: [PATCH 133/252] Scheduled biweekly dependency update for week 33 (#1081) * Update flake8 from 4.0.1 to 5.0.4 * Update flake8-isort from 4.1.1 to 4.2.0 * Update sphinx from 5.0.2 to 5.1.1 * Update faker from 13.15.0 to 14.0.0 * Update syrupy from 2.3.1 to 3.0.0 --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-documentation.txt | 2 +- requirements/requirements-testing.txt | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index d9cd493b..9215fdc6 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ black==22.6.0 -flake8==4.0.1 -flake8-isort==4.1.1 +flake8==5.0.4 +flake8-isort==4.2.0 isort==5.10.1 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 77309503..cadfb380 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==5.0.2 +Sphinx==5.1.1 sphinx_rtd_theme==1.0.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 2a24638a..ef200e11 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.2.1 -Faker==13.15.0 +Faker==14.0.0 pytest==7.1.2 pytest-cov==3.0.0 pytest-django==4.5.2 pytest-factoryboy==2.5.0 -syrupy==2.3.1 +syrupy==3.0.0 From c8511a07b2cd456cd5701f3d49391d9d0183c36d Mon Sep 17 00:00:00 2001 From: Alexander Seidmann <40206670+aseidma@users.noreply.github.com> Date: Sat, 20 Aug 2022 15:09:56 +0200 Subject: [PATCH 134/252] Added a separate parse_data method to JSONParser (#1083) Co-authored-by: Alex Seidmann Co-authored-by: Oliver Sauder --- AUTHORS | 1 + CHANGELOG.md | 1 + rest_framework_json_api/parsers.py | 19 +++++++++++++------ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/AUTHORS b/AUTHORS index 419144b5..15a5393f 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,6 +1,7 @@ Adam Wróbel Adam Ziolkowski Alan Crosswell +Alex Seidmann Anton Shutik Ashley Loewen Asif Saif Uddin diff --git a/CHANGELOG.md b/CHANGELOG.md index 91551dca..41990d56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Added * Added support for Django 4.1. +* Expanded JSONParser API with `parse_data` method ### Removed diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index f77a6501..2aef1aa1 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -70,14 +70,11 @@ def parse_metadata(result): else: return {} - def parse(self, stream, media_type=None, parser_context=None): + def parse_data(self, result, parser_context): """ - Parses the incoming bytestream as JSON and returns the resulting data + Formats the output of calling JSONParser to match the JSON:API specification + and returns the result. """ - result = super().parse( - stream, media_type=media_type, parser_context=parser_context - ) - if not isinstance(result, dict) or "data" not in result: raise ParseError("Received document does not contain primary data") @@ -166,3 +163,13 @@ def parse(self, stream, media_type=None, parser_context=None): parsed_data.update(self.parse_relationships(data)) parsed_data.update(self.parse_metadata(result)) return parsed_data + + def parse(self, stream, media_type=None, parser_context=None): + """ + Parses the incoming bytestream as JSON and returns the resulting data + """ + result = super().parse( + stream, media_type=media_type, parser_context=parser_context + ) + + return self.parse_data(result, parser_context) From c016b8bc5a71256c015cdbb91413ccf70dc8e3fd Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sun, 21 Aug 2022 03:09:03 -0400 Subject: [PATCH 135/252] Document how to override DRF generateschema generator_class. (#1084) * Document how to override DRF generateschema generator_class. * changelog --- CHANGELOG.md | 4 ++++ docs/usage.md | 5 ++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41990d56..73a9a738 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ any parts of the framework not mentioned in the documentation should generally b * Added support for Django 4.1. * Expanded JSONParser API with `parse_data` method +### Changed + +* Improved documentation of how to override DRF's generateschema `--generator_class` to generate a proper DJA OAS schema. + ### Removed * Removed support for Django 2.2. diff --git a/docs/usage.md b/docs/usage.md index 952fe80c..40bb0f27 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1085,11 +1085,10 @@ class MySchemaGenerator(JSONAPISchemaGenerator): ### Generate a Static Schema on Command Line See [DRF documentation for generateschema](https://www.django-rest-framework.org/api-guide/schemas/#generating-a-static-schema-with-the-generateschema-management-command) -To generate an OAS schema document, use something like: +To generate a static OAS schema document, using the `generateschema` management command, you **must override DRF's default** `generator_class` with the DJA-specific version: ```text -$ django-admin generateschema --settings=example.settings \ - --generator_class myapp.views.MySchemaGenerator >myschema.yaml +$ ./manage.py generateschema --generator_class rest_framework_json_api.schemas.openapi.SchemaGenerator ``` You can then use any number of OAS tools such as From 897305c76806b1185ff06e3636f10978754d4398 Mon Sep 17 00:00:00 2001 From: Jonas Kiefer <17874616+jokiefer@users.noreply.github.com> Date: Tue, 6 Sep 2022 22:59:57 +0200 Subject: [PATCH 136/252] Adhered to field naming format setting when generating schema (#1048) Co-authored-by: Oliver Sauder --- CHANGELOG.md | 1 + example/migrations/0012_author_full_name.py | 19 ++++++++ example/models.py | 1 + example/serializers.py | 1 + example/tests/__snapshots__/test_openapi.ambr | 45 +++++++++++++++++++ example/tests/test_format_keys.py | 1 + example/views.py | 1 + .../django_filters/backends.py | 5 ++- rest_framework_json_api/schemas/openapi.py | 2 +- 9 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 example/migrations/0012_author_full_name.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 73a9a738..46dc3419 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ any parts of the framework not mentioned in the documentation should generally b * Fixed invalid relationship pointer in error objects when field naming formatting is used. * Properly resolved related resource type when nested source field is defined. * Prevented overwriting of pointer in custom error object +* Adhered to field naming format setting when generating schema parameters and required fields. ### Added diff --git a/example/migrations/0012_author_full_name.py b/example/migrations/0012_author_full_name.py new file mode 100644 index 00000000..1f67558e --- /dev/null +++ b/example/migrations/0012_author_full_name.py @@ -0,0 +1,19 @@ +# Generated by Django 4.1 on 2022-09-06 15:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("example", "0011_rename_type_author_author_type_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="author", + name="full_name", + field=models.CharField(default="", max_length=50), + preserve_default=False, + ), + ] diff --git a/example/models.py b/example/models.py index c3850076..8fc86c22 100644 --- a/example/models.py +++ b/example/models.py @@ -53,6 +53,7 @@ class Meta: class Author(BaseModel): name = models.CharField(max_length=50) + full_name = models.CharField(max_length=50) email = models.EmailField() author_type = models.ForeignKey(AuthorType, null=True, on_delete=models.CASCADE) diff --git a/example/serializers.py b/example/serializers.py index b9bf71d6..75a1de11 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -268,6 +268,7 @@ class Meta: model = Author fields = ( "name", + "full_name", "email", "bio", "entries", diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr index fb8c2abe..a0231471 100644 --- a/example/tests/__snapshots__/test_openapi.ambr +++ b/example/tests/__snapshots__/test_openapi.ambr @@ -104,6 +104,10 @@ "maxLength": 254, "type": "string" }, + "fullName": { + "maxLength": 50, + "type": "string" + }, "name": { "maxLength": 50, "type": "string" @@ -280,6 +284,24 @@ "type": "string" } }, + { + "description": "author_type", + "in": "query", + "name": "filter[authorType]", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "name", + "in": "query", + "name": "filter[name]", + "required": false, + "schema": { + "type": "string" + } + }, { "description": "A search term.", "in": "query", @@ -399,6 +421,24 @@ "type": "string" } }, + { + "description": "author_type", + "in": "query", + "name": "filter[authorType]", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "name", + "in": "query", + "name": "filter[name]", + "required": false, + "schema": { + "type": "string" + } + }, { "description": "A search term.", "in": "query", @@ -508,6 +548,10 @@ "maxLength": 254, "type": "string" }, + "fullName": { + "maxLength": 50, + "type": "string" + }, "name": { "maxLength": 50, "type": "string" @@ -515,6 +559,7 @@ }, "required": [ "name", + "fullName", "email" ], "type": "object" diff --git a/example/tests/test_format_keys.py b/example/tests/test_format_keys.py index b91cf595..d0e36d81 100644 --- a/example/tests/test_format_keys.py +++ b/example/tests/test_format_keys.py @@ -55,6 +55,7 @@ def test_options_format_field_names(db, client): data = response.json()["data"] expected_keys = { "name", + "fullName", "email", "bio", "entries", diff --git a/example/views.py b/example/views.py index 27ec6c2e..edb49ba8 100644 --- a/example/views.py +++ b/example/views.py @@ -222,6 +222,7 @@ class NoFiltersetEntryViewSet(EntryViewSet): class AuthorViewSet(ModelViewSet): queryset = Author.objects.all() + filterset_fields = ("author_type", "name") def get_serializer_class(self): serializer_classes = { diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index 5302bf09..365831c7 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -4,7 +4,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.settings import api_settings -from rest_framework_json_api.utils import undo_format_field_name +from rest_framework_json_api.utils import format_field_name, undo_format_field_name class DjangoFilterBackend(DjangoFilterBackend): @@ -139,5 +139,6 @@ def get_schema_operation_parameters(self, view): result = super().get_schema_operation_parameters(view) for res in result: if "name" in res: - res["name"] = "filter[{}]".format(res["name"]).replace("__", ".") + name = format_field_name(res["name"].replace("__", ".")) + res["name"] = "filter[{}]".format(name) return result diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index c3d553b7..1aa690fa 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -667,7 +667,7 @@ def map_serializer(self, serializer): continue if field.required: - required.append(field.field_name) + required.append(format_field_name(field.field_name)) schema = self.map_field(field) if field.read_only: From cbf52a3ee98c708eee06c50278cf73de5ab4f3e6 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 7 Sep 2022 00:45:46 +0200 Subject: [PATCH 137/252] Avoided using same related name twice in tests models (#1086) --- tests/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/models.py b/tests/models.py index 63718fc6..a483f2d0 100644 --- a/tests/models.py +++ b/tests/models.py @@ -46,7 +46,7 @@ class NestedRelatedSource(DJAModel): fk_source = models.ForeignKey( ForeignKeySource, related_name="nested_source", on_delete=models.CASCADE ) - m2m_target = models.ManyToManyField(ManyToManySource, related_name="nested_source") + m2m_target = models.ManyToManyField(ManyToManySource, related_name="nested_target") fk_target = models.ForeignKey( - ForeignKeySource, related_name="nested_source", on_delete=models.CASCADE + ForeignKeySource, related_name="nested_target", on_delete=models.CASCADE ) From 3a832bd5770a5f7b6766b0d2098476dbe857e5f6 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 23 Sep 2022 00:06:13 +0200 Subject: [PATCH 138/252] Added support for DRF 3.14 and dropping support for DRF 3.12. (#1090) --- CHANGELOG.md | 4 +++- README.rst | 2 +- docs/getting-started.md | 2 +- rest_framework_json_api/compat.py | 15 +++++++++++++++ rest_framework_json_api/metadata.py | 3 ++- rest_framework_json_api/schemas/openapi.py | 5 +++-- setup.py | 2 +- tox.ini | 7 ++++--- 8 files changed, 30 insertions(+), 10 deletions(-) create mode 100644 rest_framework_json_api/compat.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 46dc3419..a295aee1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Added * Added support for Django 4.1. +* Added support for Django REST framework 3.14. * Expanded JSONParser API with `parse_data` method ### Changed @@ -29,13 +30,14 @@ any parts of the framework not mentioned in the documentation should generally b ### Removed * Removed support for Django 2.2. +* Removed support for Django REST framework 3.12. ## [5.0.0] - 2022-01-03 This release is not backwards compatible. For easy migration best upgrade first to version 4.3.0 and resolve all deprecation warnings before updating to 5.0.0 -This is the last release supporting Django 2.2. +This is the last release supporting Django 2.2 and Django REST framework 3.12. ### Added diff --git a/README.rst b/README.rst index c82ebb2d..8812bc5d 100644 --- a/README.rst +++ b/README.rst @@ -90,7 +90,7 @@ Requirements 1. Python (3.7, 3.8, 3.9, 3.10) 2. Django (3.2, 4.0, 4.1) -3. Django REST framework (3.12, 3.13) +3. Django REST framework (3.13, 3.14) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index e03b415b..ab0e961d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -53,7 +53,7 @@ like the following: 1. Python (3.7, 3.8, 3.9, 3.10) 2. Django (3.2, 4.0, 4.1) -3. Django REST framework (3.12, 3.13) +3. Django REST framework (3.13, 3.14) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/rest_framework_json_api/compat.py b/rest_framework_json_api/compat.py new file mode 100644 index 00000000..96648eb0 --- /dev/null +++ b/rest_framework_json_api/compat.py @@ -0,0 +1,15 @@ +# Django REST framework 3.14 removed NullBooleanField +# can be removed once support for DRF 3.13 is dropped. +try: + from rest_framework.serializers import NullBooleanField +except ImportError: # pragma: no cover + NullBooleanField = object() + + +# Django REST framework 3.14 deprecates usage of `_get_reference`. +# can be removed once support for DRF 3.13 is dropped. +def get_reference(schema, serializer): + try: + return schema.get_reference(serializer) + except AttributeError: # pragma: no cover + return schema._get_reference(serializer) diff --git a/rest_framework_json_api/metadata.py b/rest_framework_json_api/metadata.py index 6b88e578..1906de71 100644 --- a/rest_framework_json_api/metadata.py +++ b/rest_framework_json_api/metadata.py @@ -7,6 +7,7 @@ from rest_framework.settings import api_settings from rest_framework.utils.field_mapping import ClassLookupDict +from rest_framework_json_api.compat import NullBooleanField from rest_framework_json_api.utils import format_field_name, get_related_resource_type @@ -23,7 +24,7 @@ class JSONAPIMetadata(SimpleMetadata): serializers.Field: "GenericField", serializers.RelatedField: "Relationship", serializers.BooleanField: "Boolean", - serializers.NullBooleanField: "Boolean", + NullBooleanField: "Boolean", serializers.CharField: "String", serializers.URLField: "URL", serializers.EmailField: "Email", diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 1aa690fa..59ed3a3e 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -7,6 +7,7 @@ from rest_framework.schemas.utils import is_list_view from rest_framework_json_api import serializers, views +from rest_framework_json_api.compat import get_reference from rest_framework_json_api.utils import format_field_name @@ -515,10 +516,10 @@ def _get_toplevel_200_response(self, operation, collection=True): if collection: data = { "type": "array", - "items": self._get_reference(self.view.get_serializer()), + "items": get_reference(self, self.view.get_serializer()), } else: - data = self._get_reference(self.view.get_serializer()) + data = get_reference(self, self.view.get_serializer()) return { "description": operation["operationId"], diff --git a/setup.py b/setup.py index 6ac4d9f3..87e296f1 100755 --- a/setup.py +++ b/setup.py @@ -96,7 +96,7 @@ def get_package_data(package): ], install_requires=[ "inflection>=0.5.0", - "djangorestframework>=3.12,<3.14", + "djangorestframework>=3.13,<3.15", "django>=3.2,<4.2", ], extras_require={ diff --git a/tox.ini b/tox.ini index 4d52ce48..3d1219b5 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,8 @@ [tox] envlist = - py{37,38,39,310}-django32-drf{312,313,master}, - py{38,39,310}-django{40,41}-drf{313,master}, + py{37,38,39,310}-django32-drf{313,314,master}, + py{38,39,310}-django40-drf{313,314,master}, + py{38,39,310}-django41-drf{314,master}, lint,docs [testenv] @@ -9,8 +10,8 @@ deps = django32: Django>=3.2,<3.3 django40: Django>=4.0,<4.1 django41: Django>=4.1,<4.2 - drf312: djangorestframework>=3.12,<3.13 drf313: djangorestframework>=3.13,<3.14 + drf314: djangorestframework>=3.14,<3.15 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip -rrequirements/requirements-testing.txt -rrequirements/requirements-optionals.txt From bc2cfd521937eac3bf4ec345f2dce2fc0ae6caec Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 23 Sep 2022 15:00:03 -0500 Subject: [PATCH 139/252] Scheduled biweekly dependency update for week 38 (#1089) * Update black from 22.6.0 to 22.8.0 * Update faker from 14.0.0 to 14.2.0 * Update pytest from 7.1.2 to 7.1.3 Co-authored-by: Oliver Sauder --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-testing.txt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 9215fdc6..819acc6f 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==22.6.0 +black==22.8.0 flake8==5.0.4 flake8-isort==4.2.0 isort==5.10.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index ef200e11..949a2c39 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,6 +1,6 @@ factory-boy==3.2.1 -Faker==14.0.0 -pytest==7.1.2 +Faker==14.2.0 +pytest==7.1.3 pytest-cov==3.0.0 pytest-django==4.5.2 pytest-factoryboy==2.5.0 From 81f4fe292c861dd78edde4e13f3b0e54b863b4c9 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sat, 24 Sep 2022 14:43:40 +0200 Subject: [PATCH 140/252] Release 6.0.0 (#1091) --- CHANGELOG.md | 2 ++ rest_framework_json_api/__init__.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a295aee1..713f60e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ any parts of the framework not mentioned in the documentation should generally b ## [Unreleased] +## [6.0.0] - 2022-09-24 + ### Fixed * Fixed invalid relationship pointer in error objects when field naming formatting is used. diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index e8cac7c0..dd0ae050 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,5 +1,5 @@ __title__ = "djangorestframework-jsonapi" -__version__ = "5.0.0" +__version__ = "6.0.0" __author__ = "" __license__ = "BSD" __copyright__ = "" From 236c7376bdde14b52ba493a1dbe7ad3356d2f465 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 25 Oct 2022 22:46:22 +0400 Subject: [PATCH 141/252] Added support for Python 3.11 (#1096) --- .github/workflows/tests.yml | 2 +- CHANGELOG.md | 4 ++++ README.rst | 2 +- docs/getting-started.md | 2 +- example/tests/test_model_viewsets.py | 10 +++++++--- setup.py | 1 + tox.ini | 2 +- 7 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2a77e701..9b277e3f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7", "3.8", "3.9", "3.10"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] env: PYTHON: ${{ matrix.python-version }} steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 713f60e3..ef228d1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ any parts of the framework not mentioned in the documentation should generally b ## [Unreleased] +### Added + +* Added support for Python 3.11. + ## [6.0.0] - 2022-09-24 ### Fixed diff --git a/README.rst b/README.rst index 8812bc5d..0e11f8b1 100644 --- a/README.rst +++ b/README.rst @@ -88,7 +88,7 @@ As a Django REST framework JSON:API (short DJA) we are trying to address followi Requirements ------------ -1. Python (3.7, 3.8, 3.9, 3.10) +1. Python (3.7, 3.8, 3.9, 3.10, 3.11) 2. Django (3.2, 4.0, 4.1) 3. Django REST framework (3.13, 3.14) diff --git a/docs/getting-started.md b/docs/getting-started.md index ab0e961d..b73af5ea 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -51,7 +51,7 @@ like the following: ## Requirements -1. Python (3.7, 3.8, 3.9, 3.10) +1. Python (3.7, 3.8, 3.9, 3.10, 3.11) 2. Django (3.2, 4.0, 4.1) 3. Django REST framework (3.13, 3.14) diff --git a/example/tests/test_model_viewsets.py b/example/tests/test_model_viewsets.py index 21a641f8..ce6c7ba5 100644 --- a/example/tests/test_model_viewsets.py +++ b/example/tests/test_model_viewsets.py @@ -208,10 +208,14 @@ def test_key_in_post(self): def test_404_error_pointer(self): self.client.login(username="miles", password="pw") not_found_url = reverse("user-detail", kwargs={"pk": 12345}) - errors = { - "errors": [{"detail": "Not found.", "status": "404", "code": "not_found"}] - } response = self.client.get(not_found_url) assert 404 == response.status_code + result = response.json() + + # exact detail message differs between Python versions + # but only relevant is that there is a message + assert result["errors"][0].pop("detail") is not None + + errors = {"errors": [{"status": "404", "code": "not_found"}]} assert errors == response.json() diff --git a/setup.py b/setup.py index 87e296f1..3c60bba2 100755 --- a/setup.py +++ b/setup.py @@ -90,6 +90,7 @@ def get_package_data(package): "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", diff --git a/tox.ini b/tox.ini index 3d1219b5..563b08ef 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,7 @@ envlist = py{37,38,39,310}-django32-drf{313,314,master}, py{38,39,310}-django40-drf{313,314,master}, - py{38,39,310}-django41-drf{314,master}, + py{38,39,310,311}-django41-drf{314,master}, lint,docs [testenv] From 5e6872bb26c1c3a57040cc0c67f8459f5b59abc1 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 25 Oct 2022 23:07:58 +0400 Subject: [PATCH 142/252] Added 3rdParty packages policy (#1097) --- docs/usage.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/usage.md b/docs/usage.md index 40bb0f27..aa5f7767 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1128,3 +1128,16 @@ urlpatterns = [ ] ``` +## Third Party Packages + +### About Third Party Packages + +Following the example of [Django REST framework](https://www.django-rest-framework.org/community/third-party-packages/) we also support, encourage and strongly favor the creation of Third Party Packages to encapsulate new behavior rather than adding additional functionality directly to Django REST framework JSON:API especially when it involves adding new dependencies. + +We aim to make creating third party packages as easy as possible, whilst keeping a simple and well maintained core API. By promoting third party packages we ensure that the responsibility for a package remains with its author. If a package proves suitably popular it can always be considered for inclusion into the DJA core. + +### Existing Third Party Packages + +To submit new content, [open an issue](https://github.com/django-json-api/django-rest-framework-json-api/issues/new/choose) or [create a pull request](https://github.com/django-json-api/django-rest-framework-json-api/compare). + +* [drf-yasg-json-api](https://github.com/glowka/drf-yasg-json-api) - Automated generation of Swagger/OpenAPI 2.0 from Django REST framework JSON:API endpoints. From 4e182922882b72cdd1b50626a39e1a27cb36384b Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 26 Oct 2022 17:09:58 +0400 Subject: [PATCH 143/252] Added support to overwrite serializer methods in schema class (#1098) --- CHANGELOG.md | 4 +++ rest_framework_json_api/schemas/openapi.py | 34 ++++++++++++++-------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef228d1d..5919fae3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ any parts of the framework not mentioned in the documentation should generally b * Added support for Python 3.11. +### Changed + +* Added support to overwrite serializer methods in customized schema class + ## [6.0.0] - 2022-09-24 ### Fixed diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 59ed3a3e..99fc2462 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -427,9 +427,9 @@ def get_operation(self, path, method): # get request and response code schemas if method == "GET": if is_list_view(path, method, self.view): - self._add_get_collection_response(operation) + self._add_get_collection_response(operation, path) else: - self._add_get_item_response(operation) + self._add_get_item_response(operation, path) elif method == "POST": self._add_post_item_response(operation, path) elif method == "PATCH": @@ -487,25 +487,29 @@ def _get_sort_parameters(self, path, method): """ return [{"$ref": "#/components/parameters/sort"}] - def _add_get_collection_response(self, operation): + def _add_get_collection_response(self, operation, path): """ Add GET 200 response for a collection to operation """ operation["responses"] = { - "200": self._get_toplevel_200_response(operation, collection=True) + "200": self._get_toplevel_200_response( + operation, path, "GET", collection=True + ) } self._add_get_4xx_responses(operation) - def _add_get_item_response(self, operation): + def _add_get_item_response(self, operation, path): """ add GET 200 response for an item to operation """ operation["responses"] = { - "200": self._get_toplevel_200_response(operation, collection=False) + "200": self._get_toplevel_200_response( + operation, path, "GET", collection=False + ) } self._add_get_4xx_responses(operation) - def _get_toplevel_200_response(self, operation, collection=True): + def _get_toplevel_200_response(self, operation, path, method, collection=True): """ return top-level JSON:API GET 200 response @@ -516,10 +520,12 @@ def _get_toplevel_200_response(self, operation, collection=True): if collection: data = { "type": "array", - "items": get_reference(self, self.view.get_serializer()), + "items": get_reference( + self, self.get_response_serializer(path, method) + ), } else: - data = get_reference(self, self.view.get_serializer()) + data = get_reference(self, self.get_response_serializer(path, method)) return { "description": operation["operationId"], @@ -555,7 +561,9 @@ def _add_post_item_response(self, operation, path): """ operation["requestBody"] = self.get_request_body(path, "POST") operation["responses"] = { - "201": self._get_toplevel_200_response(operation, collection=False) + "201": self._get_toplevel_200_response( + operation, path, "POST", collection=False + ) } operation["responses"]["201"]["description"] = ( "[Created](https://jsonapi.org/format/#crud-creating-responses-201). " @@ -574,7 +582,9 @@ def _add_patch_item_response(self, operation, path): """ operation["requestBody"] = self.get_request_body(path, "PATCH") operation["responses"] = { - "200": self._get_toplevel_200_response(operation, collection=False) + "200": self._get_toplevel_200_response( + operation, path, "PATCH", collection=False + ) } self._add_patch_4xx_responses(operation) @@ -591,7 +601,7 @@ def get_request_body(self, path, method): """ A request body is required by JSON:API for POST, PATCH, and DELETE methods. """ - serializer = self.get_serializer(path, method) + serializer = self.get_request_serializer(path, method) if not isinstance(serializer, (serializers.BaseSerializer,)): return {} is_relationship = isinstance(self.view, views.RelationshipView) From eab94bd862399c77694c96da03421b8bc477ffb7 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 27 Oct 2022 17:06:15 +0400 Subject: [PATCH 144/252] Clarified how to set resource object identifier type (#1099) `resource_name` is not used in JSON:API spec. Using resource object identifier type in the documentation makes it easier for newcomers to find this setting. Also clarified usage of `resource_name = False`, a question which occasionally has come up during discussions. --- docs/usage.md | 38 +++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index aa5f7767..5da8846a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -278,18 +278,15 @@ class MyModelSerializer(serializers.ModelSerializer): # ... ``` -### Setting the resource_name - -You may manually set the `resource_name` property on views, serializers, or -models to specify the `type` key in the json output. In the case of setting the -`resource_name` property for models you must include the property inside a -`JSONAPIMeta` class on the model. It is automatically set for you as the plural -of the view or model name except on resources that do not subclass -`rest_framework.viewsets.ModelViewSet`: +### Setting resource identifier object type +You may manually set resource identifier object type by using `resource_name` property on views, serializers, or +models. In case of setting the `resource_name` property for models you must include the property inside a +`JSONAPIMeta` class on the model. It is usually automatically set for you as the plural of the view or model name except +on resources that do not subclass `rest_framework.viewsets.ModelViewSet`: Example - `resource_name` on View: -``` python +```python class Me(generics.GenericAPIView): """ Current user's identity endpoint. @@ -301,12 +298,9 @@ class Me(generics.GenericAPIView): allowed_methods = ['GET'] permission_classes = (permissions.IsAuthenticated, ) ``` -If you set the `resource_name` property on the object to `False` the data -will be returned without modification. - Example - `resource_name` on Model: -``` python +```python class Me(models.Model): """ A simple model @@ -324,11 +318,25 @@ on the view should be used sparingly as serializers and models are shared betwee multiple endpoints. Setting the `resource_name` on views may result in a different `type` being set depending on which endpoint the resource is fetched from. +### Build JSON:API view output manually + +If in a view you want to build the output manually, you can set `resource_name` to `False`. + +Example: +```python +class User(ModelViewSet): + resource_name = False + queryset = User.objects.all() + serializer_class = UserSerializer + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + data = [{"id": 1, "type": "users", "attributes": {"fullName": "Test User"}}]) +``` ### Inflecting object and relation keys -This package includes the ability (off by default) to automatically convert [JSON:API field names](https://jsonapi.org/format/#document-resource-object-fields) of requests and responses from the python/rest_framework's preferred underscore to -a format of your choice. To hook this up include the following setting in your +This package includes the ability (off by default) to automatically convert [JSON:API field names](https://jsonapi.org/format/#document-resource-object-fields) of requests and responses from the Django REST framework's preferred underscore to a format of your choice. To hook this up include the following setting in your project settings: ``` python From 0b374e186b2b70886ca7fcb8ada55ad19906709a Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 28 Oct 2022 00:18:16 -0500 Subject: [PATCH 145/252] Scheduled biweekly dependency update for week 42 (#1095) * Update black from 22.8.0 to 22.10.0 * Update flake8-isort from 4.2.0 to 5.0.0 * Update sphinx from 5.1.1 to 5.3.0 * Update faker from 14.2.0 to 15.1.1 * Update pytest-cov from 3.0.0 to 4.0.0 * Update syrupy from 3.0.0 to 3.0.2 Co-authored-by: Oliver Sauder --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-documentation.txt | 2 +- requirements/requirements-testing.txt | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 819acc6f..2b00c11f 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==22.8.0 +black==22.10.0 flake8==5.0.4 -flake8-isort==4.2.0 +flake8-isort==5.0.0 isort==5.10.1 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index cadfb380..9801cc37 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==5.1.1 +Sphinx==5.3.0 sphinx_rtd_theme==1.0.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 949a2c39..24bbcc65 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.2.1 -Faker==14.2.0 +Faker==15.1.1 pytest==7.1.3 -pytest-cov==3.0.0 +pytest-cov==4.0.0 pytest-django==4.5.2 pytest-factoryboy==2.5.0 -syrupy==3.0.0 +syrupy==3.0.2 From 55bd31204e83e2c07d63d442fd706a3287412cab Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 8 Nov 2022 06:25:47 -0500 Subject: [PATCH 146/252] Scheduled biweekly dependency update for week 45 (#1100) * Update sphinx_rtd_theme from 1.0.0 to 1.1.1 * Update faker from 15.1.1 to 15.2.0 * Update pytest from 7.1.3 to 7.2.0 * Update syrupy from 3.0.2 to 3.0.4 * Use correct setup name of pytest Co-authored-by: Oliver Sauder --- example/tests/unit/test_pagination.py | 2 +- requirements/requirements-documentation.txt | 2 +- requirements/requirements-testing.txt | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/example/tests/unit/test_pagination.py b/example/tests/unit/test_pagination.py index 45042939..83570e7c 100644 --- a/example/tests/unit/test_pagination.py +++ b/example/tests/unit/test_pagination.py @@ -14,7 +14,7 @@ class TestLimitOffset: Unit tests for `pagination.JsonApiLimitOffsetPagination`. """ - def setup(self): + def setup_method(self): class ExamplePagination(pagination.JsonApiLimitOffsetPagination): default_limit = 10 max_limit = 15 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 9801cc37..95e25adc 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 Sphinx==5.3.0 -sphinx_rtd_theme==1.0.0 +sphinx_rtd_theme==1.1.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 24bbcc65..921fc035 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.2.1 -Faker==15.1.1 -pytest==7.1.3 +Faker==15.2.0 +pytest==7.2.0 pytest-cov==4.0.0 pytest-django==4.5.2 pytest-factoryboy==2.5.0 -syrupy==3.0.2 +syrupy==3.0.4 From 9ced3be23c5b5c7772d11959148e350300e382d0 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 9 Nov 2022 00:43:39 +0400 Subject: [PATCH 147/252] Introduce flake8 bugbear plugin (#1101) * Introduce flake8 bugbear * Enable optional bugbear warnings --- example/settings/dev.py | 2 +- .../test_non_paginated_responses.py | 12 +++--- example/tests/integration/test_pagination.py | 6 +-- .../tests/integration/test_polymorphism.py | 6 +-- example/tests/test_filters.py | 10 ++--- example/tests/test_views.py | 4 +- example/views.py | 4 +- requirements/requirements-codestyle.txt | 1 + .../django_filters/backends.py | 4 +- rest_framework_json_api/exceptions.py | 5 ++- rest_framework_json_api/metadata.py | 2 +- rest_framework_json_api/parsers.py | 4 +- rest_framework_json_api/relations.py | 2 +- rest_framework_json_api/schemas/openapi.py | 43 +++++++++++-------- rest_framework_json_api/serializers.py | 21 ++++----- rest_framework_json_api/utils.py | 16 +++---- rest_framework_json_api/views.py | 14 +++--- setup.cfg | 7 ++- 18 files changed, 88 insertions(+), 75 deletions(-) diff --git a/example/settings/dev.py b/example/settings/dev.py index 80482ffa..8e13ec15 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -68,7 +68,7 @@ REST_FRAMEWORK = { "PAGE_SIZE": 5, "EXCEPTION_HANDLER": "rest_framework_json_api.exceptions.exception_handler", - "DEFAULT_PAGINATION_CLASS": "rest_framework_json_api.pagination.JsonApiPageNumberPagination", + "DEFAULT_PAGINATION_CLASS": "rest_framework_json_api.pagination.JsonApiPageNumberPagination", # noqa: B950 "DEFAULT_PARSER_CLASSES": ( "rest_framework_json_api.parsers.JSONParser", "rest_framework.parsers.FormParser", diff --git a/example/tests/integration/test_non_paginated_responses.py b/example/tests/integration/test_non_paginated_responses.py index 5a2e59c8..3483b7f4 100644 --- a/example/tests/integration/test_non_paginated_responses.py +++ b/example/tests/integration/test_non_paginated_responses.py @@ -29,7 +29,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "blogHyperlinked": { "links": { "related": "http://testserver/entries/1/blog", - "self": "http://testserver/entries/1/relationships/blog_hyperlinked", + "self": "http://testserver/entries/1/relationships/blog_hyperlinked", # noqa: B950 } }, "authors": { @@ -43,7 +43,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "commentsHyperlinked": { "links": { "related": "http://testserver/entries/1/comments", - "self": "http://testserver/entries/1/relationships/comments_hyperlinked", + "self": "http://testserver/entries/1/relationships/comments_hyperlinked", # noqa: B950 } }, "suggested": { @@ -63,7 +63,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "featuredHyperlinked": { "links": { "related": "http://testserver/entries/1/featured", - "self": "http://testserver/entries/1/relationships/featured_hyperlinked", + "self": "http://testserver/entries/1/relationships/featured_hyperlinked", # noqa: B950 } }, "tags": {"data": [], "meta": {"count": 0}}, @@ -84,7 +84,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "blogHyperlinked": { "links": { "related": "http://testserver/entries/2/blog", - "self": "http://testserver/entries/2/relationships/blog_hyperlinked", + "self": "http://testserver/entries/2/relationships/blog_hyperlinked", # noqa: B950 } }, "authors": { @@ -98,7 +98,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "commentsHyperlinked": { "links": { "related": "http://testserver/entries/2/comments", - "self": "http://testserver/entries/2/relationships/comments_hyperlinked", + "self": "http://testserver/entries/2/relationships/comments_hyperlinked", # noqa: B950 } }, "suggested": { @@ -118,7 +118,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "featuredHyperlinked": { "links": { "related": "http://testserver/entries/2/featured", - "self": "http://testserver/entries/2/relationships/featured_hyperlinked", + "self": "http://testserver/entries/2/relationships/featured_hyperlinked", # noqa: B950 } }, "tags": {"data": [], "meta": {"count": 0}}, diff --git a/example/tests/integration/test_pagination.py b/example/tests/integration/test_pagination.py index aedf8c6c..c723fce1 100644 --- a/example/tests/integration/test_pagination.py +++ b/example/tests/integration/test_pagination.py @@ -29,7 +29,7 @@ def test_pagination_with_single_entry(single_entry, client): "blogHyperlinked": { "links": { "related": "http://testserver/entries/1/blog", - "self": "http://testserver/entries/1/relationships/blog_hyperlinked", + "self": "http://testserver/entries/1/relationships/blog_hyperlinked", # noqa: B950 } }, "authors": { @@ -43,7 +43,7 @@ def test_pagination_with_single_entry(single_entry, client): "commentsHyperlinked": { "links": { "related": "http://testserver/entries/1/comments", - "self": "http://testserver/entries/1/relationships/comments_hyperlinked", + "self": "http://testserver/entries/1/relationships/comments_hyperlinked", # noqa: B950 } }, "suggested": { @@ -63,7 +63,7 @@ def test_pagination_with_single_entry(single_entry, client): "featuredHyperlinked": { "links": { "related": "http://testserver/entries/1/featured", - "self": "http://testserver/entries/1/relationships/featured_hyperlinked", + "self": "http://testserver/entries/1/relationships/featured_hyperlinked", # noqa: B950 } }, "tags": { diff --git a/example/tests/integration/test_polymorphism.py b/example/tests/integration/test_polymorphism.py index 69a97220..116243f1 100644 --- a/example/tests/integration/test_polymorphism.py +++ b/example/tests/integration/test_polymorphism.py @@ -33,7 +33,7 @@ def test_polymorphism_on_detail_relations(single_company, client): def test_polymorphism_on_included_relations(single_company, client): response = client.get( reverse("company-detail", kwargs={"pk": single_company.pk}) - + "?include=current_project,future_projects,current_art_project,current_research_project" + + "?include=current_project,future_projects,current_art_project,current_research_project" # noqa: B950 ) content = response.json() assert ( @@ -169,14 +169,14 @@ def test_invalid_type_on_polymorphic_model(client): try: assert ( content["errors"][0]["detail"] - == "The resource object's type (invalidProjects) is not the type that constitute the " + == "The resource object's type (invalidProjects) is not the type that constitute the " # noqa: B950 "collection represented by the endpoint (one of [researchProjects, artProjects])." ) except AssertionError: # Available type list order isn't enforced assert ( content["errors"][0]["detail"] - == "The resource object's type (invalidProjects) is not the type that constitute the " + == "The resource object's type (invalidProjects) is not the type that constitute the " # noqa: B950 "collection represented by the endpoint (one of [artProjects, researchProjects])." ) diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index 5bb12121..ab74dd0b 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -482,7 +482,7 @@ def test_search_keywords(self): "blog": {"data": {"type": "blogs", "id": "1"}}, "blogHyperlinked": { "links": { - "self": "http://testserver/entries/7/relationships/blog_hyperlinked", # noqa: E501 + "self": "http://testserver/entries/7/relationships/blog_hyperlinked", # noqa: B950 "related": "http://testserver/entries/7/blog", } }, @@ -490,7 +490,7 @@ def test_search_keywords(self): "comments": {"meta": {"count": 0}, "data": []}, "commentsHyperlinked": { "links": { - "self": "http://testserver/entries/7/relationships/comments_hyperlinked", # noqa: E501 + "self": "http://testserver/entries/7/relationships/comments_hyperlinked", # noqa: B950 "related": "http://testserver/entries/7/comments", } }, @@ -515,14 +515,14 @@ def test_search_keywords(self): }, "suggestedHyperlinked": { "links": { - "self": "http://testserver/entries/7/relationships/suggested_hyperlinked", # noqa: E501 + "self": "http://testserver/entries/7/relationships/suggested_hyperlinked", # noqa: B950 "related": "http://testserver/entries/7/suggested/", } }, "tags": {"data": [], "meta": {"count": 0}}, "featuredHyperlinked": { "links": { - "self": "http://testserver/entries/7/relationships/featured_hyperlinked", # noqa: E501 + "self": "http://testserver/entries/7/relationships/featured_hyperlinked", # noqa: B950 "related": "http://testserver/entries/7/featured", } }, @@ -550,7 +550,7 @@ def test_search_multiple_keywords(self): 1. For each keyword, search the 4 search_fields for a match and then get the result set which is the union of all results for the given keyword. 2. Intersect those results sets such that *all* keywords are represented. - See `example/fixtures/blogentry.json` for the test content that the searches are based on. + See `example/fixtures/blogentry.json` for what test content the searches are based on. The searches test for both direct entries and related blogs across multiple fields. """ for searches in ( diff --git a/example/tests/test_views.py b/example/tests/test_views.py index f6947fef..ad3fe124 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -562,8 +562,8 @@ def test_if_returns_error_on_bad_endpoint_name(self): expected = [ { "detail": ( - "The resource object's type (bad) is not the type that constitute the collection " - "represented by the endpoint (blogs)." + "The resource object's type (bad) is not the type that constitute the " + "collection represented by the endpoint (blogs)." ), "source": {"pointer": "/data"}, "status": "409", diff --git a/example/views.py b/example/views.py index edb49ba8..b0d92811 100644 --- a/example/views.py +++ b/example/views.py @@ -144,8 +144,8 @@ class NoPagination(JsonApiPageNumberPagination): class NonPaginatedEntryViewSet(EntryViewSet): pagination_class = NoPagination - # override the default filter backends in order to test QueryParameterValidationFilter without - # breaking older usage of non-standard query params like `page_size`. + # override the default filter backends in order to test QueryParameterValidationFilter + # without breaking older usage of non-standard query params like `page_size`. filter_backends = ( QueryParameterValidationFilter, OrderingFilter, diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 2b00c11f..f60aab51 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,5 @@ black==22.10.0 flake8==5.0.4 +flake8-bugbear==22.10.27 flake8-isort==5.0.0 isort==5.10.1 diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index 365831c7..83495004 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -44,7 +44,9 @@ class DjangoFilterBackend(DjangoFilterBackend): - A related resource path can be used: - ``?filter[inventory.item.partNum]=123456`` (where `inventory.item` is the relationship path) + ``?filter[inventory.item.partNum]=123456`` + + where `inventory.item` is the relationship path. If you are also using rest_framework.filters.SearchFilter you'll want to customize the name of the query parameter for searching to make sure it doesn't conflict diff --git a/rest_framework_json_api/exceptions.py b/rest_framework_json_api/exceptions.py index b13ccad5..1c7acbdb 100644 --- a/rest_framework_json_api/exceptions.py +++ b/rest_framework_json_api/exceptions.py @@ -18,8 +18,9 @@ def rendered_with_json_api(view): def exception_handler(exc, context): # Import this here to avoid potential edge-case circular imports, which # crashes with: - # "ImportError: Could not import 'rest_framework_json_api.parsers.JSONParser' for API setting - # 'DEFAULT_PARSER_CLASSES'. ImportError: cannot import name 'exceptions'.'" + # "ImportError: Could not import 'rest_framework_json_api.parsers.JSONParser' for + # API setting 'DEFAULT_PARSER_CLASSES'. + # ImportError: cannot import name 'exceptions'.'" # # Also see: https://github.com/django-json-api/django-rest-framework-json-api/issues/158 from rest_framework.views import exception_handler as drf_exception_handler diff --git a/rest_framework_json_api/metadata.py b/rest_framework_json_api/metadata.py index 1906de71..14b9f69b 100644 --- a/rest_framework_json_api/metadata.py +++ b/rest_framework_json_api/metadata.py @@ -113,7 +113,7 @@ def get_field_info(self, field): field_info["type"] = self.type_lookup[field] try: - serializer_model = getattr(serializer.Meta, "model") + serializer_model = serializer.Meta.model field_info["relationship_type"] = self.relation_type_lookup[ getattr(serializer_model, field.field_name) ] diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index 2aef1aa1..e26f6028 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -85,8 +85,8 @@ def parse_data(self, result, parser_context): from rest_framework_json_api.views import RelationshipView if isinstance(view, RelationshipView): - # We skip parsing the object as JSON:API Resource Identifier Object and not a regular - # Resource Object + # We skip parsing the object as JSON:API Resource Identifier Object and not + # a regular Resource Object if isinstance(data, list): for resource_identifier_object in data: if not ( diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index 6cca15c8..6eb69bdb 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -123,7 +123,7 @@ def get_links(self, obj=None, lookup_field="pk"): # AuthorViewSet.as_view({'get': 'retrieve_related'})) # 2. url(r'^authors/(?P[^/.]+)/bio/$', # AuthorBioViewSet.as_view({'get': 'retrieve'})) - # So, if related_link_url_kwarg == 'pk' it will add 'related_field' parameter to reverse() + # So, if related_link_url_kwarg == 'pk' it adds 'related_field' parameter to reverse() if self.related_link_url_kwarg == "pk": related_kwargs = self_kwargs else: diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 99fc2462..5ddc8143 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -115,11 +115,11 @@ class SchemaGenerator(drf_openapi.SchemaGenerator): "uniqueItems": True, }, # A RelationshipView uses a ResourceIdentifierObjectSerializer (hence the name - # ResourceIdentifierObject returned by get_component_name()) which serializes type and - # id. These can be lists or individual items depending on whether the relationship is - # toMany or toOne so offer both options since we are not iterating over all the - # possible {related_field}'s but rather rendering one path schema which may represent - # toMany and toOne relationships. + # ResourceIdentifierObject returned by get_component_name()) which serializes type + # and id. These can be lists or individual items depending on whether the + # relationship is toMany or toOne so offer both options since we are not iterating + # over all the possible {related_field}'s but rather rendering one path schema + # which may represent toMany and toOne relationships. "ResourceIdentifierObject": { "oneOf": [ {"$ref": "#/components/schemas/relationshipToOne"}, @@ -181,10 +181,12 @@ class SchemaGenerator(drf_openapi.SchemaGenerator): "properties": { "pointer": { "type": "string", - "description": "A [JSON Pointer](https://tools.ietf.org/html/rfc6901) " - "to the associated entity in the request document " - "[e.g. `/data` for a primary data object, or " - "`/data/attributes/title` for a specific attribute.", + "description": ( + "A [JSON Pointer](https://tools.ietf.org/html/rfc6901) " + "to the associated entity in the request document " + "[e.g. `/data` for a primary data object, or " + "`/data/attributes/title` for a specific attribute.", + ), }, "parameter": { "type": "string", @@ -273,7 +275,8 @@ def get_schema(self, request=None, public=False): _, view_endpoints = self._get_paths_and_endpoints(None if public else request) #: `expanded_endpoints` is like view_endpoints with one extra field tacked on: - #: - 'action' copy of current view.action (list/fetch) as this gets reset for each request. + #: - 'action' copy of current view.action (list/fetch) as this gets reset for + # each request. expanded_endpoints = [] for path, method, view in view_endpoints: if hasattr(view, "action") and view.action == "retrieve_related": @@ -371,14 +374,15 @@ def _expand_related(self, path, method, view, view_endpoints): def _find_related_view(self, view_endpoints, related_serializer, parent_view): """ - For a given related_serializer, try to find it's "parent" view instance in view_endpoints. + For a given related_serializer, try to find it's "parent" view instance. + :param view_endpoints: list of all view endpoints :param related_serializer: the related serializer for a given related field :param parent_view: the parent view (used to find toMany vs. toOne). TODO: not actually used. :return:view """ - for path, method, view in view_endpoints: + for _path, _method, view in view_endpoints: view_serializer = view.get_serializer() if isinstance(view_serializer, related_serializer): return view @@ -468,7 +472,7 @@ def _get_fields_parameters(self, path, method): # TODO: See if able to identify the specific types for fields[type]=... and return this: # name: fields # in: query - # description: '[sparse fieldsets](https://jsonapi.org/format/#fetching-sparse-fieldsets)' + # description: '[sparse fieldsets](https://jsonapi.org/format/#fetching-sparse-fieldsets)' # noqa: B950 # required: true # style: deepObject # schema: @@ -609,10 +613,11 @@ def get_request_body(self, path, method): # DRF uses a $ref to the component schema definition, but this # doesn't work for JSON:API due to the different required fields based on # the method, so make those changes and inline another copy of the schema. + # TODO: A future improvement could make this DRYer with multiple component schemas: - # A base schema for each viewset that has no required fields - # One subclassed from the base that requires some fields (`type` but not `id` for POST) - # Another subclassed from base with required type/id but no required attributes (PATCH) + # A base schema for each viewset that has no required fields + # One subclassed from the base that requires some fields (`type` but not `id` for POST) + # Another subclassed from base with required type/id but no required attributes (PATCH) if is_relationship: item_schema = {"$ref": "#/components/schemas/ResourceIdentifierObject"} @@ -653,7 +658,9 @@ def get_request_body(self, path, method): def map_serializer(self, serializer): """ Custom map_serializer that serializes the schema using the JSON:API spec. - Non-attributes like related and identity fields, are move to 'relationships' and 'links'. + + Non-attributes like related and identity fields, are moved to 'relationships' + and 'links'. """ # TODO: remove attributes, etc. for relationshipView?? required = [] @@ -830,7 +837,7 @@ def _add_delete_responses(self, operation): } self._add_async_response(operation) operation["responses"]["204"] = { - "description": "[no content](https://jsonapi.org/format/#crud-deleting-responses-204)", + "description": "[no content](https://jsonapi.org/format/#crud-deleting-responses-204)", # noqa: B950 } # the 4xx errors: self._add_generic_failure_responses(operation) diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index bfdfec71..91f86464 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -98,7 +98,7 @@ def __init__(self, *args, **kwargs): # iterate over a *copy* of self.fields' underlying OrderedDict, because we may # modify the original during the iteration. # self.fields is a `rest_framework.utils.serializer_helpers.BindingDict` - for field_name, field in self.fields.fields.copy().items(): + for field_name, _field in self.fields.fields.copy().items(): if ( field_name == api_settings.URL_FIELD_NAME ): # leave self link there @@ -208,17 +208,13 @@ def __new__(cls, name, bases, attrs): serializer = super().__new__(cls, name, bases, attrs) if attrs.get("included_serializers", None): - setattr( - serializer, - "included_serializers", - LazySerializersDict(serializer, attrs["included_serializers"]), + serializer.included_serializers = LazySerializersDict( + serializer, attrs["included_serializers"] ) if attrs.get("related_serializers", None): - setattr( - serializer, - "related_serializers", - LazySerializersDict(serializer, attrs["related_serializers"]), + serializer.related_serializers = LazySerializersDict( + serializer, attrs["related_serializers"] ) return serializer @@ -334,10 +330,9 @@ def __new__(cls, name, bases, attrs): return new_class polymorphic_serializers = getattr(new_class, "polymorphic_serializers", None) - if not polymorphic_serializers: - raise NotImplementedError( - "A PolymorphicModelSerializer must define a `polymorphic_serializers` attribute." - ) + assert ( + polymorphic_serializers is not None + ), "A PolymorphicModelSerializer must define a `polymorphic_serializers` attribute." serializer_to_model = { serializer: serializer.Meta.model for serializer in polymorphic_serializers } diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 2e86e762..c9114128 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -48,7 +48,7 @@ def get_resource_name(context, expand_polymorphic_types=False): return "errors" try: - resource_name = getattr(view, "resource_name") + resource_name = view.resource_name except AttributeError: try: if "kwargs" in context and "related_field" in context["kwargs"]: @@ -80,10 +80,10 @@ def get_resource_name(context, expand_polymorphic_types=False): def get_serializer_fields(serializer): fields = None if hasattr(serializer, "child"): - fields = getattr(serializer.child, "fields") + fields = serializer.child.fields meta = getattr(serializer.child, "Meta", None) if hasattr(serializer, "fields"): - fields = getattr(serializer, "fields") + fields = serializer.fields meta = getattr(serializer, "Meta", None) if fields is not None: @@ -159,8 +159,8 @@ def format_link_segment(value): def undo_format_link_segment(value): """ - Takes a link segment and undos format link segment to underscore which is the Python convention - but only in case `JSON_API_FORMAT_RELATED_LINKS` is actually configured. + Takes a link segment and undos format link segment to underscore which is the Python + convention but only in case `JSON_API_FORMAT_RELATED_LINKS` is actually configured. """ if json_api_settings.FORMAT_RELATED_LINKS: @@ -331,7 +331,7 @@ def get_relation_instance(resource_instance, source, serializer): # if the field is not defined on the model then we check the serializer # and if no value is there we skip over the field completely serializer_method = getattr(serializer, source, None) - if serializer_method and hasattr(serializer_method, "__call__"): + if serializer_method and callable(serializer_method): relation_instance = serializer_method(resource_instance) else: return False, None @@ -356,8 +356,8 @@ class Hyperlink(str): https://github.com/tomchristie/django-rest-framework """ - def __new__(self, url, name): - ret = str.__new__(self, url) + def __new__(cls, url, name): + ret = str.__new__(cls, url) ret.name = name return ret diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index c53864a0..de7e8aa6 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -129,7 +129,10 @@ def get_queryset(self, *args, **kwargs): class RelatedMixin: """ - This mixin handles all related entities, whose Serializers are declared in "related_serializers" + Mixing handling related links. + + This mixin handles all related entities, whose Serializers are declared + in "related_serializers". """ def retrieve_related(self, request, *args, **kwargs): @@ -162,6 +165,10 @@ def get_related_serializer_class(self): if "related_field" in self.kwargs: field_name = self.get_related_field_name() + assert hasattr(parent_serializer_class, "included_serializers") or hasattr( + parent_serializer_class, "related_serializers" + ), 'Either "included_serializers" or "related_serializers" should be configured' + # Try get the class from related_serializers if hasattr(parent_serializer_class, "related_serializers"): _class = parent_serializer_class.related_serializers.get( @@ -177,11 +184,6 @@ def get_related_serializer_class(self): if _class is None: raise NotFound - else: - assert ( - False - ), 'Either "included_serializers" or "related_serializers" should be configured' - return _class return parent_serializer_class diff --git a/setup.cfg b/setup.cfg index 2eb90b9b..c9d88f15 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,8 +9,13 @@ max-line-length = 88 extend-ignore = # whitespace before ':' - disabled as not PEP8 compliant E203, - # line too long (managed by black) + # line too long, as using bugbear E501 +extend-select = + # Line too long. This is a pragmatic equivalent of pycodestyle's E501 + B950, + # Invalid first argument used for method. + B902 exclude = build/lib, .eggs From db8b0ff66e4bcfbb755535268662300609b0568d Mon Sep 17 00:00:00 2001 From: Michael K Date: Thu, 10 Nov 2022 05:52:30 +0000 Subject: [PATCH 148/252] Use syntax highlighting in readme (#1102) --- README.rst | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/README.rst b/README.rst index 0e11f8b1..acc50976 100644 --- a/README.rst +++ b/README.rst @@ -24,7 +24,9 @@ Overview * Format specification: https://jsonapi.org/format/ -By default, Django REST framework will produce a response like:: +By default, Django REST framework will produce a response like: + +.. code:: JSON { "count": 20, @@ -39,7 +41,9 @@ By default, Django REST framework will produce a response like:: However, for an ``identity`` model in JSON:API format the response should look -like the following:: +like the following: + +.. code:: JSON { "links": { @@ -104,7 +108,7 @@ Installation Install using ``pip``... -:: +.. code:: sh $ pip install djangorestframework-jsonapi $ # for optional package integrations @@ -115,7 +119,7 @@ Install using ``pip``... or from source... -:: +.. code:: sh $ git clone https://github.com/django-json-api/django-rest-framework-json-api.git $ cd django-rest-framework-json-api @@ -124,7 +128,7 @@ or from source... and add ``rest_framework_json_api`` to your ``INSTALLED_APPS`` setting below ``rest_framework``. -:: +.. code:: python INSTALLED_APPS = [ ... @@ -140,7 +144,7 @@ Running the example app It is recommended to create a virtualenv for testing. Assuming it is already installed and activated: -:: +.. code:: sh $ git clone https://github.com/django-json-api/django-rest-framework-json-api.git $ cd django-rest-framework-json-api @@ -172,7 +176,7 @@ One can either add ``rest_framework_json_api.parsers.JSONParser`` and ``rest_framework_json_api.renderers.JSONRenderer`` to each ``ViewSet`` class, or override ``settings.REST_FRAMEWORK`` -:: +.. code:: python REST_FRAMEWORK = { 'PAGE_SIZE': 10, From 531e6a2b919fa7775b1d509341d0f340c5afbf34 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 14 Nov 2022 20:38:11 +0400 Subject: [PATCH 149/252] Repaired example app (#1105) --- example/fixtures/drf_example.json | 6 ++++-- example/settings/dev.py | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/example/fixtures/drf_example.json b/example/fixtures/drf_example.json index 498c0d1c..944f502c 100644 --- a/example/fixtures/drf_example.json +++ b/example/fixtures/drf_example.json @@ -26,8 +26,9 @@ "created_at": "2016-05-02T10:09:48.277", "modified_at": "2016-05-02T10:09:48.277", "name": "Alice", + "full_name": "Alice Test", "email": "alice@example.com", - "type": null + "author_type": null } }, { @@ -37,8 +38,9 @@ "created_at": "2016-05-02T10:09:57.133", "modified_at": "2016-05-02T10:09:57.133", "name": "Bob", + "full_name": "Bob Test", "email": "bob@example.com", - "type": null + "author_type": null } }, { diff --git a/example/settings/dev.py b/example/settings/dev.py index 8e13ec15..c5405338 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -6,6 +6,7 @@ MEDIA_ROOT = os.path.normcase(os.path.dirname(os.path.abspath(__file__))) MEDIA_URL = "/media/" USE_TZ = False +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" DATABASE_ENGINE = "sqlite3" From adfffeedc95990269b614bc9121b1b54b8095be0 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 16 Nov 2022 16:12:43 +0400 Subject: [PATCH 150/252] Adjusted security policy (#1107) --- SECURITY.md | 4 ++-- docs/CONTRIBUTING.md | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index e524f6a5..5cb25b29 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,6 +4,6 @@ If you believe you've found something in Django REST framework JSON:API which has security implications, please **do not raise the issue in a public forum**. -Send a description of the issue via email to [rest-framework-jsonapi-security@googlegroups.com][security-mail]. The project maintainers will then work with you to resolve any issues where required, prior to any public disclosure. +Use the security advisory to [report a vulnerability](https://github.com/django-json-api/django-rest-framework-json-api/security/advisories/new) instead. -[security-mail]: mailto:rest-framework-jsonapi-security@googlegroups.com +The project maintainers will then work with you to resolve any issues where required, prior to any public disclosure. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 2aa87cfe..0715c364 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -69,4 +69,3 @@ In case a new maintainer joins our team we need to consider to what of following * [Github organization](https://github.com/django-json-api) * [Read the Docs project](https://django-rest-framework-json-api.readthedocs.io/) * [PyPi project](https://pypi.org/project/djangorestframework-jsonapi/) -* [Google Groups security mailing list](https://groups.google.com/g/rest-framework-jsonapi-security) From ee7b7de4fb0be1694749fa5359a2f403f5a1ad90 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 30 Nov 2022 21:57:40 +0400 Subject: [PATCH 151/252] Moved pagination and settings tests to tests module (#1110) Those were already in pytest style and only needed some simplifications. --- example/tests/unit/test_pagination.py | 92 ------------------- tests/test_pagination.py | 55 +++++++++++ .../tests/unit => tests}/test_settings.py | 0 3 files changed, 55 insertions(+), 92 deletions(-) delete mode 100644 example/tests/unit/test_pagination.py create mode 100644 tests/test_pagination.py rename {example/tests/unit => tests}/test_settings.py (100%) diff --git a/example/tests/unit/test_pagination.py b/example/tests/unit/test_pagination.py deleted file mode 100644 index 83570e7c..00000000 --- a/example/tests/unit/test_pagination.py +++ /dev/null @@ -1,92 +0,0 @@ -from collections import OrderedDict - -from rest_framework.request import Request -from rest_framework.test import APIRequestFactory -from rest_framework.utils.urls import replace_query_param - -from rest_framework_json_api import pagination - -factory = APIRequestFactory() - - -class TestLimitOffset: - """ - Unit tests for `pagination.JsonApiLimitOffsetPagination`. - """ - - def setup_method(self): - class ExamplePagination(pagination.JsonApiLimitOffsetPagination): - default_limit = 10 - max_limit = 15 - - self.pagination = ExamplePagination() - self.queryset = range(1, 101) - self.base_url = "http://testserver/" - - def paginate_queryset(self, request): - return list(self.pagination.paginate_queryset(self.queryset, request)) - - def get_paginated_content(self, queryset): - response = self.pagination.get_paginated_response(queryset) - return response.data - - def get_test_request(self, arguments): - return Request(factory.get("/", arguments)) - - def test_valid_offset_limit(self): - """ - Basic test, assumes offset and limit are given. - """ - offset = 10 - limit = 5 - count = len(self.queryset) - last_offset = (count // limit) * limit - next_offset = 15 - prev_offset = 5 - - request = self.get_test_request( - { - self.pagination.limit_query_param: limit, - self.pagination.offset_query_param: offset, - } - ) - base_url = replace_query_param( - self.base_url, self.pagination.limit_query_param, limit - ) - last_url = replace_query_param( - base_url, self.pagination.offset_query_param, last_offset - ) - first_url = base_url - next_url = replace_query_param( - base_url, self.pagination.offset_query_param, next_offset - ) - prev_url = replace_query_param( - base_url, self.pagination.offset_query_param, prev_offset - ) - queryset = self.paginate_queryset(request) - content = self.get_paginated_content(queryset) - next_offset = offset + limit - - expected_content = { - "results": list(range(offset + 1, next_offset + 1)), - "links": OrderedDict( - [ - ("first", first_url), - ("last", last_url), - ("next", next_url), - ("prev", prev_url), - ] - ), - "meta": { - "pagination": OrderedDict( - [ - ("count", count), - ("limit", limit), - ("offset", offset), - ] - ) - }, - } - - assert queryset == list(range(offset + 1, next_offset + 1)) - assert content == expected_content diff --git a/tests/test_pagination.py b/tests/test_pagination.py new file mode 100644 index 00000000..c09ac71c --- /dev/null +++ b/tests/test_pagination.py @@ -0,0 +1,55 @@ +from collections import OrderedDict + +from rest_framework.request import Request + +from rest_framework_json_api.pagination import JsonApiLimitOffsetPagination + + +class TestLimitOffsetPagination: + def test_get_paginated_response(self, rf): + pagination = JsonApiLimitOffsetPagination() + queryset = range(1, 101) + offset = 10 + limit = 5 + count = len(queryset) + + request = Request( + rf.get( + "/", + { + pagination.limit_query_param: limit, + pagination.offset_query_param: offset, + }, + ) + ) + queryset = list(pagination.paginate_queryset(queryset, request)) + content = pagination.get_paginated_response(queryset).data + + expected_content = { + "results": list(range(11, 16)), + "links": OrderedDict( + [ + ("first", "http://testserver/?page%5Blimit%5D=5"), + ( + "last", + "http://testserver/?page%5Blimit%5D=5&page%5Boffset%5D=100", + ), + ( + "next", + "http://testserver/?page%5Blimit%5D=5&page%5Boffset%5D=15", + ), + ("prev", "http://testserver/?page%5Blimit%5D=5&page%5Boffset%5D=5"), + ] + ), + "meta": { + "pagination": OrderedDict( + [ + ("count", count), + ("limit", limit), + ("offset", offset), + ] + ) + }, + } + + assert content == expected_content diff --git a/example/tests/unit/test_settings.py b/tests/test_settings.py similarity index 100% rename from example/tests/unit/test_settings.py rename to tests/test_settings.py From 3d4a926a566b61e4926267ad791f45a232336da8 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 8 Dec 2022 16:26:41 +0400 Subject: [PATCH 152/252] Updated to CodeQL v2 (#1112) --- .github/workflows/codeql-analysis.yml | 45 ++++++++++++++++----------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 9b429fa7..8faf1a2f 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -13,55 +13,62 @@ name: "CodeQL" on: push: - branches: [ main ] + branches: [ "main" ] pull_request: # The branches below must be a subset of the branches above - branches: [ main ] + branches: [ "main" ] schedule: - - cron: '33 5 * * 6' + - cron: '39 4 * * 2' jobs: analyze: name: Analyze runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write strategy: fail-fast: false matrix: language: [ 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] - # Learn more: - # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v1 + uses: github/codeql-action/autobuild@v2 # ℹ️ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - #- run: | - # make bootstrap - # make release + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" From caccd48914cad676cbcc9a8be1cec73ba576f15a Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 12 Dec 2022 22:28:56 +0400 Subject: [PATCH 153/252] Unified tox configuration (#1113) * Unified tox configuration * Followed example of DRF using -f cmd line of tox v4 instead of tox-py * For linting jobs updated to Python 3.8 as will be required by future updates of Flake8 * Added missing black job * Ignored only testenvs which are actually defined --- .github/workflows/tests.yml | 8 ++++---- tox.ini | 18 +++++++++++++----- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9b277e3f..75573cb7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,9 +20,9 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install tox tox-py + pip install tox - name: Run tox targets for ${{ matrix.python-version }} - run: tox --py current + run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d .) - name: Upload coverage report uses: codecov/codecov-action@v2 with: @@ -36,10 +36,10 @@ jobs: tox-env: ["black", "lint", "docs"] steps: - uses: actions/checkout@v2 - - name: Set up Python 3.7 + - name: Set up Python 3.8 uses: actions/setup-python@v2 with: - python-version: 3.7 + python-version: 3.8 - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/tox.ini b/tox.ini index 563b08ef..c9fd139b 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,9 @@ envlist = py{37,38,39,310}-django32-drf{313,314,master}, py{38,39,310}-django40-drf{313,314,master}, py{38,39,310,311}-django41-drf{314,master}, - lint,docs + black, + docs, + lint [testenv] deps = @@ -24,13 +26,13 @@ commands = pytest --cov --no-cov-on-fail --cov-report xml {posargs} [testenv:black] -basepython = python3.7 +basepython = python3.8 deps = -rrequirements/requirements-codestyle.txt commands = black --check . [testenv:lint] -basepython = python3.7 +basepython = python3.8 deps = -rrequirements/requirements-codestyle.txt -rrequirements/requirements-testing.txt @@ -38,7 +40,7 @@ deps = commands = flake8 [testenv:docs] -basepython = python3.7 +basepython = python3.8 deps = -rrequirements/requirements-testing.txt -rrequirements/requirements-optionals.txt @@ -46,5 +48,11 @@ deps = commands = sphinx-build -W -b html -d docs/_build/doctrees docs docs/_build/html -[testenv:py{37,38,39,310}-django{32,40,41}-drfmaster] +[testenv:py{37,38,39,310}-django32-drfmaster] +ignore_outcome = true + +[testenv:py{38,39,310}-django40-drfmaster] +ignore_outcome = true + +[testenv:py{38,39,310,311}-django41-drfmaster] ignore_outcome = true From 5f48b708bbe72dc8aaf684ab45bb43e39777d70b Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 12 Dec 2022 22:41:01 +0400 Subject: [PATCH 154/252] Removed obsolete tests (#1114) * Format keys/field names is extensively tested in test_utils * Testing factories simply tests functionality of another library but nothing DJA specific Co-authored-by: Alan Crosswell --- example/tests/test_format_keys.py | 69 ---------------------------- example/tests/unit/test_factories.py | 47 ------------------- 2 files changed, 116 deletions(-) delete mode 100644 example/tests/test_format_keys.py delete mode 100644 example/tests/unit/test_factories.py diff --git a/example/tests/test_format_keys.py b/example/tests/test_format_keys.py deleted file mode 100644 index d0e36d81..00000000 --- a/example/tests/test_format_keys.py +++ /dev/null @@ -1,69 +0,0 @@ -from django.contrib.auth import get_user_model -from django.urls import reverse -from django.utils import encoding -from rest_framework import status - -from example.tests import TestBase - - -class FormatKeysSetTests(TestBase): - """ - Test that camelization and underscoring of key names works if they are activated. - """ - - list_url = reverse("user-list") - - def setUp(self): - super().setUp() - self.detail_url = reverse("user-detail", kwargs={"pk": self.miles.pk}) - - def test_camelization(self): - """ - Test that camelization works. - """ - response = self.client.get(self.list_url) - self.assertEqual(response.status_code, 200) - - user = get_user_model().objects.all()[0] - expected = { - "data": [ - { - "type": "users", - "id": encoding.force_str(user.pk), - "attributes": { - "firstName": user.first_name, - "lastName": user.last_name, - "email": user.email, - }, - } - ], - "links": { - "first": "http://testserver/identities?page%5Bnumber%5D=1", - "last": "http://testserver/identities?page%5Bnumber%5D=2", - "next": "http://testserver/identities?page%5Bnumber%5D=2", - "prev": None, - }, - "meta": {"pagination": {"page": 1, "pages": 2, "count": 2}}, - } - - assert expected == response.json() - - -def test_options_format_field_names(db, client): - response = client.options(reverse("author-list")) - assert response.status_code == status.HTTP_200_OK - data = response.json()["data"] - expected_keys = { - "name", - "fullName", - "email", - "bio", - "entries", - "firstEntry", - "authorType", - "comments", - "secrets", - "defaults", - "initials", - } - assert expected_keys == data["actions"]["POST"].keys() diff --git a/example/tests/unit/test_factories.py b/example/tests/unit/test_factories.py deleted file mode 100644 index ac9d7b2a..00000000 --- a/example/tests/unit/test_factories.py +++ /dev/null @@ -1,47 +0,0 @@ -import pytest - -from example.factories import BlogFactory -from example.models import Blog - -pytestmark = pytest.mark.django_db - - -def test_factory_instance(blog_factory): - - assert blog_factory == BlogFactory - - -def test_model_instance(blog): - - assert isinstance(blog, Blog) - - -def test_multiple_blog(blog_factory): - another_blog = blog_factory(name="Cool Blog") - new_blog = blog_factory(name="Awesome Blog") - - assert another_blog.name == "Cool Blog" - assert new_blog.name == "Awesome Blog" - - -def test_factories_with_relations(author_factory, entry_factory): - - author = author_factory(name="Joel Spolsky") - entry = entry_factory( - headline=( - "The Absolute Minimum Every Software Developer" - "Absolutely, Positively Must Know About Unicode " - "and Character Sets (No Excuses!)" - ), - blog__name="Joel on Software", - authors=(author,), - ) - - assert entry.blog.name == "Joel on Software" - assert entry.headline == ( - "The Absolute Minimum Every Software Developer" - "Absolutely, Positively Must Know About Unicode " - "and Character Sets (No Excuses!)" - ) - assert entry.authors.all().count() == 1 - assert entry.authors.all()[0].name == "Joel Spolsky" From 4dc1c9779c6d5c9dd2a54c46dfb0b6b2e619c4a6 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 19 Dec 2022 12:28:28 -0500 Subject: [PATCH 155/252] Scheduled biweekly dependency update for week 51 (#1115) * Update black from 22.10.0 to 22.12.0 * Update flake8 from 5.0.4 to 6.0.0 * Update flake8-bugbear from 22.10.27 to 22.12.6 * Update flake8-isort from 5.0.0 to 5.0.3 * Update isort from 5.10.1 to 5.11.3 * Update twine from 4.0.1 to 4.0.2 * Update faker from 15.2.0 to 15.3.4 * Update pytest-factoryboy from 2.5.0 to 2.5.1 * Update syrupy from 3.0.4 to 3.0.5 --- requirements/requirements-codestyle.txt | 10 +++++----- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index f60aab51..93eb7b6f 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ -black==22.10.0 -flake8==5.0.4 -flake8-bugbear==22.10.27 -flake8-isort==5.0.0 -isort==5.10.1 +black==22.12.0 +flake8==6.0.0 +flake8-bugbear==22.12.6 +flake8-isort==5.0.3 +isort==5.11.3 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index d76bde9d..eb2532c4 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==4.0.1 +twine==4.0.2 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 921fc035..cf10eb2c 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.2.1 -Faker==15.2.0 +Faker==15.3.4 pytest==7.2.0 pytest-cov==4.0.0 pytest-django==4.5.2 -pytest-factoryboy==2.5.0 -syrupy==3.0.4 +pytest-factoryboy==2.5.1 +syrupy==3.0.5 From 72a062c89e797e1452557f0e75c3ac1c0ba9497b Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 20 Dec 2022 00:19:25 +0400 Subject: [PATCH 156/252] Removed unnecessary test dependencies when building docs (#1116) Removed unnecessary test dependencies when building docs --- tox.ini | 1 - 1 file changed, 1 deletion(-) diff --git a/tox.ini b/tox.ini index c9fd139b..236f5517 100644 --- a/tox.ini +++ b/tox.ini @@ -42,7 +42,6 @@ commands = flake8 [testenv:docs] basepython = python3.8 deps = - -rrequirements/requirements-testing.txt -rrequirements/requirements-optionals.txt -rrequirements/requirements-documentation.txt commands = From 085554e28c663d9a336bf7174c58d81925d9d650 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 20 Dec 2022 00:27:58 +0400 Subject: [PATCH 157/252] Set daily schedule for running test jobs (#1117) This way we will quickly notice when a point release in Python, DRF or Django makes DJA fail. Co-authored-by: Alan Crosswell --- .github/workflows/tests.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 75573cb7..a22301fe 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,5 +1,11 @@ name: Tests -on: [push, pull_request] +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + schedule: + - cron: '0 4 * * *' jobs: test: From a4b1b25bffd7a35b6609ff0b6f77c3a4b8339fd1 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 27 Jan 2023 14:05:29 -0500 Subject: [PATCH 158/252] Scheduled biweekly dependency update for week 03 (#1119) * Update flake8-bugbear from 22.12.6 to 23.1.14 * Update flake8-isort from 5.0.3 to 6.0.0 * Update isort from 5.11.3 to 5.11.4 * Update faker from 15.3.4 to 16.4.0 * Update pytest from 7.2.0 to 7.2.1 * Update syrupy from 3.0.5 to 3.0.6 * Addressed linting issue B028 by using !r format string --------- Co-authored-by: Oliver Sauder --- requirements/requirements-codestyle.txt | 6 +++--- requirements/requirements-testing.txt | 6 +++--- rest_framework_json_api/utils.py | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 93eb7b6f..b698dde6 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ black==22.12.0 flake8==6.0.0 -flake8-bugbear==22.12.6 -flake8-isort==5.0.3 -isort==5.11.3 +flake8-bugbear==23.1.14 +flake8-isort==6.0.0 +isort==5.11.4 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index cf10eb2c..d3b097fd 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.2.1 -Faker==15.3.4 -pytest==7.2.0 +Faker==16.4.0 +pytest==7.2.1 pytest-cov==4.0.0 pytest-django==4.5.2 pytest-factoryboy==2.5.1 -syrupy==3.0.5 +syrupy==3.0.6 diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index c9114128..130894aa 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -303,8 +303,8 @@ def get_resource_type_from_serializer(serializer): elif hasattr(meta, "model"): return get_resource_type_from_model(meta.model) raise AttributeError( - f"can not detect 'resource_name' on serializer '{serializer.__class__.__name__}'" - f" in module '{serializer.__class__.__module__}'" + f"can not detect 'resource_name' on serializer {serializer.__class__.__name__!r}" + f" in module {serializer.__class__.__module__!r}" ) From 735ce291d7385fea39b5a50c107449c2d8fdd3d9 Mon Sep 17 00:00:00 2001 From: kal Date: Wed, 1 Feb 2023 07:24:34 -0500 Subject: [PATCH 159/252] Avoided duplicated sort query parameter in schema generation (#1124) --- AUTHORS | 1 + CHANGELOG.md | 4 ++++ example/tests/__snapshots__/test_openapi.ambr | 10 ++-------- example/tests/unit/test_filter_schema_params.py | 3 ++- rest_framework_json_api/filters.py | 3 +++ rest_framework_json_api/schemas/openapi.py | 16 ---------------- 6 files changed, 12 insertions(+), 25 deletions(-) diff --git a/AUTHORS b/AUTHORS index 15a5393f..e27ba5f5 100644 --- a/AUTHORS +++ b/AUTHORS @@ -21,6 +21,7 @@ Jonas Kiefer Jonas Metzener Jonathan Senecal Joseba Mendivil +Kal Kevin Partington Kieran Evans Léo S. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5919fae3..a509b262 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ any parts of the framework not mentioned in the documentation should generally b * Added support to overwrite serializer methods in customized schema class +### Fixed + +* Refactored handling of the `sort` query parameter to fix duplicate declaration in the generated schema definition + ## [6.0.0] - 2022-09-24 ### Fixed diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr index a0231471..713387d3 100644 --- a/example/tests/__snapshots__/test_openapi.ambr +++ b/example/tests/__snapshots__/test_openapi.ambr @@ -273,10 +273,7 @@ "$ref": "#/components/parameters/fields" }, { - "$ref": "#/components/parameters/sort" - }, - { - "description": "Which field to use when ordering the results.", + "description": "[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)", "in": "query", "name": "sort", "required": false, @@ -391,9 +388,6 @@ { "$ref": "#/components/parameters/fields" }, - { - "$ref": "#/components/parameters/sort" - }, { "description": "A page number within the paginated result set.", "in": "query", @@ -413,7 +407,7 @@ } }, { - "description": "Which field to use when ordering the results.", + "description": "[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)", "in": "query", "name": "sort", "required": false, diff --git a/example/tests/unit/test_filter_schema_params.py b/example/tests/unit/test_filter_schema_params.py index f50304ac..d7cb4fb8 100644 --- a/example/tests/unit/test_filter_schema_params.py +++ b/example/tests/unit/test_filter_schema_params.py @@ -72,7 +72,8 @@ def test_filters_get_schema_params(): "name": "sort", "required": False, "in": "query", - "description": "Which field to use when ordering the results.", + "description": "[list of fields to sort by]" + "(https://jsonapi.org/format/#fetching-sorting)", "schema": {"type": "string"}, } ], diff --git a/rest_framework_json_api/filters.py b/rest_framework_json_api/filters.py index 33ce240f..ead6f84b 100644 --- a/rest_framework_json_api/filters.py +++ b/rest_framework_json_api/filters.py @@ -22,6 +22,9 @@ class OrderingFilter(OrderingFilter): #: override :py:attr:`rest_framework.filters.OrderingFilter.ordering_param` #: with JSON:API-compliant query parameter name. ordering_param = "sort" + ordering_description = ( + "[list of fields to sort by]" "(https://jsonapi.org/format/#fetching-sorting)" + ) def remove_invalid_fields(self, queryset, fields, view, request): """ diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 5ddc8143..99ec8ff1 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -248,15 +248,6 @@ class SchemaGenerator(drf_openapi.SchemaGenerator): }, "explode": True, }, - "sort": { - "name": "sort", - "in": "query", - "description": "[list of fields to sort by]" - "(https://jsonapi.org/format/#fetching-sorting)", - "required": False, - "style": "form", - "schema": {"type": "string"}, - }, }, } @@ -422,7 +413,6 @@ def get_operation(self, path, method): if method in ["GET", "HEAD"]: parameters += self._get_include_parameters(path, method) parameters += self._get_fields_parameters(path, method) - parameters += self._get_sort_parameters(path, method) parameters += self.get_pagination_parameters(path, method) parameters += self.get_filter_parameters(path, method) operation["parameters"] = parameters @@ -485,12 +475,6 @@ def _get_fields_parameters(self, path, method): # explode: true return [{"$ref": "#/components/parameters/fields"}] - def _get_sort_parameters(self, path, method): - """ - sort parameter: https://jsonapi.org/format/#fetching-sorting - """ - return [{"$ref": "#/components/parameters/sort"}] - def _add_get_collection_response(self, operation, path): """ Add GET 200 response for a collection to operation From ee2b8f2f0464463fb3e13a95a22e71962eee9ec4 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 21 Feb 2023 01:21:22 -0500 Subject: [PATCH 160/252] Scheduled biweekly dependency update for week 08 (#1129) * Update black from 22.12.0 to 23.1.0 * Update flake8-bugbear from 23.1.14 to 23.2.13 * Update isort from 5.11.4 to 5.12.0 * Update sphinx from 5.3.0 to 6.1.3 * Update sphinx_rtd_theme from 1.1.1 to 1.2.0 * Update faker from 16.4.0 to 17.0.0 * Reformatted with black 2023 style * Set warning stacklevel so it is clear This is a simply warning with a key where not stacktrace is needed. --------- Co-authored-by: Oliver Sauder --- example/migrations/0001_initial.py | 1 - example/migrations/0002_taggeditem.py | 1 - example/migrations/0003_polymorphics.py | 1 - example/migrations/0004_auto_20171011_0631.py | 1 - example/migrations/0005_auto_20180922_1508.py | 1 - example/migrations/0006_auto_20181228_0752.py | 1 - example/migrations/0007_artproject_description.py | 1 - example/migrations/0008_labresults.py | 1 - example/migrations/0009_labresults_author.py | 1 - example/migrations/0010_auto_20210714_0809.py | 1 - .../0011_rename_type_author_author_type_and_more.py | 1 - example/migrations/0012_author_full_name.py | 1 - example/serializers.py | 1 - example/tests/conftest.py | 2 -- example/tests/integration/test_meta.py | 2 -- example/tests/integration/test_model_resource_name.py | 2 -- example/tests/integration/test_non_paginated_responses.py | 1 - example/tests/integration/test_pagination.py | 1 - example/tests/unit/test_default_drf_serializers.py | 4 ---- requirements/requirements-codestyle.txt | 6 +++--- requirements/requirements-documentation.txt | 4 ++-- requirements/requirements-testing.txt | 2 +- rest_framework_json_api/renderers.py | 2 -- rest_framework_json_api/schemas/openapi.py | 5 ++--- rest_framework_json_api/utils.py | 1 - rest_framework_json_api/views.py | 2 -- 26 files changed, 8 insertions(+), 39 deletions(-) diff --git a/example/migrations/0001_initial.py b/example/migrations/0001_initial.py index 0161cd49..18805099 100644 --- a/example/migrations/0001_initial.py +++ b/example/migrations/0001_initial.py @@ -3,7 +3,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [] diff --git a/example/migrations/0002_taggeditem.py b/example/migrations/0002_taggeditem.py index 0abf49e0..62b1cf81 100644 --- a/example/migrations/0002_taggeditem.py +++ b/example/migrations/0002_taggeditem.py @@ -3,7 +3,6 @@ class Migration(migrations.Migration): - dependencies = [ ("contenttypes", "0002_remove_content_type_name"), ("example", "0001_initial"), diff --git a/example/migrations/0003_polymorphics.py b/example/migrations/0003_polymorphics.py index 1bae8491..70c62ddd 100644 --- a/example/migrations/0003_polymorphics.py +++ b/example/migrations/0003_polymorphics.py @@ -3,7 +3,6 @@ class Migration(migrations.Migration): - dependencies = [ ("contenttypes", "0002_remove_content_type_name"), ("example", "0002_taggeditem"), diff --git a/example/migrations/0004_auto_20171011_0631.py b/example/migrations/0004_auto_20171011_0631.py index fa597a29..51db8e3f 100644 --- a/example/migrations/0004_auto_20171011_0631.py +++ b/example/migrations/0004_auto_20171011_0631.py @@ -3,7 +3,6 @@ class Migration(migrations.Migration): - dependencies = [ ("example", "0003_polymorphics"), ] diff --git a/example/migrations/0005_auto_20180922_1508.py b/example/migrations/0005_auto_20180922_1508.py index 58b2808d..52c3a0ac 100644 --- a/example/migrations/0005_auto_20180922_1508.py +++ b/example/migrations/0005_auto_20180922_1508.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ ("example", "0004_auto_20171011_0631"), ] diff --git a/example/migrations/0006_auto_20181228_0752.py b/example/migrations/0006_auto_20181228_0752.py index 4126c797..3ef2678e 100644 --- a/example/migrations/0006_auto_20181228_0752.py +++ b/example/migrations/0006_auto_20181228_0752.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ ("example", "0005_auto_20180922_1508"), ] diff --git a/example/migrations/0007_artproject_description.py b/example/migrations/0007_artproject_description.py index 20f9d42e..4ed2d7a9 100644 --- a/example/migrations/0007_artproject_description.py +++ b/example/migrations/0007_artproject_description.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ("example", "0006_auto_20181228_0752"), ] diff --git a/example/migrations/0008_labresults.py b/example/migrations/0008_labresults.py index e0a1d6ba..290c2cb8 100644 --- a/example/migrations/0008_labresults.py +++ b/example/migrations/0008_labresults.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ ("example", "0007_artproject_description"), ] diff --git a/example/migrations/0009_labresults_author.py b/example/migrations/0009_labresults_author.py index 6365d01c..6c805ab0 100644 --- a/example/migrations/0009_labresults_author.py +++ b/example/migrations/0009_labresults_author.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ ("example", "0008_labresults"), ] diff --git a/example/migrations/0010_auto_20210714_0809.py b/example/migrations/0010_auto_20210714_0809.py index de36ba20..cd96cccc 100644 --- a/example/migrations/0010_auto_20210714_0809.py +++ b/example/migrations/0010_auto_20210714_0809.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ("example", "0009_labresults_author"), ] diff --git a/example/migrations/0011_rename_type_author_author_type_and_more.py b/example/migrations/0011_rename_type_author_author_type_and_more.py index 191e901c..cc2f5546 100644 --- a/example/migrations/0011_rename_type_author_author_type_and_more.py +++ b/example/migrations/0011_rename_type_author_author_type_and_more.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ ("contenttypes", "0002_remove_content_type_name"), ("example", "0010_auto_20210714_0809"), diff --git a/example/migrations/0012_author_full_name.py b/example/migrations/0012_author_full_name.py index 1f67558e..0486d041 100644 --- a/example/migrations/0012_author_full_name.py +++ b/example/migrations/0012_author_full_name.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ("example", "0011_rename_type_author_author_type_and_more"), ] diff --git a/example/serializers.py b/example/serializers.py index 75a1de11..3d94e6cc 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -181,7 +181,6 @@ class JSONAPIMeta: class EntryDRFSerializers(drf_serilazers.ModelSerializer): - tags = TaggedItemDRFSerializer(many=True, read_only=True) url = drf_serilazers.HyperlinkedIdentityField( view_name="drf-entry-blog-detail", diff --git a/example/tests/conftest.py b/example/tests/conftest.py index df5bbdfc..22ab6bd1 100644 --- a/example/tests/conftest.py +++ b/example/tests/conftest.py @@ -31,7 +31,6 @@ @pytest.fixture def single_entry(blog, author, entry_factory, comment_factory, tagged_item_factory): - entry = entry_factory(blog=blog, authors=(author,)) comment_factory(entry=entry) tagged_item_factory(content_object=entry) @@ -40,7 +39,6 @@ def single_entry(blog, author, entry_factory, comment_factory, tagged_item_facto @pytest.fixture def multiple_entries(blog_factory, author_factory, entry_factory, comment_factory): - entries = [ entry_factory(blog=blog_factory(), authors=(author_factory(),)), entry_factory(blog=blog_factory(), authors=(author_factory(),)), diff --git a/example/tests/integration/test_meta.py b/example/tests/integration/test_meta.py index f54cda8e..c63a6d9b 100644 --- a/example/tests/integration/test_meta.py +++ b/example/tests/integration/test_meta.py @@ -7,7 +7,6 @@ def test_top_level_meta_for_list_view(blog, client): - expected = { "data": [ { @@ -37,7 +36,6 @@ def test_top_level_meta_for_list_view(blog, client): def test_top_level_meta_for_detail_view(blog, client): - expected = { "data": { "type": "blogs", diff --git a/example/tests/integration/test_model_resource_name.py b/example/tests/integration/test_model_resource_name.py index 2dacd2d5..b2fda333 100644 --- a/example/tests/integration/test_model_resource_name.py +++ b/example/tests/integration/test_model_resource_name.py @@ -50,7 +50,6 @@ def _check_relationship_and_included_comment_type_are_the_same(django_client, ur @pytest.mark.usefixtures("single_entry") class TestModelResourceName: - create_data = { "data": { "type": "resource_name_from_JSONAPIMeta", @@ -147,7 +146,6 @@ def teardown_method(self, method): @pytest.mark.usefixtures("single_entry") class TestResourceNameConsistency: - # Included rename tests def test_type_match_on_included_and_inline_base(self, client): _check_relationship_and_included_comment_type_are_the_same( diff --git a/example/tests/integration/test_non_paginated_responses.py b/example/tests/integration/test_non_paginated_responses.py index 3483b7f4..60376a8b 100644 --- a/example/tests/integration/test_non_paginated_responses.py +++ b/example/tests/integration/test_non_paginated_responses.py @@ -11,7 +11,6 @@ new=lambda s: [], ) def test_multiple_entries_no_pagination(multiple_entries, client): - expected = { "data": [ { diff --git a/example/tests/integration/test_pagination.py b/example/tests/integration/test_pagination.py index c723fce1..4c60e96e 100644 --- a/example/tests/integration/test_pagination.py +++ b/example/tests/integration/test_pagination.py @@ -11,7 +11,6 @@ new=lambda s: [], ) def test_pagination_with_single_entry(single_entry, client): - expected = { "data": [ { diff --git a/example/tests/unit/test_default_drf_serializers.py b/example/tests/unit/test_default_drf_serializers.py index d74edac7..87231896 100644 --- a/example/tests/unit/test_default_drf_serializers.py +++ b/example/tests/unit/test_default_drf_serializers.py @@ -71,7 +71,6 @@ def test_render_format_field_names(db, settings, entry): @pytest.mark.django_db def test_blog_create(client): - url = reverse("drf-entry-blog-list") name = "Dummy Name" @@ -107,7 +106,6 @@ def test_blog_create(client): @pytest.mark.django_db def test_get_object_gives_correct_blog(client, blog, entry): - url = reverse("drf-entry-blog-detail", kwargs={"entry_pk": entry.id}) resp = client.get(url) expected = { @@ -126,7 +124,6 @@ def test_get_object_gives_correct_blog(client, blog, entry): @pytest.mark.django_db def test_get_object_patches_correct_blog(client, blog, entry): - url = reverse("drf-entry-blog-detail", kwargs={"entry_pk": entry.id}) new_name = blog.name + " update" assert not new_name == blog.name @@ -163,7 +160,6 @@ def test_get_object_patches_correct_blog(client, blog, entry): @pytest.mark.django_db def test_get_object_deletes_correct_blog(client, entry): - url = reverse("drf-entry-blog-detail", kwargs={"entry_pk": entry.id}) resp = client.delete(url) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index b698dde6..4092f80f 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ -black==22.12.0 +black==23.1.0 flake8==6.0.0 -flake8-bugbear==23.1.14 +flake8-bugbear==23.2.13 flake8-isort==6.0.0 -isort==5.11.4 +isort==5.12.0 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 95e25adc..36e96657 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==5.3.0 -sphinx_rtd_theme==1.1.1 +Sphinx==6.1.3 +sphinx_rtd_theme==1.2.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index d3b097fd..69371227 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,5 +1,5 @@ factory-boy==3.2.1 -Faker==16.4.0 +Faker==17.0.0 pytest==7.2.1 pytest-cov==4.0.0 pytest-django==4.5.2 diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index db297870..43fef0a3 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -499,7 +499,6 @@ def render_errors(self, data, accepted_media_type=None, renderer_context=None): ) def render(self, data, accepted_media_type=None, renderer_context=None): - renderer_context = renderer_context or {} view = renderer_context.get("view", None) @@ -545,7 +544,6 @@ def render(self, data, accepted_media_type=None, renderer_context=None): included_resources = utils.get_included_resources(request, serializer) if serializer is not None: - # Extract root meta for any type of serializer json_api_meta.update(self.extract_root_meta(serializer, serializer_data)) diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 99ec8ff1..7cb7d9ca 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -298,9 +298,8 @@ def get_schema(self, request=None, public=False): if components_schemas[k] == components[k]: continue warnings.warn( - 'Schema component "{}" has been overriden with a different value.'.format( - k - ) + f'Schema component "{k}" has been overriden with a different value.', + stacklevel=1, ) components_schemas.update(components) diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 130894aa..91b3aba9 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -415,7 +415,6 @@ def format_drf_errors(response, context, exc): def format_error_object(message, pointer, response): errors = [] if isinstance(message, dict): - # as there is no required field in error object we check that all fields are string # except links, source or meta which might be a dict is_custom_error = all( diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index de7e8aa6..0b3df693 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -66,7 +66,6 @@ def get_queryset(self, *args, **kwargs): self.request, self.get_serializer_class() ) for included in included_resources + ["__all__"]: - select_related = self.get_select_related(included) if select_related is not None: qs = qs.select_related(*select_related) @@ -110,7 +109,6 @@ def get_queryset(self, *args, **kwargs): if level == levels[-1]: included_model = field else: - if issubclass(field_class, ReverseOneToOneDescriptor): model_field = field.related.field else: From acffc186207449fb378a64277258d55f528effde Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 21 Feb 2023 15:16:59 +0400 Subject: [PATCH 161/252] Adjusted to use same Python and Ubuntu version for RDT (#1130) --- .readthedocs.yaml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..96c57f04 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,6 @@ +version: 2 + +build: + os: "ubuntu-22.04" + tools: + python: "3.8" From b8faed84b96126aab7c4e0241cb62c9ce4f7e13f Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 21 Feb 2023 15:24:02 +0400 Subject: [PATCH 162/252] Adjusted some still old formatted strings to f-strings (#1131) --- CHANGELOG.md | 1 + docs/conf.py | 2 +- rest_framework_json_api/django_filters/backends.py | 2 +- rest_framework_json_api/renderers.py | 4 ++-- rest_framework_json_api/settings.py | 2 +- rest_framework_json_api/utils.py | 2 +- 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a509b262..ce154cbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Changed * Added support to overwrite serializer methods in customized schema class +* Adjusted some still old formatted strings to f-strings. ### Fixed diff --git a/docs/conf.py b/docs/conf.py index ea67682f..fcc91d58 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -61,7 +61,7 @@ # General information about the project. project = "Django REST framework JSON:API" year = datetime.date.today().year -copyright = "{}, Django REST framework JSON:API contributors".format(year) +copyright = f"{year}, Django REST framework JSON:API contributors" author = "Django REST framework JSON:API contributors" # The version info for the project you're documenting, acts as replacement for diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index 83495004..c0044839 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -142,5 +142,5 @@ def get_schema_operation_parameters(self, view): for res in result: if "name" in res: name = format_field_name(res["name"].replace("__", ".")) - res["name"] = "filter[{}]".format(name) + res["name"] = f"filter[{name}]" return result diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 43fef0a3..0540a39f 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -166,7 +166,7 @@ def extract_relationships(cls, fields, resource, resource_instance): (relations.PrimaryKeyRelatedField, relations.HyperlinkedRelatedField), ): resolved, relation = utils.get_relation_instance( - resource_instance, "%s_id" % source, field.parent + resource_instance, f"{source}_id", field.parent ) if not resolved: continue @@ -341,7 +341,7 @@ def extract_included( serializer_data = field.data new_included_resources = [ - key.replace("%s." % field_name, "", 1) + key.replace(f"{field_name}.", "", 1) for key in included_resources if field_name == key.split(".")[0] ] diff --git a/rest_framework_json_api/settings.py b/rest_framework_json_api/settings.py index 0800e901..83228359 100644 --- a/rest_framework_json_api/settings.py +++ b/rest_framework_json_api/settings.py @@ -30,7 +30,7 @@ def __init__(self, user_settings=settings, defaults=DEFAULTS): def __getattr__(self, attr): if attr not in self.defaults: - raise AttributeError("Invalid JSON:API setting: '%s'" % attr) + raise AttributeError(f"Invalid JSON:API setting: '{attr}'") value = getattr( self.user_settings, JSON_API_SETTINGS_PREFIX + attr, self.defaults[attr] diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 91b3aba9..b7f2f9a0 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -269,7 +269,7 @@ def get_related_resource_type(relation): if hasattr(relation, "child_relation"): return get_related_resource_type(relation.child_relation) raise APIException( - _("Could not resolve resource type for relation %s" % relation) + _(f"Could not resolve resource type for relation {relation}") ) return get_resource_type_from_model(relation_model) From 8e1edcd1ac8ac92d864128fcde383563a2398318 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 21 Feb 2023 16:07:20 +0400 Subject: [PATCH 163/252] Added requirements.txt and spinx docs location to RTD conf (#1133) --- .readthedocs.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 96c57f04..d4503fd3 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -4,3 +4,10 @@ build: os: "ubuntu-22.04" tools: python: "3.8" + +sphinx: + configuration: docs/conf.py + +python: + install: + - requirements: docs/requirements.txt From 5c88b9339bd9ed640c2c8b80b0d3789653aa6620 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 21 Feb 2023 17:06:39 +0400 Subject: [PATCH 164/252] Made docs/requirements.txt obsolete and install project itself (#1134) --- .readthedocs.yaml | 5 ++++- docs/requirements.txt | 6 ------ tox.ini | 1 + 3 files changed, 5 insertions(+), 7 deletions(-) delete mode 100644 docs/requirements.txt diff --git a/.readthedocs.yaml b/.readthedocs.yaml index d4503fd3..d70a3ae5 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -10,4 +10,7 @@ sphinx: python: install: - - requirements: docs/requirements.txt + - requirements: requirements/requirements-optionals.txt + - requirements: requirements/requirements-documentation.txt + - method: pip + path: . diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index 4ffe63ef..00000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -# RTD only supports one requirements.txt file, therefore this has been created. -# This file needs to keep in sync with tox testenv docs dependencies. - --r ../requirements/requirements-optionals.txt --r ../requirements/requirements-testing.txt --r ../requirements/requirements-documentation.txt diff --git a/tox.ini b/tox.ini index 236f5517..0879e756 100644 --- a/tox.ini +++ b/tox.ini @@ -40,6 +40,7 @@ deps = commands = flake8 [testenv:docs] +# keep in sync with .readthedocs.yml basepython = python3.8 deps = -rrequirements/requirements-optionals.txt From ad5a793b54ca2922223352a58830515d25fa4807 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 22 Feb 2023 20:20:32 +0400 Subject: [PATCH 165/252] Replaced OrderedDict with dict (#1132) * try using python dict instead of ordered dict in metadata * try using python dict instead of ordered dict in pagination field * try using python dict instead of ordered dict in relations * Adjusted to dict syntax * Removed OrderedDict from renderers.py * Removed OrderedDict in serializers, utils and views * Simplified creating of dicts in renderers.py * Added CHANGELOG entry --------- Co-authored-by: Asif Saif Uddin --- CHANGELOG.md | 1 + rest_framework_json_api/metadata.py | 16 ++---- rest_framework_json_api/pagination.py | 53 +++++++---------- rest_framework_json_api/relations.py | 15 ++--- rest_framework_json_api/renderers.py | 80 ++++++++++---------------- rest_framework_json_api/serializers.py | 5 +- rest_framework_json_api/utils.py | 7 +-- rest_framework_json_api/views.py | 7 +-- tests/test_pagination.py | 34 ++++------- 9 files changed, 83 insertions(+), 135 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce154cbb..7a1c3444 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ any parts of the framework not mentioned in the documentation should generally b * Added support to overwrite serializer methods in customized schema class * Adjusted some still old formatted strings to f-strings. +* Replaced `OrderedDict` with `dict` which is also ordered since Python 3.7. ### Fixed diff --git a/rest_framework_json_api/metadata.py b/rest_framework_json_api/metadata.py index 14b9f69b..c7f7b7b4 100644 --- a/rest_framework_json_api/metadata.py +++ b/rest_framework_json_api/metadata.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from django.db.models.fields import related from django.utils.encoding import force_str from rest_framework import serializers @@ -65,7 +63,7 @@ class JSONAPIMetadata(SimpleMetadata): ) def determine_metadata(self, request, view): - metadata = OrderedDict() + metadata = {} metadata["name"] = view.get_view_name() metadata["description"] = view.get_view_description() metadata["renders"] = [ @@ -92,19 +90,17 @@ def get_serializer_info(self, serializer): # Remove the URL field if present serializer.fields.pop(api_settings.URL_FIELD_NAME, None) - return OrderedDict( - [ - (format_field_name(field_name), self.get_field_info(field)) - for field_name, field in serializer.fields.items() - ] - ) + return { + format_field_name(field_name): self.get_field_info(field) + for field_name, field in serializer.fields.items() + } def get_field_info(self, field): """ Given an instance of a serializer field, return a dictionary of metadata about it. """ - field_info = OrderedDict() + field_info = {} serializer = field.parent if isinstance(field, serializers.ManyRelatedField): diff --git a/rest_framework_json_api/pagination.py b/rest_framework_json_api/pagination.py index 6cbd744c..0c0d3bb9 100644 --- a/rest_framework_json_api/pagination.py +++ b/rest_framework_json_api/pagination.py @@ -1,7 +1,6 @@ """ Pagination fields """ -from collections import OrderedDict from rest_framework.pagination import LimitOffsetPagination, PageNumberPagination from rest_framework.utils.urls import remove_query_param, replace_query_param @@ -36,22 +35,18 @@ def get_paginated_response(self, data): { "results": data, "meta": { - "pagination": OrderedDict( - [ - ("page", self.page.number), - ("pages", self.page.paginator.num_pages), - ("count", self.page.paginator.count), - ] - ) + "pagination": { + "page": self.page.number, + "pages": self.page.paginator.num_pages, + "count": self.page.paginator.count, + } + }, + "links": { + "first": self.build_link(1), + "last": self.build_link(self.page.paginator.num_pages), + "next": self.build_link(next), + "prev": self.build_link(previous), }, - "links": OrderedDict( - [ - ("first", self.build_link(1)), - ("last", self.build_link(self.page.paginator.num_pages)), - ("next", self.build_link(next)), - ("prev", self.build_link(previous)), - ] - ), } ) @@ -97,21 +92,17 @@ def get_paginated_response(self, data): { "results": data, "meta": { - "pagination": OrderedDict( - [ - ("count", self.count), - ("limit", self.limit), - ("offset", self.offset), - ] - ) + "pagination": { + "count": self.count, + "limit": self.limit, + "offset": self.offset, + } + }, + "links": { + "first": self.get_first_link(), + "last": self.get_last_link(), + "next": self.get_next_link(), + "prev": self.get_previous_link(), }, - "links": OrderedDict( - [ - ("first", self.get_first_link()), - ("last", self.get_last_link()), - ("next", self.get_next_link()), - ("prev", self.get_previous_link()), - ] - ), } ) diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index 6eb69bdb..bb360ebb 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -1,5 +1,4 @@ import json -from collections import OrderedDict import inflection from django.core.exceptions import ImproperlyConfigured @@ -104,7 +103,7 @@ def get_url(self, name, view_name, kwargs, request): def get_links(self, obj=None, lookup_field="pk"): request = self.context.get("request", None) view = self.context.get("view", None) - return_data = OrderedDict() + return_data = {} kwargs = { lookup_field: getattr(obj, lookup_field) @@ -257,7 +256,7 @@ def to_representation(self, value): if resource_type is None or not self._skip_polymorphic_optimization: resource_type = get_resource_type_from_instance(value) - return OrderedDict([("type", resource_type), ("id", str(pk))]) + return {"type": resource_type, "id": str(pk)} def get_resource_type_from_included_serializer(self): """ @@ -301,12 +300,10 @@ def get_choices(self, cutoff=None): if cutoff is not None: queryset = queryset[:cutoff] - return OrderedDict( - [ - (json.dumps(self.to_representation(item)), self.display_value(item)) - for item in queryset - ] - ) + return { + json.dumps(self.to_representation(item)): self.display_value(item) + for item in queryset + } class PolymorphicResourceRelatedField(ResourceRelatedField): diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 0540a39f..7263b96b 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -2,13 +2,13 @@ Renderers """ import copy -from collections import OrderedDict, defaultdict +from collections import defaultdict from collections.abc import Iterable import inflection from django.db.models import Manager from django.template import loader -from django.utils import encoding +from django.utils.encoding import force_str from rest_framework import relations, renderers from rest_framework.fields import SkipField, get_attribute from rest_framework.relations import PKOnlyObject @@ -56,7 +56,7 @@ def extract_attributes(cls, fields, resource): """ Builds the `attributes` object of the JSON:API resource object. """ - data = OrderedDict() + data = {} for field_name, field in iter(fields.items()): # ID is always provided in the root of JSON:API so remove it from attributes if field_name == "id": @@ -89,7 +89,7 @@ def extract_relationships(cls, fields, resource, resource_instance): # Avoid circular deps from rest_framework_json_api.relations import ResourceRelatedField - data = OrderedDict() + data = {} # Don't try to extract relationships from a non-existent resource if resource_instance is None: @@ -125,16 +125,10 @@ def extract_relationships(cls, fields, resource, resource_instance): relation_instance if relation_instance is not None else list() ) - for related_object in relation_queryset: - relation_data.append( - OrderedDict( - [ - ("type", relation_type), - ("id", encoding.force_str(related_object.pk)), - ] - ) - ) - + relation_data = [ + {"type": relation_type, "id": force_str(related_object.pk)} + for related_object in relation_queryset + ] data.update( { field_name: { @@ -171,18 +165,12 @@ def extract_relationships(cls, fields, resource, resource_instance): if not resolved: continue relation_id = relation if resource.get(field_name) else None - relation_data = { - "data": ( - OrderedDict( - [ - ("type", relation_type), - ("id", encoding.force_str(relation_id)), - ] - ) - if relation_id is not None - else None - ) - } + relation_data = {"data": None} + if relation_id is not None: + relation_data["data"] = { + "type": relation_type, + "id": force_str(relation_id), + } if isinstance( field, relations.HyperlinkedRelatedField @@ -233,12 +221,10 @@ def extract_relationships(cls, fields, resource, resource_instance): ) relation_data.append( - OrderedDict( - [ - ("type", nested_resource_instance_type), - ("id", encoding.force_str(nested_resource_instance.pk)), - ] - ) + { + "type": nested_resource_instance_type, + "id": force_str(nested_resource_instance.pk), + } ) data.update( { @@ -419,7 +405,7 @@ def extract_meta(cls, serializer, resource): else: meta = getattr(serializer, "Meta", None) meta_fields = getattr(meta, "meta_fields", []) - data = OrderedDict() + data = {} for field_name in meta_fields: data.update({field_name: resource.get(field_name)}) return data @@ -457,37 +443,33 @@ def build_json_resource_obj( # Determine type from the instance if the underlying model is polymorphic if force_type_resolution: resource_name = utils.get_resource_type_from_instance(resource_instance) - resource_data = [ - ("type", resource_name), - ( - "id", - encoding.force_str(resource_instance.pk) if resource_instance else None, - ), - ("attributes", cls.extract_attributes(fields, resource)), - ] + resource_id = force_str(resource_instance.pk) if resource_instance else None + resource_data = { + "type": resource_name, + "id": resource_id, + "attributes": cls.extract_attributes(fields, resource), + } relationships = cls.extract_relationships(fields, resource, resource_instance) if relationships: - resource_data.append(("relationships", relationships)) + resource_data["relationships"] = relationships # Add 'self' link if field is present and valid if api_settings.URL_FIELD_NAME in resource and isinstance( fields[api_settings.URL_FIELD_NAME], relations.RelatedField ): - resource_data.append( - ("links", {"self": resource[api_settings.URL_FIELD_NAME]}) - ) + resource_data["links"] = {"self": resource[api_settings.URL_FIELD_NAME]} meta = cls.extract_meta(serializer, resource) if meta: - resource_data.append(("meta", utils.format_field_names(meta))) + resource_data["meta"] = utils.format_field_names(meta) - return OrderedDict(resource_data) + return resource_data def render_relationship_view( self, data, accepted_media_type=None, renderer_context=None ): # Special case for RelationshipView view = renderer_context.get("view", None) - render_data = OrderedDict([("data", data)]) + render_data = {"data": data} links = view.get_links() if links: render_data.update({"links": links}), @@ -615,7 +597,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): ) # Make sure we render data in a specific order - render_data = OrderedDict() + render_data = {} if isinstance(data, dict) and data.get("links"): render_data["links"] = data.get("links") diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 91f86464..a288471e 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -1,4 +1,3 @@ -from collections import OrderedDict from collections.abc import Mapping import inflection @@ -95,7 +94,7 @@ def __init__(self, *args, **kwargs): pass else: fieldset = request.query_params.get(param_name).split(",") - # iterate over a *copy* of self.fields' underlying OrderedDict, because we may + # iterate over a *copy* of self.fields' underlying dict, because we may # modify the original during the iteration. # self.fields is a `rest_framework.utils.serializer_helpers.BindingDict` for field_name, _field in self.fields.fields.copy().items(): @@ -305,7 +304,7 @@ def get_field_names(self, declared_fields, info): """ meta_fields = getattr(self.Meta, "meta_fields", []) - declared = OrderedDict() + declared = {} for field_name in set(declared_fields.keys()): field = declared_fields[field_name] if field_name not in meta_fields: diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index b7f2f9a0..3d374eed 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -1,6 +1,5 @@ import inspect import operator -from collections import OrderedDict import inflection from django.conf import settings @@ -107,11 +106,7 @@ def format_field_names(obj, format_type=None): format_type = json_api_settings.FORMAT_FIELD_NAMES if isinstance(obj, dict): - formatted = OrderedDict() - for key, value in obj.items(): - key = format_value(key, format_type) - formatted[key] = value - return formatted + return {format_value(key, format_type): value for key, value in obj.items()} return obj diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index 0b3df693..90bb5574 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -23,7 +23,6 @@ from rest_framework_json_api.serializers import ResourceIdentifierObjectSerializer from rest_framework_json_api.utils import ( Hyperlink, - OrderedDict, get_included_resources, get_resource_type_from_instance, undo_format_link_segment, @@ -275,7 +274,7 @@ def get_url(self, name, view_name, kwargs, request): return Hyperlink(url, name) def get_links(self): - return_data = OrderedDict() + return_data = {} self_link = self.get_url( "self", self.self_link_view_name, self.kwargs, self.request ) @@ -284,9 +283,9 @@ def get_links(self): "related", self.related_link_view_name, related_kwargs, self.request ) if self_link: - return_data.update({"self": self_link}) + return_data["self"] = self_link if related_link: - return_data.update({"related": related_link}) + return_data["related"] = related_link return return_data def get(self, request, *args, **kwargs): diff --git a/tests/test_pagination.py b/tests/test_pagination.py index c09ac71c..10f0ebbf 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from rest_framework.request import Request from rest_framework_json_api.pagination import JsonApiLimitOffsetPagination @@ -27,28 +25,18 @@ def test_get_paginated_response(self, rf): expected_content = { "results": list(range(11, 16)), - "links": OrderedDict( - [ - ("first", "http://testserver/?page%5Blimit%5D=5"), - ( - "last", - "http://testserver/?page%5Blimit%5D=5&page%5Boffset%5D=100", - ), - ( - "next", - "http://testserver/?page%5Blimit%5D=5&page%5Boffset%5D=15", - ), - ("prev", "http://testserver/?page%5Blimit%5D=5&page%5Boffset%5D=5"), - ] - ), + "links": { + "first": "http://testserver/?page%5Blimit%5D=5", + "last": "http://testserver/?page%5Blimit%5D=5&page%5Boffset%5D=100", + "next": "http://testserver/?page%5Blimit%5D=5&page%5Boffset%5D=15", + "prev": "http://testserver/?page%5Blimit%5D=5&page%5Boffset%5D=5", + }, "meta": { - "pagination": OrderedDict( - [ - ("count", count), - ("limit", limit), - ("offset", offset), - ] - ) + "pagination": { + "count": count, + "limit": limit, + "offset": offset, + } }, } From 2e931e0e10d169c68814586d58242f7b123c5d1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arttu=20Per=C3=A4l=C3=A4?= Date: Fri, 10 Mar 2023 08:23:42 +0200 Subject: [PATCH 166/252] Use /data as the pointer for non-field serializer errors (#1137) --- AUTHORS | 1 + CHANGELOG.md | 1 + example/api/serializers/identity.py | 7 ++++++ example/tests/test_generic_viewset.py | 32 ++++++++++++++++++++++++++ rest_framework_json_api/serializers.py | 7 +++++- rest_framework_json_api/utils.py | 9 ++++++-- 6 files changed, 54 insertions(+), 3 deletions(-) diff --git a/AUTHORS b/AUTHORS index e27ba5f5..797ab52f 100644 --- a/AUTHORS +++ b/AUTHORS @@ -3,6 +3,7 @@ Adam Ziolkowski Alan Crosswell Alex Seidmann Anton Shutik +Arttu Perälä Ashley Loewen Asif Saif Uddin Beni Keller diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a1c3444..3f9c175e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Fixed * Refactored handling of the `sort` query parameter to fix duplicate declaration in the generated schema definition +* Non-field serializer errors are given a source.pointer value of "/data". ## [6.0.0] - 2022-09-24 diff --git a/example/api/serializers/identity.py b/example/api/serializers/identity.py index 069259d2..5e2a42e3 100644 --- a/example/api/serializers/identity.py +++ b/example/api/serializers/identity.py @@ -23,6 +23,13 @@ def validate_last_name(self, data): ) return data + def validate(self, data): + if data["first_name"] == data["last_name"]: + raise serializers.ValidationError( + "First name cannot be the same as last name!" + ) + return data + class Meta: model = auth_models.User fields = ( diff --git a/example/tests/test_generic_viewset.py b/example/tests/test_generic_viewset.py index c8a28f24..40812d6e 100644 --- a/example/tests/test_generic_viewset.py +++ b/example/tests/test_generic_viewset.py @@ -129,3 +129,35 @@ def test_custom_validation_exceptions(self): ) assert expected == response.json() + + def test_nonfield_validation_exceptions(self): + """ + Non-field errors should be attributed to /data source.pointer. + """ + expected = { + "errors": [ + { + "status": "400", + "source": { + "pointer": "/data", + }, + "detail": "First name cannot be the same as last name!", + "code": "invalid", + }, + ] + } + response = self.client.post( + "/identities", + { + "data": { + "type": "users", + "attributes": { + "email": "miles@example.com", + "first_name": "Miles", + "last_name": "Miles", + }, + } + }, + ) + + assert expected == response.json() diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index a288471e..4b619ac4 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -155,7 +155,12 @@ def validate_path(serializer_class, field_path, path): class ReservedFieldNamesMixin: """Ensures that reserved field names are not used and an error raised instead.""" - _reserved_field_names = {"meta", "results", "type"} + _reserved_field_names = { + "meta", + "results", + "type", + api_settings.NON_FIELD_ERRORS_KEY, + } def get_fields(self): fields = super().get_fields() diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 3d374eed..dab8a3bb 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -13,6 +13,7 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import exceptions, relations from rest_framework.exceptions import APIException +from rest_framework.settings import api_settings from .settings import json_api_settings @@ -381,10 +382,14 @@ def format_drf_errors(response, context, exc): ] for field, error in response.data.items(): + non_field_error = field == api_settings.NON_FIELD_ERRORS_KEY field = format_field_name(field) pointer = None - # pointer can be determined only if there's a serializer. - if has_serializer: + if non_field_error: + # Serializer error does not refer to a specific field. + pointer = "/data" + elif has_serializer: + # pointer can be determined only if there's a serializer. rel = "relationships" if field in relationship_fields else "attributes" pointer = f"/data/{rel}/{field}" if isinstance(exc, Http404) and isinstance(error, str): From 3b9661ec29d893cb2858c94d7ff54a14ca4c9034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arttu=20Per=C3=A4l=C3=A4?= Date: Mon, 13 Mar 2023 15:42:58 +0200 Subject: [PATCH 167/252] Revert SchemaGenerator description value from tuple to str (#1138) --- example/tests/__snapshots__/test_openapi.ambr | 545 ++++++++++++++++++ example/tests/test_openapi.py | 7 +- rest_framework_json_api/schemas/openapi.py | 2 +- 3 files changed, 548 insertions(+), 6 deletions(-) diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr index 713387d3..fd270b40 100644 --- a/example/tests/__snapshots__/test_openapi.ambr +++ b/example/tests/__snapshots__/test_openapi.ambr @@ -706,3 +706,548 @@ } ''' # --- +# name: test_schema_construction + ''' + { + "components": { + "parameters": { + "fields": { + "description": "[sparse fieldsets](https://jsonapi.org/format/#fetching-sparse-fieldsets).\nUse fields[\\]=field1,field2,...,fieldN", + "explode": true, + "in": "query", + "name": "fields", + "required": false, + "schema": { + "type": "object" + }, + "style": "deepObject" + }, + "include": { + "description": "[list of included related resources](https://jsonapi.org/format/#fetching-includes)", + "in": "query", + "name": "include", + "required": false, + "schema": { + "type": "string" + }, + "style": "form" + } + }, + "schemas": { + "AuthorList": { + "additionalProperties": false, + "properties": { + "attributes": { + "properties": { + "defaults": { + "default": "default", + "description": "help for defaults", + "maxLength": 20, + "minLength": 3, + "type": "string", + "writeOnly": true + }, + "email": { + "format": "email", + "maxLength": 254, + "type": "string" + }, + "fullName": { + "maxLength": 50, + "type": "string" + }, + "initials": { + "readOnly": true, + "type": "string" + }, + "name": { + "maxLength": 50, + "type": "string" + } + }, + "required": [ + "name", + "fullName", + "email" + ], + "type": "object" + }, + "id": { + "$ref": "#/components/schemas/id" + }, + "links": { + "properties": { + "self": { + "$ref": "#/components/schemas/link" + } + }, + "type": "object" + }, + "relationships": { + "properties": { + "authorType": { + "$ref": "#/components/schemas/reltoone" + }, + "bio": { + "$ref": "#/components/schemas/reltoone" + }, + "comments": { + "$ref": "#/components/schemas/reltomany" + }, + "entries": { + "$ref": "#/components/schemas/reltomany" + }, + "firstEntry": { + "$ref": "#/components/schemas/reltoone" + } + }, + "type": "object" + }, + "type": { + "$ref": "#/components/schemas/type" + } + }, + "required": [ + "type", + "id" + ], + "type": "object" + }, + "ResourceIdentifierObject": { + "oneOf": [ + { + "$ref": "#/components/schemas/relationshipToOne" + }, + { + "$ref": "#/components/schemas/relationshipToMany" + } + ] + }, + "datum": { + "description": "singular item", + "properties": { + "data": { + "$ref": "#/components/schemas/resource" + } + } + }, + "error": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "id": { + "type": "string" + }, + "links": { + "$ref": "#/components/schemas/links" + }, + "source": { + "properties": { + "meta": { + "$ref": "#/components/schemas/meta" + }, + "parameter": { + "description": "A string indicating which query parameter caused the error.", + "type": "string" + }, + "pointer": { + "description": "A [JSON Pointer](https://tools.ietf.org/html/rfc6901) to the associated entity in the request document [e.g. `/data` for a primary data object, or `/data/attributes/title` for a specific attribute.", + "type": "string" + } + }, + "type": "object" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "type": "object" + }, + "errors": { + "items": { + "$ref": "#/components/schemas/error" + }, + "type": "array", + "uniqueItems": true + }, + "failure": { + "properties": { + "errors": { + "$ref": "#/components/schemas/errors" + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi" + }, + "links": { + "$ref": "#/components/schemas/links" + }, + "meta": { + "$ref": "#/components/schemas/meta" + } + }, + "required": [ + "errors" + ], + "type": "object" + }, + "id": { + "description": "Each resource object\u2019s type and id pair MUST [identify](https://jsonapi.org/format/#document-resource-object-identification) a single, unique resource.", + "type": "string" + }, + "jsonapi": { + "additionalProperties": false, + "description": "The server's implementation", + "properties": { + "meta": { + "$ref": "#/components/schemas/meta" + }, + "version": { + "type": "string" + } + }, + "type": "object" + }, + "link": { + "oneOf": [ + { + "description": "a string containing the link's URL", + "format": "uri-reference", + "type": "string" + }, + { + "properties": { + "href": { + "description": "a string containing the link's URL", + "format": "uri-reference", + "type": "string" + }, + "meta": { + "$ref": "#/components/schemas/meta" + } + }, + "required": [ + "href" + ], + "type": "object" + } + ] + }, + "linkage": { + "description": "the 'type' and 'id'", + "properties": { + "id": { + "$ref": "#/components/schemas/id" + }, + "meta": { + "$ref": "#/components/schemas/meta" + }, + "type": { + "$ref": "#/components/schemas/type" + } + }, + "required": [ + "type", + "id" + ], + "type": "object" + }, + "links": { + "additionalProperties": { + "$ref": "#/components/schemas/link" + }, + "type": "object" + }, + "meta": { + "additionalProperties": true, + "type": "object" + }, + "nulltype": { + "default": null, + "nullable": true, + "type": "object" + }, + "onlymeta": { + "additionalProperties": false, + "properties": { + "meta": { + "$ref": "#/components/schemas/meta" + } + } + }, + "pageref": { + "oneOf": [ + { + "format": "uri-reference", + "type": "string" + }, + { + "$ref": "#/components/schemas/nulltype" + } + ] + }, + "pagination": { + "properties": { + "first": { + "$ref": "#/components/schemas/pageref" + }, + "last": { + "$ref": "#/components/schemas/pageref" + }, + "next": { + "$ref": "#/components/schemas/pageref" + }, + "prev": { + "$ref": "#/components/schemas/pageref" + } + }, + "type": "object" + }, + "relationshipLinks": { + "additionalProperties": true, + "description": "optional references to other resource objects", + "properties": { + "related": { + "$ref": "#/components/schemas/link" + }, + "self": { + "$ref": "#/components/schemas/link" + } + }, + "type": "object" + }, + "relationshipToMany": { + "description": "An array of objects each containing the 'type' and 'id' for to-many relationships", + "items": { + "$ref": "#/components/schemas/linkage" + }, + "type": "array", + "uniqueItems": true + }, + "relationshipToOne": { + "anyOf": [ + { + "$ref": "#/components/schemas/nulltype" + }, + { + "$ref": "#/components/schemas/linkage" + } + ], + "description": "reference to other resource in a to-one relationship" + }, + "reltomany": { + "description": "a multiple 'to-many' relationship", + "properties": { + "data": { + "$ref": "#/components/schemas/relationshipToMany" + }, + "links": { + "$ref": "#/components/schemas/relationshipLinks" + }, + "meta": { + "$ref": "#/components/schemas/meta" + } + }, + "type": "object" + }, + "reltoone": { + "description": "a singular 'to-one' relationship", + "properties": { + "data": { + "$ref": "#/components/schemas/relationshipToOne" + }, + "links": { + "$ref": "#/components/schemas/relationshipLinks" + }, + "meta": { + "$ref": "#/components/schemas/meta" + } + }, + "type": "object" + }, + "resource": { + "additionalProperties": false, + "properties": { + "attributes": { + "type": "object" + }, + "id": { + "$ref": "#/components/schemas/id" + }, + "links": { + "$ref": "#/components/schemas/links" + }, + "meta": { + "$ref": "#/components/schemas/meta" + }, + "relationships": { + "type": "object" + }, + "type": { + "$ref": "#/components/schemas/type" + } + }, + "required": [ + "type", + "id" + ], + "type": "object" + }, + "type": { + "description": "The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships.", + "type": "string" + } + } + }, + "info": { + "title": "", + "version": "" + }, + "openapi": "3.0.2", + "paths": { + "/authors/": { + "get": { + "description": "", + "operationId": "List/authors/", + "parameters": [ + { + "$ref": "#/components/parameters/include" + }, + { + "$ref": "#/components/parameters/fields" + }, + { + "description": "A page number within the paginated result set.", + "in": "query", + "name": "page[number]", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Number of results to return per page.", + "in": "query", + "name": "page[size]", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "author_type", + "in": "query", + "name": "filter[authorType]", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "name", + "in": "query", + "name": "filter[name]", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "A search term.", + "in": "query", + "name": "filter[search]", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/vnd.api+json": { + "schema": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/AuthorList" + }, + "type": "array" + }, + "included": { + "items": { + "$ref": "#/components/schemas/resource" + }, + "type": "array", + "uniqueItems": true + }, + "jsonapi": { + "$ref": "#/components/schemas/jsonapi" + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/links" + }, + { + "$ref": "#/components/schemas/pagination" + } + ], + "description": "Link members related to primary data" + } + }, + "required": [ + "data" + ], + "type": "object" + } + } + }, + "description": "List/authors/" + }, + "401": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not authorized" + }, + "404": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "not found" + } + }, + "tags": [ + "authors" + ] + } + } + } + } + ''' +# --- diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index 5b881f8b..69cfbccc 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -97,7 +97,7 @@ def test_delete_request(snapshot): "DEFAULT_SCHEMA_CLASS": "rest_framework_json_api.schemas.openapi.AutoSchema" } ) -def test_schema_construction(): +def test_schema_construction(snapshot): """Construction of the top level dictionary.""" patterns = [ re_path("^authors/?$", views.AuthorViewSet.as_view({"get": "list"})), @@ -107,10 +107,7 @@ def test_schema_construction(): request = create_request("/") schema = generator.get_schema(request=request) - assert "openapi" in schema - assert "info" in schema - assert "paths" in schema - assert "components" in schema + assert snapshot == json.dumps(schema, indent=2, sort_keys=True) def test_schema_related_serializers(): diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 7cb7d9ca..e3c35a37 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -185,7 +185,7 @@ class SchemaGenerator(drf_openapi.SchemaGenerator): "A [JSON Pointer](https://tools.ietf.org/html/rfc6901) " "to the associated entity in the request document " "[e.g. `/data` for a primary data object, or " - "`/data/attributes/title` for a specific attribute.", + "`/data/attributes/title` for a specific attribute." ), }, "parameter": { From 1182281750e92777e37fd3701d35085943125b24 Mon Sep 17 00:00:00 2001 From: Jeppe Fihl-Pearson Date: Tue, 4 Apr 2023 15:47:23 +0100 Subject: [PATCH 168/252] Add support for Django 4.2 (#1142) * Add support for Django 4.2 * Don't use deprecated password hashing implementation The tests run pretty quickly regardless, so it should be okay to use the default password hasher. * Reinstate tests for Django 4.1 --- CHANGELOG.md | 1 + README.rst | 2 +- docs/getting-started.md | 2 +- example/settings/dev.py | 2 -- setup.py | 2 +- tox.ini | 5 +++++ 6 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f9c175e..e7243f3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Added * Added support for Python 3.11. +* Added support for Django 4.2. ### Changed diff --git a/README.rst b/README.rst index acc50976..82c2b08f 100644 --- a/README.rst +++ b/README.rst @@ -93,7 +93,7 @@ Requirements ------------ 1. Python (3.7, 3.8, 3.9, 3.10, 3.11) -2. Django (3.2, 4.0, 4.1) +2. Django (3.2, 4.0, 4.1, 4.2) 3. Django REST framework (3.13, 3.14) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index b73af5ea..07798fae 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.7, 3.8, 3.9, 3.10, 3.11) -2. Django (3.2, 4.0, 4.1) +2. Django (3.2, 4.0, 4.1, 4.2) 3. Django REST framework (3.13, 3.14) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/example/settings/dev.py b/example/settings/dev.py index c5405338..7b40e61f 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -60,8 +60,6 @@ SECRET_KEY = "abc123" -PASSWORD_HASHERS = ("django.contrib.auth.hashers.UnsaltedMD5PasswordHasher",) - INTERNAL_IPS = ("127.0.0.1",) JSON_API_FORMAT_FIELD_NAMES = "camelize" diff --git a/setup.py b/setup.py index 3c60bba2..6ebee0d5 100755 --- a/setup.py +++ b/setup.py @@ -98,7 +98,7 @@ def get_package_data(package): install_requires=[ "inflection>=0.5.0", "djangorestframework>=3.13,<3.15", - "django>=3.2,<4.2", + "django>=3.2,<4.3", ], extras_require={ "django-polymorphic": ["django-polymorphic>=3.0"], diff --git a/tox.ini b/tox.ini index 0879e756..ede6f27f 100644 --- a/tox.ini +++ b/tox.ini @@ -3,6 +3,7 @@ envlist = py{37,38,39,310}-django32-drf{313,314,master}, py{38,39,310}-django40-drf{313,314,master}, py{38,39,310,311}-django41-drf{314,master}, + py{38,39,310,311}-django42-drf{314,master}, black, docs, lint @@ -12,6 +13,7 @@ deps = django32: Django>=3.2,<3.3 django40: Django>=4.0,<4.1 django41: Django>=4.1,<4.2 + django42: Django>=4.2,<4.3 drf313: djangorestframework>=3.13,<3.14 drf314: djangorestframework>=3.14,<3.15 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip @@ -56,3 +58,6 @@ ignore_outcome = true [testenv:py{38,39,310,311}-django41-drfmaster] ignore_outcome = true + +[testenv:py{38,39,310,311}-django42-drfmaster] +ignore_outcome = true From b6311415cf8c7150a37aaae61983f7cca099f632 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Fri, 21 Apr 2023 06:04:22 -0500 Subject: [PATCH 169/252] Scheduled biweekly dependency update for week 16 (#1143) * Update black from 23.1.0 to 23.3.0 * Update flake8-bugbear from 23.2.13 to 23.3.23 * Update django-filter from 22.1 to 23.1 * Update faker from 17.0.0 to 18.4.0 * Update pytest from 7.2.1 to 7.3.1 * Update syrupy from 3.0.6 to 4.0.1 * Revert to previous syrupy which supports 3.7 --------- Co-authored-by: Oliver Sauder --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-optionals.txt | 2 +- requirements/requirements-testing.txt | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 4092f80f..8eba9fae 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ -black==23.1.0 +black==23.3.0 flake8==6.0.0 -flake8-bugbear==23.2.13 +flake8-bugbear==23.3.23 flake8-isort==6.0.0 isort==5.12.0 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 9d274a6d..b8a8b54d 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,4 +1,4 @@ -django-filter==22.1 +django-filter==23.1 django-polymorphic==3.1.0 pyyaml==6.0 uritemplate==4.1.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 69371227..7fac28a4 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,6 +1,6 @@ factory-boy==3.2.1 -Faker==17.0.0 -pytest==7.2.1 +Faker==18.4.0 +pytest==7.3.1 pytest-cov==4.0.0 pytest-django==4.5.2 pytest-factoryboy==2.5.1 From 7cab679b5db4e497d01e7555d50c398c6b376bbe Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 25 Apr 2023 20:35:06 +0400 Subject: [PATCH 170/252] Ignored pkg_resources deprecation warnings (#1146) This is to fix failing CI. Should actually be fixed in django-polymorphic where pkg_resources is used. --- setup.cfg | 3 +++ 1 file changed, 3 insertions(+) diff --git a/setup.cfg b/setup.cfg index c9d88f15..d3ea5ebc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -65,6 +65,9 @@ filterwarnings = error::PendingDeprecationWarning # Remove when DRF is not depending on it anymore ignore:The django.utils.timezone.utc alias is deprecated. + # can be removed once fixed in django polymorphic + ignore:pkg_resources is deprecated as an API + ignore:Deprecated call to `pkg_resource testpaths = example tests From ac8ffe22f0bcc639e9f128ede15e59bf95e5e081 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 22 May 2023 22:12:00 +0400 Subject: [PATCH 171/252] Removed upper bounds of Django and DRF (#1152) We only set upper bounds in cases a release is actually breaking. --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 6ebee0d5..7d01a919 100755 --- a/setup.py +++ b/setup.py @@ -97,8 +97,8 @@ def get_package_data(package): ], install_requires=[ "inflection>=0.5.0", - "djangorestframework>=3.13,<3.15", - "django>=3.2,<4.3", + "djangorestframework>=3.13", + "django>=3.2", ], extras_require={ "django-polymorphic": ["django-polymorphic>=3.0"], From a7c1a00ad97acd7d3476a8da536a549a5c4d3127 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arttu=20Per=C3=A4l=C3=A4?= Date: Mon, 12 Jun 2023 17:02:00 +0300 Subject: [PATCH 172/252] Omit include parameter from OpenAPI schema when left unused (#1157) --- CHANGELOG.md | 2 ++ example/tests/test_openapi.py | 16 ++++++++++++++++ rest_framework_json_api/schemas/openapi.py | 10 +++++++--- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7243f3f..214f73b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ any parts of the framework not mentioned in the documentation should generally b * Added support to overwrite serializer methods in customized schema class * Adjusted some still old formatted strings to f-strings. * Replaced `OrderedDict` with `dict` which is also ordered since Python 3.7. +* Compound document "include" parameter is only included in the OpenAPI schema if serializer + implements `included_serializers`. ### Fixed diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index 69cfbccc..a355540e 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -110,6 +110,22 @@ def test_schema_construction(snapshot): assert snapshot == json.dumps(schema, indent=2, sort_keys=True) +def test_schema_parameters_include(): + """Include paramater is only used when serializer defines included_serializers.""" + patterns = [ + re_path("^authors/?$", views.AuthorViewSet.as_view({"get": "list"})), + re_path("^project-types/?$", views.ProjectTypeViewset.as_view({"get": "list"})), + ] + generator = SchemaGenerator(patterns=patterns) + + request = create_request("/") + schema = generator.get_schema(request=request) + + include_ref = {"$ref": "#/components/parameters/include"} + assert include_ref in schema["paths"]["/authors/"]["get"]["parameters"] + assert include_ref not in schema["paths"]["/project-types/"]["get"]["parameters"] + + def test_schema_related_serializers(): """ Confirm that paths are generated for related fields. For example: diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index e3c35a37..f1f4f92e 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -406,11 +406,13 @@ def get_operation(self, path, method): operation["operationId"] = self.get_operation_id(path, method) operation["description"] = self.get_description(path, method) + serializer = self.get_response_serializer(path, method) + parameters = [] parameters += self.get_path_parameters(path, method) # pagination, filters only apply to GET/HEAD of collections and items if method in ["GET", "HEAD"]: - parameters += self._get_include_parameters(path, method) + parameters += self._get_include_parameters(path, method, serializer) parameters += self._get_fields_parameters(path, method) parameters += self.get_pagination_parameters(path, method) parameters += self.get_filter_parameters(path, method) @@ -448,11 +450,13 @@ def get_operation_id(self, path, method): action = self.method_mapping[method.lower()] return action + path - def _get_include_parameters(self, path, method): + def _get_include_parameters(self, path, method, serializer): """ includes parameter: https://jsonapi.org/format/#fetching-includes """ - return [{"$ref": "#/components/parameters/include"}] + if getattr(serializer, "included_serializers", {}): + return [{"$ref": "#/components/parameters/include"}] + return [] def _get_fields_parameters(self, path, method): """ From 66d2de49e5b3a876cc0847f9efa183fcb4181cee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arttu=20Per=C3=A4l=C3=A4?= Date: Tue, 13 Jun 2023 08:56:33 +0300 Subject: [PATCH 173/252] Omit id field from OpenAPI schema attributes (#1156) --- CHANGELOG.md | 1 + example/tests/test_openapi.py | 15 +++++++++++++++ rest_framework_json_api/schemas/openapi.py | 4 ++++ 3 files changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 214f73b5..50e8406c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ any parts of the framework not mentioned in the documentation should generally b * Refactored handling of the `sort` query parameter to fix duplicate declaration in the generated schema definition * Non-field serializer errors are given a source.pointer value of "/data". +* Fixed "id" field being added to /data/attributes in the OpenAPI schema when it is not rendered there. ## [6.0.0] - 2022-09-24 diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index a355540e..a2fa6f45 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -110,6 +110,21 @@ def test_schema_construction(snapshot): assert snapshot == json.dumps(schema, indent=2, sort_keys=True) +def test_schema_id_field(): + """ID field is only included in the root, not the attributes.""" + patterns = [ + re_path("^companies/?$", views.CompanyViewset.as_view({"get": "list"})), + ] + generator = SchemaGenerator(patterns=patterns) + + request = create_request("/") + schema = generator.get_schema(request=request) + + company_properties = schema["components"]["schemas"]["Company"]["properties"] + assert company_properties["id"] == {"$ref": "#/components/schemas/id"} + assert "id" not in company_properties["attributes"]["properties"] + + def test_schema_parameters_include(): """Include paramater is only used when serializer defines included_serializers.""" patterns = [ diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index f1f4f92e..9ae8a355 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -670,6 +670,10 @@ def map_serializer(self, serializer): "$ref": "#/components/schemas/reltomany" } continue + if field.field_name == "id": + # ID is always provided in the root of JSON:API and removed from the + # attributes in JSONRenderer. + continue if field.required: required.append(format_field_name(field.field_name)) From 80eea77e5253d453ba1f6754beb3d5691eaad50a Mon Sep 17 00:00:00 2001 From: Jonathan Hiles Date: Wed, 14 Jun 2023 04:30:26 +1000 Subject: [PATCH 174/252] Allowed to overwrite resource id in serializer (#1127) Co-authored-by: Oliver Sauder --- AUTHORS | 1 + CHANGELOG.md | 17 +++++++ docs/usage.md | 38 ++++++++++++++++ rest_framework_json_api/relations.py | 14 +++--- rest_framework_json_api/renderers.py | 3 +- rest_framework_json_api/utils.py | 13 ++++++ tests/test_relations.py | 25 ++++++++++- tests/test_utils.py | 17 +++++++ tests/test_views.py | 67 ++++++++++++++++++++++++++++ 9 files changed, 187 insertions(+), 8 deletions(-) diff --git a/AUTHORS b/AUTHORS index 797ab52f..fbd3fe6e 100644 --- a/AUTHORS +++ b/AUTHORS @@ -20,6 +20,7 @@ Jeppe Fihl-Pearson Jerel Unruh Jonas Kiefer Jonas Metzener +Jonathan Hiles Jonathan Senecal Joseba Mendivil Kal diff --git a/CHANGELOG.md b/CHANGELOG.md index 50e8406c..972a3daf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,23 @@ any parts of the framework not mentioned in the documentation should generally b * Replaced `OrderedDict` with `dict` which is also ordered since Python 3.7. * Compound document "include" parameter is only included in the OpenAPI schema if serializer implements `included_serializers`. +* Allowed overwriting of resource id by defining an `id` field on the serializer. + + Example: + ```python + class CustomIdSerializer(serializers.Serializer): + id = serializers.CharField(source='name') + body = serializers.CharField() + ``` + +* Allowed overwriting resource id on resource related fields by creating custom `ResourceRelatedField`. + + Example: + ```python + class CustomResourceRelatedField(relations.ResourceRelatedField): + def get_resource_id(self, value): + return value.name + ``` ### Fixed diff --git a/docs/usage.md b/docs/usage.md index 5da8846a..a7dadcce 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -278,6 +278,44 @@ class MyModelSerializer(serializers.ModelSerializer): # ... ``` +### Overwriting the resource object's id + +Per default the primary key property `pk` on the instance is used as the resource identifier. + +It is possible to overwrite the resource id by defining an `id` field on the serializer like: + +```python +class UserSerializer(serializers.ModelSerializer): + id = serializers.CharField(source='email') + name = serializers.CharField() + + class Meta: + model = User +``` + +This also works on generic serializers. + +In case you also use a model as a resource related field make sure to overwrite `get_resource_id` by creating a custom `ResourceRelatedField` class: + +```python +class UserResourceRelatedField(ResourceRelatedField): + def get_resource_id(self, value): + return value.email + +class GroupSerializer(serializers.ModelSerializer): + user = UserResourceRelatedField(queryset=User.objects) + name = serializers.CharField() + + class Meta: + model = Group +``` + +
+ Note: + When using different id than primary key, make sure that your view + manages it properly by overwriting `get_object`. +
+ ### Setting resource identifier object type You may manually set resource identifier object type by using `resource_name` property on views, serializers, or diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index bb360ebb..32547253 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -247,17 +247,21 @@ def to_internal_value(self, data): return super().to_internal_value(data["id"]) def to_representation(self, value): - if getattr(self, "pk_field", None) is not None: - pk = self.pk_field.to_representation(value.pk) - else: - pk = value.pk - + pk = self.get_resource_id(value) resource_type = self.get_resource_type_from_included_serializer() if resource_type is None or not self._skip_polymorphic_optimization: resource_type = get_resource_type_from_instance(value) return {"type": resource_type, "id": str(pk)} + def get_resource_id(self, value): + """ + Get resource id of related field. + + Per default pk of value is returned. + """ + return super().to_representation(value) + def get_resource_type_from_included_serializer(self): """ Check to see it this resource has a different resource_name when diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 7263b96b..f7660208 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -443,10 +443,9 @@ def build_json_resource_obj( # Determine type from the instance if the underlying model is polymorphic if force_type_resolution: resource_name = utils.get_resource_type_from_instance(resource_instance) - resource_id = force_str(resource_instance.pk) if resource_instance else None resource_data = { "type": resource_name, - "id": resource_id, + "id": utils.get_resource_id(resource_instance, resource), "attributes": cls.extract_attributes(fields, resource), } relationships = cls.extract_relationships(fields, resource, resource_instance) diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index dab8a3bb..2e57fbbd 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -304,6 +304,19 @@ def get_resource_type_from_serializer(serializer): ) +def get_resource_id(resource_instance, resource): + """Returns the resource identifier for a given instance (`id` takes priority over `pk`).""" + if resource and "id" in resource: + return resource["id"] and encoding.force_str(resource["id"]) or None + if resource_instance: + return ( + hasattr(resource_instance, "pk") + and encoding.force_str(resource_instance.pk) + or None + ) + return None + + 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 diff --git a/tests/test_relations.py b/tests/test_relations.py index 74721cfa..ad4bebcb 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -10,9 +10,10 @@ HyperlinkedRelatedField, SerializerMethodHyperlinkedRelatedField, ) +from rest_framework_json_api.serializers import ModelSerializer, ResourceRelatedField from rest_framework_json_api.utils import format_link_segment from rest_framework_json_api.views import RelationshipView -from tests.models import BasicModel +from tests.models import BasicModel, ForeignKeySource, ForeignKeyTarget from tests.serializers import ( ForeignKeySourceSerializer, ManyToManySourceReadOnlySerializer, @@ -46,6 +47,28 @@ def test_serialize( assert serializer.data["target"] == expected + def test_get_resource_id(self, foreign_key_target): + class CustomResourceRelatedField(ResourceRelatedField): + def get_resource_id(self, value): + return value.name + + class CustomPkFieldSerializer(ModelSerializer): + target = CustomResourceRelatedField( + queryset=ForeignKeyTarget.objects, pk_field="name" + ) + + class Meta: + model = ForeignKeySource + fields = ("target",) + + serializer = CustomPkFieldSerializer(instance={"target": foreign_key_target}) + expected = { + "type": "ForeignKeyTarget", + "id": "Target", + } + + assert serializer.data["target"] == expected + @pytest.mark.parametrize( "format_type,pluralize_type,resource_type", [ diff --git a/tests/test_utils.py b/tests/test_utils.py index 038e8ce9..f2a3d176 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -14,6 +14,7 @@ format_resource_type, format_value, get_related_resource_type, + get_resource_id, get_resource_name, get_resource_type_from_serializer, undo_format_field_name, @@ -392,6 +393,22 @@ class SerializerWithoutResourceName(serializers.Serializer): ) +@pytest.mark.parametrize( + "resource_instance, resource, expected", + [ + (None, None, None), + (object(), {}, None), + (BasicModel(id=5), None, "5"), + (BasicModel(id=9), {}, "9"), + (None, {"id": 11}, "11"), + (object(), {"pk": 11}, None), + (BasicModel(id=6), {"id": 11}, "11"), + ], +) +def test_get_resource_id(resource_instance, resource, expected): + assert get_resource_id(resource_instance, resource) == expected + + @pytest.mark.parametrize( "message,pointer,response,result", [ diff --git a/tests/test_views.py b/tests/test_views.py index 42680d6a..47fec02a 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -183,6 +183,50 @@ def test_patch(self, client): } } + @pytest.mark.urls(__name__) + def test_post_with_missing_id(self, client): + data = { + "data": { + "id": None, + "type": "custom", + "attributes": {"body": "hello"}, + } + } + + url = reverse("custom") + + response = client.post(url, data=data) + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "data": { + "type": "custom", + "id": None, + "attributes": {"body": "hello"}, + } + } + + @pytest.mark.urls(__name__) + def test_patch_with_custom_id(self, client): + data = { + "data": { + "id": 2_193_102, + "type": "custom", + "attributes": {"body": "hello"}, + } + } + + url = reverse("custom-id") + + response = client.patch(url, data=data) + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "data": { + "type": "custom", + "id": "2176ce", # get_id() -> hex + "attributes": {"body": "hello"}, + } + } + # Routing setup @@ -202,6 +246,14 @@ class CustomModelSerializer(serializers.Serializer): id = serializers.IntegerField() +class CustomIdModelSerializer(serializers.Serializer): + id = serializers.SerializerMethodField() + body = serializers.CharField() + + def get_id(self, obj): + return hex(obj.id)[2:] + + class CustomAPIView(APIView): parser_classes = [JSONParser] renderer_classes = [JSONRenderer] @@ -211,11 +263,26 @@ def patch(self, request, *args, **kwargs): serializer = CustomModelSerializer(CustomModel(request.data)) return Response(status=status.HTTP_200_OK, data=serializer.data) + def post(self, request, *args, **kwargs): + serializer = CustomModelSerializer(request.data) + return Response(status=status.HTTP_200_OK, data=serializer.data) + + +class CustomIdAPIView(APIView): + parser_classes = [JSONParser] + renderer_classes = [JSONRenderer] + resource_name = "custom" + + def patch(self, request, *args, **kwargs): + serializer = CustomIdModelSerializer(CustomModel(request.data)) + return Response(status=status.HTTP_200_OK, data=serializer.data) + router = SimpleRouter() router.register(r"basic_models", BasicModelViewSet, basename="basic-model") urlpatterns = [ path("custom", CustomAPIView.as_view(), name="custom"), + path("custom-id", CustomIdAPIView.as_view(), name="custom-id"), ] urlpatterns += router.urls From 57df2f1d663425bf84fba3de951355624569b26e Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 20 Jun 2023 15:08:17 +0400 Subject: [PATCH 175/252] Added missing group type to custom query parameter regex (#1158) --- docs/usage.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index a7dadcce..a50a97f7 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -118,7 +118,7 @@ If you want to change the list of valid query parameters, override the `.query_r ```python # compiled regex that matches the allowed https://jsonapi.org/format/#query-parameters # `sort` and `include` stand alone; `filter`, `fields`, and `page` have []'s -query_regex = re.compile(r'^(sort|include)$|^(filter|fields|page)(\[[\w\.\-]+\])?$') +query_regex = re.compile(r"^(sort|include)$|^(?Pfilter|fields|page)(\[[\w\.\-]+\])?$") ``` For example: ```python @@ -126,7 +126,7 @@ import re from rest_framework_json_api.filters import QueryParameterValidationFilter class MyQPValidator(QueryParameterValidationFilter): - query_regex = re.compile(r'^(sort|include|page|page_size)$|^(filter|fields|page)(\[[\w\.\-]+\])?$') + query_regex = re.compile(r"^(sort|include|page|page_size)$|^(?Pfilter|fields|page)(\[[\w\.\-]+\])?$") ``` If you don't care if non-JSON:API query parameters are allowed (and potentially silently ignored), From 71508efa3fee8ff75c9cfcd092844801aeb407e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arttu=20Per=C3=A4l=C3=A4?= Date: Wed, 28 Jun 2023 08:27:20 +0300 Subject: [PATCH 176/252] Fix many serializer method fields having the incorrect relation schema (#1161) --- CHANGELOG.md | 2 ++ example/tests/test_openapi.py | 22 ++++++++++++++++++++++ rest_framework_json_api/schemas/openapi.py | 15 +++++++++++---- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 972a3daf..d494fc2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,8 @@ any parts of the framework not mentioned in the documentation should generally b * Refactored handling of the `sort` query parameter to fix duplicate declaration in the generated schema definition * Non-field serializer errors are given a source.pointer value of "/data". * Fixed "id" field being added to /data/attributes in the OpenAPI schema when it is not rendered there. +* Fixed `SerializerMethodResourceRelatedField(many=True)` fields being given + a "reltoone" schema reference instead of "reltomany". ## [6.0.0] - 2022-09-24 diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index a2fa6f45..5710da2a 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -141,6 +141,28 @@ def test_schema_parameters_include(): assert include_ref not in schema["paths"]["/project-types/"]["get"]["parameters"] +def test_schema_serializer_method_resource_related_field(): + """SerializerMethodResourceRelatedField fieds have the correct relation ref.""" + patterns = [ + re_path("^entries/?$", views.EntryViewSet.as_view({"get": "list"})), + ] + generator = SchemaGenerator(patterns=patterns) + + request = Request(RequestFactory().get("/", {"include": "featured"})) + schema = generator.get_schema(request=request) + + entry_schema = schema["components"]["schemas"]["Entry"] + entry_relationships = entry_schema["properties"]["relationships"]["properties"] + + rel_to_many_ref = {"$ref": "#/components/schemas/reltomany"} + assert entry_relationships["suggested"] == rel_to_many_ref + assert entry_relationships["suggestedHyperlinked"] == rel_to_many_ref + + rel_to_one_ref = {"$ref": "#/components/schemas/reltoone"} + assert entry_relationships["featured"] == rel_to_one_ref + assert entry_relationships["featuredHyperlinked"] == rel_to_one_ref + + def test_schema_related_serializers(): """ Confirm that paths are generated for related fields. For example: diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 9ae8a355..4a0eeb83 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -8,6 +8,7 @@ from rest_framework_json_api import serializers, views from rest_framework_json_api.compat import get_reference +from rest_framework_json_api.relations import ManySerializerMethodResourceRelatedField from rest_framework_json_api.utils import format_field_name @@ -660,14 +661,20 @@ def map_serializer(self, serializer): continue if isinstance(field, serializers.HiddenField): continue - if isinstance(field, serializers.RelatedField): + if isinstance( + field, + ( + serializers.ManyRelatedField, + ManySerializerMethodResourceRelatedField, + ), + ): relationships[format_field_name(field.field_name)] = { - "$ref": "#/components/schemas/reltoone" + "$ref": "#/components/schemas/reltomany" } continue - if isinstance(field, serializers.ManyRelatedField): + if isinstance(field, serializers.RelatedField): relationships[format_field_name(field.field_name)] = { - "$ref": "#/components/schemas/reltomany" + "$ref": "#/components/schemas/reltoone" } continue if field.field_name == "id": From 311385e4b4f38bfc76203be8b7b3584ddc8cd080 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arttu=20Per=C3=A4l=C3=A4?= Date: Wed, 28 Jun 2023 21:19:12 +0300 Subject: [PATCH 177/252] Include meta section for SerializerMethodResourceRelatedField(many=True) (#1162) Fixes #572 --- CHANGELOG.md | 2 ++ example/tests/integration/test_non_paginated_responses.py | 2 ++ example/tests/integration/test_pagination.py | 1 + example/tests/test_filters.py | 1 + rest_framework_json_api/renderers.py | 6 ++++++ 5 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d494fc2a..a9dc65db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,8 @@ any parts of the framework not mentioned in the documentation should generally b return value.name ``` +* `SerializerMethodResourceRelatedField(many=True)` relationship data now includes a meta section. + ### Fixed * Refactored handling of the `sort` query parameter to fix duplicate declaration in the generated schema definition diff --git a/example/tests/integration/test_non_paginated_responses.py b/example/tests/integration/test_non_paginated_responses.py index 60376a8b..92d26de3 100644 --- a/example/tests/integration/test_non_paginated_responses.py +++ b/example/tests/integration/test_non_paginated_responses.py @@ -51,6 +51,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "related": "http://testserver/entries/1/suggested/", "self": "http://testserver/entries/1/relationships/suggested", }, + "meta": {"count": 1}, }, "suggestedHyperlinked": { "links": { @@ -106,6 +107,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): "related": "http://testserver/entries/2/suggested/", "self": "http://testserver/entries/2/relationships/suggested", }, + "meta": {"count": 1}, }, "suggestedHyperlinked": { "links": { diff --git a/example/tests/integration/test_pagination.py b/example/tests/integration/test_pagination.py index 4c60e96e..1a4bd056 100644 --- a/example/tests/integration/test_pagination.py +++ b/example/tests/integration/test_pagination.py @@ -51,6 +51,7 @@ def test_pagination_with_single_entry(single_entry, client): "related": "http://testserver/entries/1/suggested/", "self": "http://testserver/entries/1/relationships/suggested", }, + "meta": {"count": 0}, }, "suggestedHyperlinked": { "links": { diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index ab74dd0b..87f9d059 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -512,6 +512,7 @@ def test_search_keywords(self): {"type": "entries", "id": "11"}, {"type": "entries", "id": "12"}, ], + "meta": {"count": 11}, }, "suggestedHyperlinked": { "links": { diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index f7660208..9a0d9c0c 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -19,6 +19,7 @@ from rest_framework_json_api import utils from rest_framework_json_api.relations import ( HyperlinkedMixin, + ManySerializerMethodResourceRelatedField, ResourceRelatedField, SkipDataMixin, ) @@ -152,6 +153,11 @@ def extract_relationships(cls, fields, resource, resource_instance): if not isinstance(field, SkipDataMixin): relation_data.update({"data": resource.get(field_name)}) + if isinstance(field, ManySerializerMethodResourceRelatedField): + relation_data.update( + {"meta": {"count": len(resource.get(field_name))}} + ) + data.update({field_name: relation_data}) continue From 590dbb478b34deb642ad32ed77fff7190c65bcce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arttu=20Per=C3=A4l=C3=A4?= Date: Sat, 1 Jul 2023 08:52:23 +0300 Subject: [PATCH 178/252] Specify required relationship fields in OpenAPI schema (#1163) --- CHANGELOG.md | 1 + example/tests/__snapshots__/test_openapi.ambr | 10 ++++++++++ rest_framework_json_api/schemas/openapi.py | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9dc65db..a5f86d1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ any parts of the framework not mentioned in the documentation should generally b ``` * `SerializerMethodResourceRelatedField(many=True)` relationship data now includes a meta section. +* Required relationship fields are now marked as required in the OpenAPI schema. ### Fixed diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr index fd270b40..93b8f7fb 100644 --- a/example/tests/__snapshots__/test_openapi.ambr +++ b/example/tests/__snapshots__/test_openapi.ambr @@ -587,6 +587,11 @@ "$ref": "#/components/schemas/reltoone" } }, + "required": [ + "bio", + "entries", + "comments" + ], "type": "object" }, "type": { @@ -801,6 +806,11 @@ "$ref": "#/components/schemas/reltoone" } }, + "required": [ + "bio", + "entries", + "comments" + ], "type": "object" }, "type": { diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 4a0eeb83..a0d2be0d 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -631,6 +631,15 @@ def get_request_body(self, path, method): ): # noqa E501 if "readOnly" in schema: del item_schema["properties"]["attributes"]["properties"][name] + + if "properties" in item_schema and "relationships" in item_schema["properties"]: + # No required relationships for PATCH + if ( + method in ["PATCH", "PUT"] + and "required" in item_schema["properties"]["relationships"] + ): + del item_schema["properties"]["relationships"]["required"] + return { "content": { ct: { @@ -653,6 +662,7 @@ def map_serializer(self, serializer): # TODO: remove attributes, etc. for relationshipView?? required = [] attributes = {} + relationships_required = [] relationships = {} for field in serializer.fields.values(): @@ -668,11 +678,15 @@ def map_serializer(self, serializer): ManySerializerMethodResourceRelatedField, ), ): + if field.required: + relationships_required.append(format_field_name(field.field_name)) relationships[format_field_name(field.field_name)] = { "$ref": "#/components/schemas/reltomany" } continue if isinstance(field, serializers.RelatedField): + if field.required: + relationships_required.append(format_field_name(field.field_name)) relationships[format_field_name(field.field_name)] = { "$ref": "#/components/schemas/reltoone" } @@ -727,6 +741,10 @@ def map_serializer(self, serializer): "type": "object", "properties": relationships, } + if relationships_required: + result["properties"]["relationships"][ + "required" + ] = relationships_required return result def _add_async_response(self, operation): From 39a9c92274e795bc3318ca64a1aa7c7cdc71a5fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arttu=20Per=C3=A4l=C3=A4?= Date: Mon, 3 Jul 2023 17:07:29 +0300 Subject: [PATCH 179/252] Add HTTP 400 Bad Request as a possible generic error response (#1165) --- CHANGELOG.md | 1 + example/tests/__snapshots__/test_openapi.ambr | 60 +++++++++++++++++++ rest_framework_json_api/schemas/openapi.py | 1 + 3 files changed, 62 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5f86d1d..05ebecd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ any parts of the framework not mentioned in the documentation should generally b * Added support for Python 3.11. * Added support for Django 4.2. +* Added `400 Bad Request` as a possible error response in the OpenAPI schema. ### Changed diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr index 93b8f7fb..28cb78f0 100644 --- a/example/tests/__snapshots__/test_openapi.ambr +++ b/example/tests/__snapshots__/test_openapi.ambr @@ -38,6 +38,16 @@ "204": { "description": "[no content](https://jsonapi.org/format/#crud-deleting-responses-204)" }, + "400": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "bad request" + }, "401": { "content": { "application/vnd.api+json": { @@ -204,6 +214,16 @@ }, "description": "update/authors/{id}" }, + "400": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "bad request" + }, "401": { "content": { "application/vnd.api+json": { @@ -349,6 +369,16 @@ }, "description": "retrieve/authors/{id}/" }, + "400": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "bad request" + }, "401": { "content": { "application/vnd.api+json": { @@ -486,6 +516,16 @@ }, "description": "List/authors/" }, + "400": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "bad request" + }, "401": { "content": { "application/vnd.api+json": { @@ -664,6 +704,16 @@ "204": { "description": "[Created](https://jsonapi.org/format/#crud-creating-responses-204) with the supplied `id`. No other changes from what was POSTed." }, + "400": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "bad request" + }, "401": { "content": { "application/vnd.api+json": { @@ -1231,6 +1281,16 @@ }, "description": "List/authors/" }, + "400": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "bad request" + }, "401": { "content": { "application/vnd.api+json": { diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index a0d2be0d..9c1dd620 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -779,6 +779,7 @@ def _add_generic_failure_responses(self, operation): Add generic failure response(s) to operation """ for code, reason in [ + ("400", "bad request"), ("401", "not authorized"), ]: operation["responses"][code] = self._failure_response(reason) From 126cd9fa6fa56a62fb4514089073060cc20c5dbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arttu=20Per=C3=A4l=C3=A4?= Date: Wed, 5 Jul 2023 22:12:26 +0300 Subject: [PATCH 180/252] Add additionalProperties to includes' attributes and relationships (#1164) Create new schema for objects in the included array This allows documenting the "attributes" and "relationships" objects to possibly have additional properties. --- CHANGELOG.md | 2 + example/tests/__snapshots__/test_openapi.ambr | 40 ++++++++++++++++--- rest_framework_json_api/schemas/openapi.py | 23 ++++++++++- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05ebecd8..82c32eec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,8 @@ any parts of the framework not mentioned in the documentation should generally b * `SerializerMethodResourceRelatedField(many=True)` relationship data now includes a meta section. * Required relationship fields are now marked as required in the OpenAPI schema. +* Objects in the included array are documented in the OpenAPI schema to possibly have additional + properties in their "attributes" and "relationships" objects. ### Fixed diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr index 28cb78f0..18aec07f 100644 --- a/example/tests/__snapshots__/test_openapi.ambr +++ b/example/tests/__snapshots__/test_openapi.ambr @@ -185,7 +185,7 @@ }, "included": { "items": { - "$ref": "#/components/schemas/resource" + "$ref": "#/components/schemas/include" }, "type": "array", "uniqueItems": true @@ -340,7 +340,7 @@ }, "included": { "items": { - "$ref": "#/components/schemas/resource" + "$ref": "#/components/schemas/include" }, "type": "array", "uniqueItems": true @@ -487,7 +487,7 @@ }, "included": { "items": { - "$ref": "#/components/schemas/resource" + "$ref": "#/components/schemas/include" }, "type": "array", "uniqueItems": true @@ -662,7 +662,7 @@ }, "included": { "items": { - "$ref": "#/components/schemas/resource" + "$ref": "#/components/schemas/include" }, "type": "array", "uniqueItems": true @@ -962,6 +962,36 @@ "description": "Each resource object\u2019s type and id pair MUST [identify](https://jsonapi.org/format/#document-resource-object-identification) a single, unique resource.", "type": "string" }, + "include": { + "additionalProperties": false, + "properties": { + "attributes": { + "additionalProperties": true, + "type": "object" + }, + "id": { + "$ref": "#/components/schemas/id" + }, + "links": { + "$ref": "#/components/schemas/links" + }, + "meta": { + "$ref": "#/components/schemas/meta" + }, + "relationships": { + "additionalProperties": true, + "type": "object" + }, + "type": { + "$ref": "#/components/schemas/type" + } + }, + "required": [ + "type", + "id" + ], + "type": "object" + }, "jsonapi": { "additionalProperties": false, "description": "The server's implementation", @@ -1252,7 +1282,7 @@ }, "included": { "items": { - "$ref": "#/components/schemas/resource" + "$ref": "#/components/schemas/include" }, "type": "array", "uniqueItems": true diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 9c1dd620..cc8ae233 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -49,6 +49,27 @@ class SchemaGenerator(drf_openapi.SchemaGenerator): "meta": {"$ref": "#/components/schemas/meta"}, }, }, + "include": { + "type": "object", + "required": ["type", "id"], + "additionalProperties": False, + "properties": { + "type": {"$ref": "#/components/schemas/type"}, + "id": {"$ref": "#/components/schemas/id"}, + "attributes": { + "type": "object", + "additionalProperties": True, + # ... + }, + "relationships": { + "type": "object", + "additionalProperties": True, + # ... + }, + "links": {"$ref": "#/components/schemas/links"}, + "meta": {"$ref": "#/components/schemas/meta"}, + }, + }, "link": { "oneOf": [ { @@ -531,7 +552,7 @@ def _get_toplevel_200_response(self, operation, path, method, collection=True): "included": { "type": "array", "uniqueItems": True, - "items": {"$ref": "#/components/schemas/resource"}, + "items": {"$ref": "#/components/schemas/include"}, }, "links": { "description": "Link members related to primary data", From b7dd5851b9380e48598870c277f525c8e5d685f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arttu=20Per=C3=A4l=C3=A4?= Date: Thu, 13 Jul 2023 18:37:53 +0300 Subject: [PATCH 181/252] Exclude callable field defaults from the OpenAPI schema (#1167) * Exclude callable field defaults from the OpenAPI schema * Organize callable default test in line with project standards --- CHANGELOG.md | 1 + rest_framework_json_api/schemas/openapi.py | 2 +- tests/schemas/test_openapi.py | 11 +++++++++++ tests/serializers.py | 19 +++++++++++++------ 4 files changed, 26 insertions(+), 7 deletions(-) create mode 100644 tests/schemas/test_openapi.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 82c32eec..efbe93d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ any parts of the framework not mentioned in the documentation should generally b * Fixed "id" field being added to /data/attributes in the OpenAPI schema when it is not rendered there. * Fixed `SerializerMethodResourceRelatedField(many=True)` fields being given a "reltoone" schema reference instead of "reltomany". +* Callable field default values are excluded from the OpenAPI schema, as they don't resolve to YAML data types. ## [6.0.0] - 2022-09-24 diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index cc8ae233..52f08da6 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -727,7 +727,7 @@ def map_serializer(self, serializer): schema["writeOnly"] = True if field.allow_null: schema["nullable"] = True - if field.default and field.default != empty: + if field.default and field.default != empty and not callable(field.default): schema["default"] = field.default if field.help_text: # Ensure django gettext_lazy is rendered correctly diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py new file mode 100644 index 00000000..427f18fc --- /dev/null +++ b/tests/schemas/test_openapi.py @@ -0,0 +1,11 @@ +from rest_framework_json_api.schemas.openapi import AutoSchema +from tests.serializers import CallableDefaultSerializer + + +class TestAutoSchema: + def test_schema_callable_default(self): + inspector = AutoSchema() + result = inspector.map_serializer(CallableDefaultSerializer()) + assert result["properties"]["attributes"]["properties"]["field"] == { + "type": "string", + } diff --git a/tests/serializers.py b/tests/serializers.py index 8894a211..e0f7c03b 100644 --- a/tests/serializers.py +++ b/tests/serializers.py @@ -1,5 +1,5 @@ +from rest_framework_json_api import serializers from rest_framework_json_api.relations import ResourceRelatedField -from rest_framework_json_api.serializers import ModelSerializer from tests.models import ( BasicModel, ForeignKeySource, @@ -9,13 +9,13 @@ ) -class BasicModelSerializer(ModelSerializer): +class BasicModelSerializer(serializers.ModelSerializer): class Meta: fields = ("text",) model = BasicModel -class ForeignKeySourceSerializer(ModelSerializer): +class ForeignKeySourceSerializer(serializers.ModelSerializer): target = ResourceRelatedField(queryset=ForeignKeyTarget.objects) class Meta: @@ -23,7 +23,7 @@ class Meta: fields = ("target",) -class ManyToManySourceSerializer(ModelSerializer): +class ManyToManySourceSerializer(serializers.ModelSerializer): targets = ResourceRelatedField(many=True, queryset=ManyToManyTarget.objects) class Meta: @@ -31,14 +31,21 @@ class Meta: fields = ("targets",) -class ManyToManyTargetSerializer(ModelSerializer): +class ManyToManyTargetSerializer(serializers.ModelSerializer): class Meta: model = ManyToManyTarget -class ManyToManySourceReadOnlySerializer(ModelSerializer): +class ManyToManySourceReadOnlySerializer(serializers.ModelSerializer): targets = ResourceRelatedField(many=True, read_only=True) class Meta: model = ManyToManySource fields = ("targets",) + + +class CallableDefaultSerializer(serializers.Serializer): + field = serializers.CharField(default=serializers.CreateOnlyDefault("default")) + + class Meta: + fields = ("field",) From cd5f17970123400a2c5b98cfbb11f940d2cf09a9 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 25 Aug 2023 15:39:19 +0400 Subject: [PATCH 182/252] Release 6.1.0 (#1173) --- CHANGELOG.md | 4 ++-- rest_framework_json_api/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efbe93d2..243f13b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [6.1.0] - 2023-08-25 ### Added @@ -48,7 +48,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Fixed -* Refactored handling of the `sort` query parameter to fix duplicate declaration in the generated schema definition +* Refactored handling of the `sort` query parameter to fix duplicate declaration in the generated OpenAPI schema definition * Non-field serializer errors are given a source.pointer value of "/data". * Fixed "id" field being added to /data/attributes in the OpenAPI schema when it is not rendered there. * Fixed `SerializerMethodResourceRelatedField(many=True)` fields being given diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index dd0ae050..96e6db9d 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,5 +1,5 @@ __title__ = "djangorestframework-jsonapi" -__version__ = "6.0.0" +__version__ = "6.1.0" __author__ = "" __license__ = "BSD" __copyright__ = "" From ed5a999ace76d967f96731ccc799c42cdc72f426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arttu=20Per=C3=A4l=C3=A4?= Date: Tue, 19 Sep 2023 08:10:35 +0300 Subject: [PATCH 183/252] Fix schema generation for nested serializers (#1177) * Fix Serializer schema generation when used as a ListField child * Fix Serializer schema generation when used in another serializer --- CHANGELOG.md | 6 +++ example/factories.py | 22 ++++++++++ example/migrations/0013_questionnaire.py | 28 +++++++++++++ .../migrations/0014_questionnaire_metadata.py | 18 +++++++++ example/models.py | 6 +++ example/serializers.py | 20 ++++++++++ example/tests/conftest.py | 2 + example/tests/test_openapi.py | 40 +++++++++++++++++++ example/tests/test_serializers.py | 33 +++++++++++++++ example/urls.py | 2 + example/urls_test.py | 2 + example/views.py | 7 ++++ rest_framework_json_api/schemas/openapi.py | 8 ++++ 13 files changed, 194 insertions(+) create mode 100644 example/migrations/0013_questionnaire.py create mode 100644 example/migrations/0014_questionnaire_metadata.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 243f13b3..7a3b4a57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## [Unreleased] + +### Fixed + +* Fixed OpenAPI schema generation for `Serializer` when used inside another `Serializer` or as a child of `ListField`. + ## [6.1.0] - 2023-08-25 ### Added diff --git a/example/factories.py b/example/factories.py index 4ca1e0b1..37340df4 100644 --- a/example/factories.py +++ b/example/factories.py @@ -12,6 +12,7 @@ Company, Entry, ProjectType, + Questionnaire, ResearchProject, TaggedItem, ) @@ -140,3 +141,24 @@ def future_projects(self, create, extracted, **kwargs): if extracted: for project in extracted: self.future_projects.add(project) + + +class QuestionnaireFactory(factory.django.DjangoModelFactory): + class Meta: + model = Questionnaire + + name = factory.LazyAttribute(lambda x: faker.text()) + questions = [ + { + "text": "What is your name?", + "required": True, + }, + { + "text": "What is your quest?", + "required": False, + }, + { + "text": "What is the air-speed velocity of an unladen swallow?", + }, + ] + metadata = {"author": "Bridgekeeper"} diff --git a/example/migrations/0013_questionnaire.py b/example/migrations/0013_questionnaire.py new file mode 100644 index 00000000..0a3b7cd2 --- /dev/null +++ b/example/migrations/0013_questionnaire.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.5 on 2023-09-07 02:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("example", "0012_author_full_name"), + ] + + operations = [ + migrations.CreateModel( + name="Questionnaire", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), + ("questions", models.JSONField()), + ], + ), + ] diff --git a/example/migrations/0014_questionnaire_metadata.py b/example/migrations/0014_questionnaire_metadata.py new file mode 100644 index 00000000..e320ebb8 --- /dev/null +++ b/example/migrations/0014_questionnaire_metadata.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.5 on 2023-09-12 07:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("example", "0013_questionnaire"), + ] + + operations = [ + migrations.AddField( + model_name="questionnaire", + name="metadata", + field=models.JSONField(default={}), + preserve_default=False, + ), + ] diff --git a/example/models.py b/example/models.py index 8fc86c22..35a15a8e 100644 --- a/example/models.py +++ b/example/models.py @@ -180,3 +180,9 @@ class Company(models.Model): def __str__(self): return self.name + + +class Questionnaire(models.Model): + name = models.CharField(max_length=100) + questions = models.JSONField() + metadata = models.JSONField() diff --git a/example/serializers.py b/example/serializers.py index 3d94e6cc..94fe8556 100644 --- a/example/serializers.py +++ b/example/serializers.py @@ -18,6 +18,7 @@ LabResults, Project, ProjectType, + Questionnaire, ResearchProject, TaggedItem, ) @@ -421,3 +422,22 @@ class CompanySerializer(serializers.ModelSerializer): class Meta: model = Company fields = "__all__" + + +class QuestionSerializer(serializers.Serializer): + text = serializers.CharField() + required = serializers.BooleanField(default=False) + + +class QuestionnaireMetadataSerializer(serializers.Serializer): + author = serializers.CharField() + producer = serializers.CharField(default=None) + + +class QuestionnaireSerializer(serializers.ModelSerializer): + questions = serializers.ListField(child=QuestionSerializer()) + metadata = QuestionnaireMetadataSerializer() + + class Meta: + model = Questionnaire + fields = ("name", "questions", "metadata") diff --git a/example/tests/conftest.py b/example/tests/conftest.py index 22ab6bd1..6e4b05ba 100644 --- a/example/tests/conftest.py +++ b/example/tests/conftest.py @@ -12,6 +12,7 @@ CommentFactory, CompanyFactory, EntryFactory, + QuestionnaireFactory, ResearchProjectFactory, TaggedItemFactory, ) @@ -27,6 +28,7 @@ register(ArtProjectFactory) register(ResearchProjectFactory) register(CompanyFactory) +register(QuestionnaireFactory) @pytest.fixture diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index 5710da2a..2333dd6a 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -125,6 +125,46 @@ def test_schema_id_field(): assert "id" not in company_properties["attributes"]["properties"] +def test_schema_subserializers(): + """Schema for child Serializers reflects the actual response structure.""" + patterns = [ + re_path( + "^questionnaires/?$", views.QuestionnaireViewset.as_view({"get": "list"}) + ), + ] + generator = SchemaGenerator(patterns=patterns) + + request = create_request("/") + schema = generator.get_schema(request=request) + + assert { + "type": "object", + "properties": { + "metadata": { + "type": "object", + "properties": { + "author": {"type": "string"}, + "producer": {"type": "string"}, + }, + "required": ["author"], + }, + "questions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": {"type": "string"}, + "required": {"type": "boolean", "default": False}, + }, + "required": ["text"], + }, + }, + "name": {"type": "string", "maxLength": 100}, + }, + "required": ["name", "questions", "metadata"], + } == schema["components"]["schemas"]["Questionnaire"]["properties"]["attributes"] + + def test_schema_parameters_include(): """Include paramater is only used when serializer defines included_serializers.""" patterns = [ diff --git a/example/tests/test_serializers.py b/example/tests/test_serializers.py index 37f50b53..9ad487e5 100644 --- a/example/tests/test_serializers.py +++ b/example/tests/test_serializers.py @@ -224,6 +224,39 @@ def test_model_serializer_with_implicit_fields(self, comment, client): assert response.status_code == 200 assert expected == response.json() + def test_model_serializer_with_subserializers(self, questionnaire, client): + expected = { + "data": { + "type": "questionnaires", + "id": str(questionnaire.pk), + "attributes": { + "name": questionnaire.name, + "questions": [ + { + "text": "What is your name?", + "required": True, + }, + { + "text": "What is your quest?", + "required": False, + }, + { + "text": "What is the air-speed velocity of an unladen swallow?", + "required": False, + }, + ], + "metadata": {"author": "Bridgekeeper", "producer": None}, + }, + }, + } + + response = client.get( + reverse("questionnaire-detail", kwargs={"pk": questionnaire.pk}) + ) + + assert response.status_code == 200 + assert expected == response.json() + class TestPolymorphicModelSerializer(TestCase): def setUp(self): diff --git a/example/urls.py b/example/urls.py index 3d1cf2fa..413d058d 100644 --- a/example/urls.py +++ b/example/urls.py @@ -19,6 +19,7 @@ NonPaginatedEntryViewSet, ProjectTypeViewset, ProjectViewset, + QuestionnaireViewset, ) router = routers.DefaultRouter(trailing_slash=False) @@ -32,6 +33,7 @@ router.register(r"projects", ProjectViewset) router.register(r"project-types", ProjectTypeViewset) router.register(r"lab-results", LabResultViewSet) +router.register(r"questionnaires", QuestionnaireViewset) urlpatterns = [ path("", include(router.urls)), diff --git a/example/urls_test.py b/example/urls_test.py index 92802a81..bb8fbecf 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -20,6 +20,7 @@ NonPaginatedEntryViewSet, ProjectTypeViewset, ProjectViewset, + QuestionnaireViewset, ) router = routers.DefaultRouter(trailing_slash=False) @@ -38,6 +39,7 @@ router.register(r"projects", ProjectViewset) router.register(r"project-types", ProjectTypeViewset) router.register(r"lab-results", LabResultViewSet) +router.register(r"questionnaires", QuestionnaireViewset) # for the old tests router.register(r"identities", Identity) diff --git a/example/views.py b/example/views.py index b0d92811..9c949684 100644 --- a/example/views.py +++ b/example/views.py @@ -29,6 +29,7 @@ LabResults, Project, ProjectType, + Questionnaire, ) from example.serializers import ( AuthorDetailSerializer, @@ -43,6 +44,7 @@ LabResultsSerializer, ProjectSerializer, ProjectTypeSerializer, + QuestionnaireSerializer, ) HTTP_422_UNPROCESSABLE_ENTITY = 422 @@ -292,3 +294,8 @@ class LabResultViewSet(ReadOnlyModelViewSet): "__all__": [], "author": ["author__bio", "author__entries"], } + + +class QuestionnaireViewset(ModelViewSet): + queryset = Questionnaire.objects.all() + serializer_class = QuestionnaireSerializer diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 52f08da6..c0d9ca3a 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -681,6 +681,14 @@ def map_serializer(self, serializer): and 'links'. """ # TODO: remove attributes, etc. for relationshipView?? + if isinstance( + serializer.parent, (serializers.ListField, serializers.BaseSerializer) + ): + # Return plain non-JSON:API serializer schema for serializers nested inside + # a Serializer or a ListField, as those don't use the full JSON:API + # serializer schemas. + return super().map_serializer(serializer) + required = [] attributes = {} relationships_required = [] From b75255c3e4d658f79f26c6b8ed1d08c97ca9273c Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 19 Sep 2023 17:03:53 +0400 Subject: [PATCH 184/252] Removed support for Python 3.7 (#1178) --- .github/workflows/tests.yml | 2 +- CHANGELOG.md | 6 ++++++ README.rst | 2 +- docs/getting-started.md | 2 +- setup.py | 1 - tox.ini | 4 ++-- 6 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a22301fe..f90480ac 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11"] env: PYTHON: ${{ matrix.python-version }} steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a3b4a57..36d3ba82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,14 @@ any parts of the framework not mentioned in the documentation should generally b * Fixed OpenAPI schema generation for `Serializer` when used inside another `Serializer` or as a child of `ListField`. +### Removed + +* Removed support for Python 3.7. + ## [6.1.0] - 2023-08-25 +This is the last release supporting Python 3.7. + ### Added * Added support for Python 3.11. diff --git a/README.rst b/README.rst index 82c2b08f..6708dab5 100644 --- a/README.rst +++ b/README.rst @@ -92,7 +92,7 @@ As a Django REST framework JSON:API (short DJA) we are trying to address followi Requirements ------------ -1. Python (3.7, 3.8, 3.9, 3.10, 3.11) +1. Python (3.8, 3.9, 3.10, 3.11) 2. Django (3.2, 4.0, 4.1, 4.2) 3. Django REST framework (3.13, 3.14) diff --git a/docs/getting-started.md b/docs/getting-started.md index 07798fae..e65a0ac3 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -51,7 +51,7 @@ like the following: ## Requirements -1. Python (3.7, 3.8, 3.9, 3.10, 3.11) +1. Python (3.8, 3.9, 3.10, 3.11) 2. Django (3.2, 4.0, 4.1, 4.2) 3. Django REST framework (3.13, 3.14) diff --git a/setup.py b/setup.py index 7d01a919..67d98385 100755 --- a/setup.py +++ b/setup.py @@ -86,7 +86,6 @@ def get_package_data(package): "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/tox.ini b/tox.ini index ede6f27f..9fb72d1c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{37,38,39,310}-django32-drf{313,314,master}, + py{38,39,310}-django32-drf{313,314,master}, py{38,39,310}-django40-drf{313,314,master}, py{38,39,310,311}-django41-drf{314,master}, py{38,39,310,311}-django42-drf{314,master}, @@ -50,7 +50,7 @@ deps = commands = sphinx-build -W -b html -d docs/_build/doctrees docs docs/_build/html -[testenv:py{37,38,39,310}-django32-drfmaster] +[testenv:py{38,39,310}-django32-drfmaster] ignore_outcome = true [testenv:py{38,39,310}-django40-drfmaster] From 14c1ddcc0e6153aabcfdce8eb2520fb3a54c28a5 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 21 Sep 2023 08:22:24 -0500 Subject: [PATCH 185/252] Scheduled biweekly dependency update for week 38 (#1176) * Update black from 23.3.0 to 23.9.1 * Update flake8 from 6.0.0 to 6.1.0 * Update flake8-bugbear from 23.3.23 to 23.9.16 * Update flake8-isort from 6.0.0 to 6.1.0 * Update sphinx from 6.1.3 to 7.2.6 * Update sphinx_rtd_theme from 1.2.0 to 1.3.0 * Update django-filter from 23.1 to 23.3 * Update pyyaml from 6.0 to 6.0.1 * Update factory-boy from 3.2.1 to 3.3.0 * Update faker from 18.4.0 to 19.6.1 * Update pytest from 7.3.1 to 7.4.2 * Update pytest-cov from 4.0.0 to 4.1.0 * Update syrupy from 3.0.6 to 4.5.0 * Adopt to updated versions * Use Python 3.9 for build * Removed duplicated entry in set * Increased tox base python to 3.9 --------- Co-authored-by: Oliver Sauder --- .github/workflows/tests.yml | 4 ++-- .readthedocs.yaml | 2 +- example/factories.py | 4 ++++ example/tests/__snapshots__/test_errors.ambr | 1 + example/tests/__snapshots__/test_openapi.ambr | 1 + example/tests/integration/test_polymorphism.py | 1 - requirements/requirements-codestyle.txt | 8 ++++---- requirements/requirements-documentation.txt | 4 ++-- requirements/requirements-optionals.txt | 4 ++-- requirements/requirements-testing.txt | 10 +++++----- setup.cfg | 3 +++ tox.ini | 6 +++--- 12 files changed, 28 insertions(+), 20 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f90480ac..b167fde5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -42,10 +42,10 @@ jobs: tox-env: ["black", "lint", "docs"] steps: - uses: actions/checkout@v2 - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.readthedocs.yaml b/.readthedocs.yaml index d70a3ae5..997afe3f 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -3,7 +3,7 @@ version: 2 build: os: "ubuntu-22.04" tools: - python: "3.8" + python: "3.9" sphinx: configuration: docs/conf.py diff --git a/example/factories.py b/example/factories.py index 37340df4..87698fa2 100644 --- a/example/factories.py +++ b/example/factories.py @@ -37,6 +37,7 @@ class Meta: class AuthorFactory(factory.django.DjangoModelFactory): class Meta: + skip_postgeneration_save = True model = Author name = factory.LazyAttribute(lambda x: faker.name()) @@ -49,6 +50,7 @@ class Meta: class AuthorBioFactory(factory.django.DjangoModelFactory): class Meta: model = AuthorBio + skip_postgeneration_save = True author = factory.SubFactory(AuthorFactory) body = factory.LazyAttribute(lambda x: faker.text()) @@ -69,6 +71,7 @@ class Meta: class EntryFactory(factory.django.DjangoModelFactory): class Meta: model = Entry + skip_postgeneration_save = True headline = factory.LazyAttribute(lambda x: faker.sentence(nb_words=4)) body_text = factory.LazyAttribute(lambda x: faker.text()) @@ -130,6 +133,7 @@ class Meta: class CompanyFactory(factory.django.DjangoModelFactory): class Meta: model = Company + skip_postgeneration_save = True name = factory.LazyAttribute(lambda x: faker.company()) current_project = factory.SubFactory(ArtProjectFactory) diff --git a/example/tests/__snapshots__/test_errors.ambr b/example/tests/__snapshots__/test_errors.ambr index fe872b46..5dca2771 100644 --- a/example/tests/__snapshots__/test_errors.ambr +++ b/example/tests/__snapshots__/test_errors.ambr @@ -1,3 +1,4 @@ +# serializer version: 1 # name: test_first_level_attribute_error dict({ 'errors': list([ diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr index 18aec07f..1de8057b 100644 --- a/example/tests/__snapshots__/test_openapi.ambr +++ b/example/tests/__snapshots__/test_openapi.ambr @@ -1,3 +1,4 @@ +# serializer version: 1 # name: test_delete_request ''' { diff --git a/example/tests/integration/test_polymorphism.py b/example/tests/integration/test_polymorphism.py index 116243f1..836a9046 100644 --- a/example/tests/integration/test_polymorphism.py +++ b/example/tests/integration/test_polymorphism.py @@ -50,7 +50,6 @@ def test_polymorphism_on_included_relations(single_company, client): for rel in content["data"]["relationships"]["futureProjects"]["data"] } == {"researchProjects", "artProjects"} assert {x.get("type") for x in content.get("included")} == { - "artProjects", "artProjects", "researchProjects", }, "Detail included types are incorrect" diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 8eba9fae..cc8adaed 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ -black==23.3.0 -flake8==6.0.0 -flake8-bugbear==23.3.23 -flake8-isort==6.0.0 +black==23.9.1 +flake8==6.1.0 +flake8-bugbear==23.9.16 +flake8-isort==6.1.0 isort==5.12.0 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 36e96657..8dc46ee5 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==6.1.3 -sphinx_rtd_theme==1.2.0 +Sphinx==7.2.6 +sphinx_rtd_theme==1.3.0 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index b8a8b54d..fc16b02c 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,4 +1,4 @@ -django-filter==23.1 +django-filter==23.3 django-polymorphic==3.1.0 -pyyaml==6.0 +pyyaml==6.0.1 uritemplate==4.1.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 7fac28a4..64cb57b7 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ -factory-boy==3.2.1 -Faker==18.4.0 -pytest==7.3.1 -pytest-cov==4.0.0 +factory-boy==3.3.0 +Faker==19.6.1 +pytest==7.4.2 +pytest-cov==4.1.0 pytest-django==4.5.2 pytest-factoryboy==2.5.1 -syrupy==3.0.6 +syrupy==4.5.0 diff --git a/setup.cfg b/setup.cfg index d3ea5ebc..f55ed558 100644 --- a/setup.cfg +++ b/setup.cfg @@ -68,6 +68,9 @@ filterwarnings = # can be removed once fixed in django polymorphic ignore:pkg_resources is deprecated as an API ignore:Deprecated call to `pkg_resource + # Django filter schema generation. Can be removed once we remove + # schema support + ignore:Built-in schema generation is deprecated. testpaths = example tests diff --git a/tox.ini b/tox.ini index 9fb72d1c..8d6df244 100644 --- a/tox.ini +++ b/tox.ini @@ -28,13 +28,13 @@ commands = pytest --cov --no-cov-on-fail --cov-report xml {posargs} [testenv:black] -basepython = python3.8 +basepython = python3.9 deps = -rrequirements/requirements-codestyle.txt commands = black --check . [testenv:lint] -basepython = python3.8 +basepython = python3.9 deps = -rrequirements/requirements-codestyle.txt -rrequirements/requirements-testing.txt @@ -43,7 +43,7 @@ commands = flake8 [testenv:docs] # keep in sync with .readthedocs.yml -basepython = python3.8 +basepython = python3.9 deps = -rrequirements/requirements-optionals.txt -rrequirements/requirements-documentation.txt From 1e37d00aa6a7515f3baf45aa660caf448c4585d9 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 22 Sep 2023 14:32:41 +0400 Subject: [PATCH 186/252] Increased minimum Python version (#1179) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 67d98385..c7b3d974 100755 --- a/setup.py +++ b/setup.py @@ -105,6 +105,6 @@ def get_package_data(package): "openapi": ["pyyaml>=5.4", "uritemplate>=3.0.1"], }, setup_requires=wheel, - python_requires=">=3.7", + python_requires=">=3.8", zip_safe=False, ) From ec67f0cb9120830d07c8b47c4f41935fac802c34 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sun, 24 Sep 2023 22:38:47 +0400 Subject: [PATCH 187/252] Removed support for Django 4.0 (#1180) --- CHANGELOG.md | 3 ++- README.rst | 2 +- docs/getting-started.md | 2 +- tox.ini | 5 ----- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36d3ba82..83b2e62d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,10 +17,11 @@ any parts of the framework not mentioned in the documentation should generally b ### Removed * Removed support for Python 3.7. +* Removed support for Django 4.0. ## [6.1.0] - 2023-08-25 -This is the last release supporting Python 3.7. +This is the last release supporting Python 3.7 and Django 4.0. ### Added diff --git a/README.rst b/README.rst index 6708dab5..f156f3ab 100644 --- a/README.rst +++ b/README.rst @@ -93,7 +93,7 @@ Requirements ------------ 1. Python (3.8, 3.9, 3.10, 3.11) -2. Django (3.2, 4.0, 4.1, 4.2) +2. Django (3.2, 4.1, 4.2) 3. Django REST framework (3.13, 3.14) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index e65a0ac3..f1ab7d4d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.8, 3.9, 3.10, 3.11) -2. Django (3.2, 4.0, 4.1, 4.2) +2. Django (3.2, 4.1, 4.2) 3. Django REST framework (3.13, 3.14) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/tox.ini b/tox.ini index 8d6df244..a4749185 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,6 @@ [tox] envlist = py{38,39,310}-django32-drf{313,314,master}, - py{38,39,310}-django40-drf{313,314,master}, py{38,39,310,311}-django41-drf{314,master}, py{38,39,310,311}-django42-drf{314,master}, black, @@ -11,7 +10,6 @@ envlist = [testenv] deps = django32: Django>=3.2,<3.3 - django40: Django>=4.0,<4.1 django41: Django>=4.1,<4.2 django42: Django>=4.2,<4.3 drf313: djangorestframework>=3.13,<3.14 @@ -53,9 +51,6 @@ commands = [testenv:py{38,39,310}-django32-drfmaster] ignore_outcome = true -[testenv:py{38,39,310}-django40-drfmaster] -ignore_outcome = true - [testenv:py{38,39,310,311}-django41-drfmaster] ignore_outcome = true From a45c475a772a3e9d3cf32694ee30dc2880ee2d6c Mon Sep 17 00:00:00 2001 From: "Panagiotis H.M. Issaris" Date: Wed, 11 Oct 2023 13:56:42 +0200 Subject: [PATCH 188/252] Add various links on the PyPI project page (#1183) * Add various links on the PyPI project page Add links to the documentation, change log, source code and issue tracker to the PyPI project page. * Split strings as the linter expects max line lengths of 88 * Reformatted project url links --------- Co-authored-by: Oliver Sauder --- setup.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/setup.py b/setup.py index c7b3d974..eeb88b97 100755 --- a/setup.py +++ b/setup.py @@ -94,6 +94,15 @@ def get_package_data(package): "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", ], + project_urls={ + "Documentation": "https://django-rest-framework-json-api.readthedocs.org/", + "Changelog": ( + "https://github.com/django-json-api/django-rest-framework-json-api/" + "blob/main/CHANGELOG.md" + ), + "Source": "https://github.com/django-json-api/django-rest-framework-json-api", + "Tracker": "https://github.com/django-json-api/django-rest-framework-json-api/issues", + }, install_requires=[ "inflection>=0.5.0", "djangorestframework>=3.13", From ffc3ca34611f8e24f5cad76b1ef5abd68f2f3eb1 Mon Sep 17 00:00:00 2001 From: Antoine Auger Date: Thu, 12 Oct 2023 08:09:32 +0200 Subject: [PATCH 189/252] Ensure that ModelSerializer fields are in same order than in DRF (#1182) * fix(ModelSerializer): preserve field order like DRF * test(ModelSerializer): add test_get_field_names * docs: update changelog and authors files --------- Co-authored-by: Oliver Sauder --- AUTHORS | 1 + CHANGELOG.md | 1 + rest_framework_json_api/serializers.py | 10 ++++---- tests/test_serializers.py | 34 ++++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/AUTHORS b/AUTHORS index fbd3fe6e..7f4a8237 100644 --- a/AUTHORS +++ b/AUTHORS @@ -2,6 +2,7 @@ Adam Wróbel Adam Ziolkowski Alan Crosswell Alex Seidmann +Antoine Auger Anton Shutik Arttu Perälä Ashley Loewen diff --git a/CHANGELOG.md b/CHANGELOG.md index 83b2e62d..3e69577b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Fixed * Fixed OpenAPI schema generation for `Serializer` when used inside another `Serializer` or as a child of `ListField`. +* `ModelSerializer` fields are now returned in the same order than DRF ### Removed diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 4b619ac4..c680f60a 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -309,11 +309,11 @@ def get_field_names(self, declared_fields, info): """ meta_fields = getattr(self.Meta, "meta_fields", []) - declared = {} - for field_name in set(declared_fields.keys()): - field = declared_fields[field_name] - if field_name not in meta_fields: - declared[field_name] = field + declared = { + field_name: field + for field_name, field in declared_fields.items() + if field_name not in meta_fields + } fields = super().get_field_names(declared, info) return list(fields) + list(getattr(self.Meta, "meta_fields", list())) diff --git a/tests/test_serializers.py b/tests/test_serializers.py index e1b14ed8..9d4200a3 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -1,5 +1,6 @@ import pytest from django.db import models +from rest_framework.utils import model_meta from rest_framework_json_api import serializers from tests.models import DJAModel, ManyToManyTarget @@ -50,3 +51,36 @@ class ReservedFieldNamesSerializer(serializers.Serializer): "ReservedFieldNamesSerializer uses following reserved field name(s) which is " "not allowed: meta, results" ) + + +def test_get_field_names(): + class MyTestModel(DJAModel): + verified = models.BooleanField(default=False) + uuid = models.UUIDField() + + class AnotherSerializer(serializers.Serializer): + ref_id = serializers.CharField() + reference_string = serializers.CharField() + + class MyTestModelSerializer(AnotherSerializer, serializers.ModelSerializer): + an_extra_field = serializers.CharField() + + class Meta: + model = MyTestModel + fields = "__all__" + extra_kwargs = { + "verified": {"read_only": True}, + } + + # Same logic than in DRF get_fields() method + declared_fields = MyTestModelSerializer._declared_fields + info = model_meta.get_field_info(MyTestModel) + + assert MyTestModelSerializer().get_field_names(declared_fields, info) == [ + "id", + "ref_id", + "reference_string", + "an_extra_field", + "verified", + "uuid", + ] From 22a8a7ee00d7df162de99c57afb700cb62101f7a Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 19 Oct 2023 09:11:08 -0500 Subject: [PATCH 190/252] Scheduled biweekly dependency update for week 42 (#1184) * Update faker from 19.6.1 to 19.10.0 * Update pytest-factoryboy from 2.5.1 to 2.6.0 --- requirements/requirements-testing.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 64cb57b7..fad665ae 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.3.0 -Faker==19.6.1 +Faker==19.10.0 pytest==7.4.2 pytest-cov==4.1.0 pytest-django==4.5.2 -pytest-factoryboy==2.5.1 +pytest-factoryboy==2.6.0 syrupy==4.5.0 From 2752093345c42b2a15f786417781cb06004042b1 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 6 Nov 2023 11:23:01 -0500 Subject: [PATCH 191/252] Scheduled biweekly dependency update for week 45 (#1186) * Update black from 23.9.1 to 23.10.1 * Update flake8-isort from 6.1.0 to 6.1.1 * Update faker from 19.10.0 to 19.13.0 * Update pytest from 7.4.2 to 7.4.3 * Update pytest-django from 4.5.2 to 4.6.0 * Update syrupy from 4.5.0 to 4.6.0 --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-testing.txt | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index cc8adaed..6d848f66 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ -black==23.9.1 +black==23.10.1 flake8==6.1.0 flake8-bugbear==23.9.16 -flake8-isort==6.1.0 +flake8-isort==6.1.1 isort==5.12.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index fad665ae..fab46118 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.3.0 -Faker==19.10.0 -pytest==7.4.2 +Faker==19.13.0 +pytest==7.4.3 pytest-cov==4.1.0 -pytest-django==4.5.2 +pytest-django==4.6.0 pytest-factoryboy==2.6.0 -syrupy==4.5.0 +syrupy==4.6.0 From 4d27bbd69ca056538077f194377e9bc58b994105 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 20 Nov 2023 11:23:51 -0500 Subject: [PATCH 192/252] Scheduled biweekly dependency update for week 47 (#1187) * Update black from 23.10.1 to 23.11.0 * Update faker from 19.13.0 to 20.0.3 * Update pytest-django from 4.6.0 to 4.7.0 --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-testing.txt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 6d848f66..9191b07a 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==23.10.1 +black==23.11.0 flake8==6.1.0 flake8-bugbear==23.9.16 flake8-isort==6.1.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index fab46118..56865900 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.3.0 -Faker==19.13.0 +Faker==20.0.3 pytest==7.4.3 pytest-cov==4.1.0 -pytest-django==4.6.0 +pytest-django==4.7.0 pytest-factoryboy==2.6.0 syrupy==4.6.0 From 027e44cc2219c9517cb6ae8ccf91e6eaf6ef01c8 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 20 Nov 2023 21:38:03 +0400 Subject: [PATCH 193/252] Added Python 3.12 support (#1185) --- .github/workflows/tests.yml | 2 +- CHANGELOG.md | 4 ++++ README.rst | 2 +- docs/getting-started.md | 2 +- requirements/requirements-optionals.txt | 5 ++++- setup.cfg | 6 +++--- setup.py | 1 + tox.ini | 4 ++-- 8 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b167fde5..17c6a9a5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] env: PYTHON: ${{ matrix.python-version }} steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e69577b..c4c9eca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ any parts of the framework not mentioned in the documentation should generally b ## [Unreleased] +### Added + +* Added support for Python 3.12 + ### Fixed * Fixed OpenAPI schema generation for `Serializer` when used inside another `Serializer` or as a child of `ListField`. diff --git a/README.rst b/README.rst index f156f3ab..59ba17da 100644 --- a/README.rst +++ b/README.rst @@ -92,7 +92,7 @@ As a Django REST framework JSON:API (short DJA) we are trying to address followi Requirements ------------ -1. Python (3.8, 3.9, 3.10, 3.11) +1. Python (3.8, 3.9, 3.10, 3.11, 3.12) 2. Django (3.2, 4.1, 4.2) 3. Django REST framework (3.13, 3.14) diff --git a/docs/getting-started.md b/docs/getting-started.md index f1ab7d4d..e1ca4abd 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -51,7 +51,7 @@ like the following: ## Requirements -1. Python (3.8, 3.9, 3.10, 3.11) +1. Python (3.8, 3.9, 3.10, 3.11, 3.12) 2. Django (3.2, 4.1, 4.2) 3. Django REST framework (3.13, 3.14) diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index fc16b02c..f01f61de 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,4 +1,7 @@ django-filter==23.3 -django-polymorphic==3.1.0 +# once next version has been released (>3.1.0) this +# should be set to pinned version again +# see https://github.com/django-polymorphic/django-polymorphic/pull/541 +django-polymorphic@git+https://github.com/django-polymorphic/django-polymorphic@master pyyaml==6.0.1 uritemplate==4.1.1 diff --git a/setup.cfg b/setup.cfg index f55ed558..a02f67e6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -65,12 +65,12 @@ filterwarnings = error::PendingDeprecationWarning # Remove when DRF is not depending on it anymore ignore:The django.utils.timezone.utc alias is deprecated. - # can be removed once fixed in django polymorphic - ignore:pkg_resources is deprecated as an API - ignore:Deprecated call to `pkg_resource # Django filter schema generation. Can be removed once we remove # schema support ignore:Built-in schema generation is deprecated. + # can be removed once django filter has released a new version including + # https://github.com/carltongibson/django-filter/pull/1623 + ignore:'pkgutil.find_loader' is deprecated and slated for removal testpaths = example tests diff --git a/setup.py b/setup.py index eeb88b97..359324fb 100755 --- a/setup.py +++ b/setup.py @@ -90,6 +90,7 @@ def get_package_data(package): "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", diff --git a/tox.ini b/tox.ini index a4749185..3943b462 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,7 @@ envlist = py{38,39,310}-django32-drf{313,314,master}, py{38,39,310,311}-django41-drf{314,master}, - py{38,39,310,311}-django42-drf{314,master}, + py{38,39,310,311,312}-django42-drf{314,master}, black, docs, lint @@ -54,5 +54,5 @@ ignore_outcome = true [testenv:py{38,39,310,311}-django41-drfmaster] ignore_outcome = true -[testenv:py{38,39,310,311}-django42-drfmaster] +[testenv:py{38,39,310,311,312}-django42-drfmaster] ignore_outcome = true From 6aaaac7b28da6b644d2dc3c39b05d2f75ee5db18 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 11 Dec 2023 06:52:05 -0800 Subject: [PATCH 194/252] Scheduled biweekly dependency update for week 49 (#1189) * Update flake8-bugbear from 23.9.16 to 23.12.2 * Update sphinx_rtd_theme from 1.3.0 to 2.0.0 * Update django-filter from 23.3 to 23.4 * Pin django-polymorphic to latest version 3.1.0 * Update faker from 20.0.3 to 20.1.0 * Fix django-polymorhpic version for now * Fixing B018 useless dict --------- Co-authored-by: Oliver Sauder --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-documentation.txt | 2 +- requirements/requirements-optionals.txt | 4 ++-- requirements/requirements-testing.txt | 2 +- rest_framework_json_api/renderers.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 9191b07a..3ffdd922 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ black==23.11.0 flake8==6.1.0 -flake8-bugbear==23.9.16 +flake8-bugbear==23.12.2 flake8-isort==6.1.1 isort==5.12.0 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 8dc46ee5..49668994 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 Sphinx==7.2.6 -sphinx_rtd_theme==1.3.0 +sphinx_rtd_theme==2.0.0 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index f01f61de..9fd6b604 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,7 +1,7 @@ -django-filter==23.3 +django-filter==23.4 # once next version has been released (>3.1.0) this # should be set to pinned version again # see https://github.com/django-polymorphic/django-polymorphic/pull/541 -django-polymorphic@git+https://github.com/django-polymorphic/django-polymorphic@master +django-polymorphic@git+https://github.com/django-polymorphic/django-polymorphic@master # pyup: ignore pyyaml==6.0.1 uritemplate==4.1.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 56865900..eeb2dffc 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,5 +1,5 @@ factory-boy==3.3.0 -Faker==20.0.3 +Faker==20.1.0 pytest==7.4.3 pytest-cov==4.1.0 pytest-django==4.7.0 diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 9a0d9c0c..b065af5b 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -477,7 +477,7 @@ def render_relationship_view( render_data = {"data": data} links = view.get_links() if links: - render_data.update({"links": links}), + render_data["links"] = links return super().render(render_data, accepted_media_type, renderer_context) def render_errors(self, data, accepted_media_type=None, renderer_context=None): From c566235ece195a31a1fe2f114bdadcd1b8992450 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 18 Dec 2023 11:46:35 -0800 Subject: [PATCH 195/252] Scheduled biweekly dependency update for week 51 (#1192) * Update black from 23.11.0 to 23.12.0 * Update isort from 5.12.0 to 5.13.2 * Update django-filter from 23.4 to 23.5 * Update faker from 20.1.0 to 21.0.0 --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-optionals.txt | 2 +- requirements/requirements-testing.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 3ffdd922..4d684cc1 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ -black==23.11.0 +black==23.12.0 flake8==6.1.0 flake8-bugbear==23.12.2 flake8-isort==6.1.1 -isort==5.12.0 +isort==5.13.2 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 9fd6b604..0e4a92da 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,4 +1,4 @@ -django-filter==23.4 +django-filter==23.5 # once next version has been released (>3.1.0) this # should be set to pinned version again # see https://github.com/django-polymorphic/django-polymorphic/pull/541 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index eeb2dffc..42cb2ba0 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,5 +1,5 @@ factory-boy==3.3.0 -Faker==20.1.0 +Faker==21.0.0 pytest==7.4.3 pytest-cov==4.1.0 pytest-django==4.7.0 From 4d4dc37f918d1b31052bee57136f620da6f1738e Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 27 Dec 2023 15:52:43 +0400 Subject: [PATCH 196/252] Added support for Django 5.0 (#1195) --- CHANGELOG.md | 1 + README.rst | 2 +- docs/getting-started.md | 2 +- tox.ini | 5 +++++ 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4c9eca0..9664124c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Added * Added support for Python 3.12 +* Added support for Django 5.0 ### Fixed diff --git a/README.rst b/README.rst index 59ba17da..6292b387 100644 --- a/README.rst +++ b/README.rst @@ -93,7 +93,7 @@ Requirements ------------ 1. Python (3.8, 3.9, 3.10, 3.11, 3.12) -2. Django (3.2, 4.1, 4.2) +2. Django (3.2, 4.1, 4.2, 5.0) 3. Django REST framework (3.13, 3.14) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index e1ca4abd..10a1ec41 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.8, 3.9, 3.10, 3.11, 3.12) -2. Django (3.2, 4.1, 4.2) +2. Django (3.2, 4.1, 4.2, 5.0) 3. Django REST framework (3.13, 3.14) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/tox.ini b/tox.ini index 3943b462..f7f83820 100644 --- a/tox.ini +++ b/tox.ini @@ -3,6 +3,7 @@ envlist = py{38,39,310}-django32-drf{313,314,master}, py{38,39,310,311}-django41-drf{314,master}, py{38,39,310,311,312}-django42-drf{314,master}, + py{310,311,312}-django50-drf{314,master}, black, docs, lint @@ -12,6 +13,7 @@ deps = django32: Django>=3.2,<3.3 django41: Django>=4.1,<4.2 django42: Django>=4.2,<4.3 + django50: Django>=5.0,<5.1 drf313: djangorestframework>=3.13,<3.14 drf314: djangorestframework>=3.14,<3.15 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip @@ -56,3 +58,6 @@ ignore_outcome = true [testenv:py{38,39,310,311,312}-django42-drfmaster] ignore_outcome = true + +[testenv:py{310,311,312}-django50-drfmaster] +ignore_outcome = true From 36c92a373a5c48cf318a31c81bf8873af30a9a26 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 28 Dec 2023 20:58:18 +0400 Subject: [PATCH 197/252] Avoided that an empty attributes dict is rendered (#1196) --- CHANGELOG.md | 2 ++ example/tests/unit/test_renderers.py | 4 +++- rest_framework_json_api/renderers.py | 4 +++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9664124c..f64e70ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ any parts of the framework not mentioned in the documentation should generally b * Fixed OpenAPI schema generation for `Serializer` when used inside another `Serializer` or as a child of `ListField`. * `ModelSerializer` fields are now returned in the same order than DRF +* Avoided that an empty attributes dict is rendered in case serializer does not + provide any attribute fields. ### Removed diff --git a/example/tests/unit/test_renderers.py b/example/tests/unit/test_renderers.py index 00eaf28b..f34ce8d6 100644 --- a/example/tests/unit/test_renderers.py +++ b/example/tests/unit/test_renderers.py @@ -126,7 +126,7 @@ class WriteonlyTestSerializer(serializers.ModelSerializer): class Meta: model = Entry - fields = ("comments", "rating") + fields = ("headline", "comments", "rating") class WriteOnlyDummyTestViewSet(views.ReadOnlyModelViewSet): queryset = Entry.objects.all() @@ -136,6 +136,7 @@ class WriteOnlyDummyTestViewSet(views.ReadOnlyModelViewSet): result = json.loads(rendered.decode()) assert "rating" not in result["data"]["attributes"] + assert "headline" in result["data"]["attributes"] assert "relationships" not in result["data"] @@ -153,6 +154,7 @@ class EmptyRelationshipViewSet(views.ReadOnlyModelViewSet): rendered = render_dummy_test_serialized_view(EmptyRelationshipViewSet, Author()) result = json.loads(rendered.decode()) + assert "attributes" not in result["data"] assert "relationships" in result["data"] assert "bio" in result["data"]["relationships"] assert result["data"]["relationships"]["bio"] == {"data": None} diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index b065af5b..0664f73a 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -452,8 +452,10 @@ def build_json_resource_obj( resource_data = { "type": resource_name, "id": utils.get_resource_id(resource_instance, resource), - "attributes": cls.extract_attributes(fields, resource), } + attributes = cls.extract_attributes(fields, resource) + if attributes: + resource_data["attributes"] = attributes relationships = cls.extract_relationships(fields, resource, resource_instance) if relationships: resource_data["relationships"] = relationships From b9a73d39e1efe5cc000e55a5929db9d0929b36ff Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 15 Jan 2024 08:13:08 -0800 Subject: [PATCH 198/252] Scheduled biweekly dependency update for week 02 (#1198) * Update black from 23.12.0 to 23.12.1 * Update flake8 from 6.1.0 to 7.0.0 * Update faker from 21.0.0 to 22.2.0 * Update pytest from 7.4.3 to 7.4.4 --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-testing.txt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 4d684cc1..656f8aab 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ -black==23.12.0 -flake8==6.1.0 +black==23.12.1 +flake8==7.0.0 flake8-bugbear==23.12.2 flake8-isort==6.1.1 isort==5.13.2 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 42cb2ba0..b4595383 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,6 +1,6 @@ factory-boy==3.3.0 -Faker==21.0.0 -pytest==7.4.3 +Faker==22.2.0 +pytest==7.4.4 pytest-cov==4.1.0 pytest-django==4.7.0 pytest-factoryboy==2.6.0 From ca45a2b448a580d5370ed3afbba9e8e221cfa1e9 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 5 Mar 2024 02:15:57 -0800 Subject: [PATCH 199/252] Scheduled biweekly dependency update for week 09 (#1203) * Update black from 23.12.1 to 24.2.0 * Update flake8-bugbear from 23.12.2 to 24.2.6 * Update twine from 4.0.2 to 5.0.0 * Update faker from 22.2.0 to 23.3.0 * Update pytest from 7.4.4 to 8.1.0 * Update pytest-django from 4.7.0 to 4.8.0 * Update syrupy from 4.6.0 to 4.6.1 * Rerun black * Updated pytest-factoryboy to work with newest pytest --------- Co-authored-by: Oliver Sauder --- example/tests/test_sideload_resources.py | 1 + requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 10 +++++----- rest_framework_json_api/parsers.py | 1 + rest_framework_json_api/relations.py | 6 +++--- rest_framework_json_api/renderers.py | 1 + 7 files changed, 14 insertions(+), 11 deletions(-) diff --git a/example/tests/test_sideload_resources.py b/example/tests/test_sideload_resources.py index 5ca96afe..51bfb841 100644 --- a/example/tests/test_sideload_resources.py +++ b/example/tests/test_sideload_resources.py @@ -1,6 +1,7 @@ """ Test sideloading resources """ + import json from django.urls import reverse diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 656f8aab..df3e0608 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ -black==23.12.1 +black==24.2.0 flake8==7.0.0 -flake8-bugbear==23.12.2 +flake8-bugbear==24.2.6 flake8-isort==6.1.1 isort==5.13.2 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index eb2532c4..489eeb83 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==4.0.2 +twine==5.0.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index b4595383..b15dd948 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.3.0 -Faker==22.2.0 -pytest==7.4.4 +Faker==23.3.0 +pytest==8.1.0 pytest-cov==4.1.0 -pytest-django==4.7.0 -pytest-factoryboy==2.6.0 -syrupy==4.6.0 +pytest-django==4.8.0 +pytest-factoryboy==2.6.1 +syrupy==4.6.1 diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index e26f6028..a0a2aeb2 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -1,6 +1,7 @@ """ Parsers """ + from rest_framework import parsers from rest_framework.exceptions import ParseError diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index 32547253..0742c1ec 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -106,9 +106,9 @@ def get_links(self, obj=None, lookup_field="pk"): return_data = {} kwargs = { - lookup_field: getattr(obj, lookup_field) - if obj - else view.kwargs[lookup_field] + lookup_field: ( + getattr(obj, lookup_field) if obj else view.kwargs[lookup_field] + ) } field_name = self.field_name if self.field_name else self.parent.field_name diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 0664f73a..639f0b11 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -1,6 +1,7 @@ """ Renderers """ + import copy from collections import defaultdict from collections.abc import Iterable From ee86180f704b6e34855c10a38979a61c7c5d02be Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 18 Mar 2024 21:38:36 +0400 Subject: [PATCH 200/252] Use sphinx theme locally and on rtd as extension (#1207) --- docs/conf.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index fcc91d58..80a85e4b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -40,7 +40,7 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ["sphinx.ext.autodoc", "recommonmark"] +extensions = ["sphinx.ext.autodoc", "recommonmark", "sphinx_rtd_theme"] autodoc_member_order = "bysource" autodoc_inherit_docstrings = False @@ -122,15 +122,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = "default" - -on_rtd = os.environ.get("READTHEDOCS", None) == "True" - -if not on_rtd: # only import and set the theme if we're building docs locally - import sphinx_rtd_theme - - html_theme = "sphinx_rtd_theme" - html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] +html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the From 70685c96fed442e4c9554608af78236de5a32ebf Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 18 Mar 2024 22:28:36 +0400 Subject: [PATCH 201/252] Added support for Django REST framework 3.15 (#1209) * Added support for Django REST framework 3.15 As per our policy this drops support for 3.13 as we only support two versions of DRF. * Added clarification in changelog about removed compat definitions --- CHANGELOG.md | 5 ++++- README.rst | 2 +- docs/getting-started.md | 2 +- rest_framework_json_api/compat.py | 15 --------------- rest_framework_json_api/metadata.py | 2 -- rest_framework_json_api/schemas/openapi.py | 7 ++----- setup.cfg | 2 -- setup.py | 2 +- tox.ini | 10 +++++----- 9 files changed, 14 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f64e70ba..b832850f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ any parts of the framework not mentioned in the documentation should generally b * Added support for Python 3.12 * Added support for Django 5.0 +* Added support for Django REST framework 3.15 ### Fixed @@ -26,10 +27,12 @@ any parts of the framework not mentioned in the documentation should generally b * Removed support for Python 3.7. * Removed support for Django 4.0. +* Removed support for Django REST framework 3.13. +* Removed obsolete compat `NullBooleanField` and `get_reference` definitions. ## [6.1.0] - 2023-08-25 -This is the last release supporting Python 3.7 and Django 4.0. +This is the last release supporting Python 3.7, Django 4.0 and Django REST framework 3.13. ### Added diff --git a/README.rst b/README.rst index 6292b387..e283f7bf 100644 --- a/README.rst +++ b/README.rst @@ -94,7 +94,7 @@ Requirements 1. Python (3.8, 3.9, 3.10, 3.11, 3.12) 2. Django (3.2, 4.1, 4.2, 5.0) -3. Django REST framework (3.13, 3.14) +3. Django REST framework (3.14, 3.15) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index 10a1ec41..aeccd46f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -53,7 +53,7 @@ like the following: 1. Python (3.8, 3.9, 3.10, 3.11, 3.12) 2. Django (3.2, 4.1, 4.2, 5.0) -3. Django REST framework (3.13, 3.14) +3. Django REST framework (3.14, 3.15) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/rest_framework_json_api/compat.py b/rest_framework_json_api/compat.py index 96648eb0..e69de29b 100644 --- a/rest_framework_json_api/compat.py +++ b/rest_framework_json_api/compat.py @@ -1,15 +0,0 @@ -# Django REST framework 3.14 removed NullBooleanField -# can be removed once support for DRF 3.13 is dropped. -try: - from rest_framework.serializers import NullBooleanField -except ImportError: # pragma: no cover - NullBooleanField = object() - - -# Django REST framework 3.14 deprecates usage of `_get_reference`. -# can be removed once support for DRF 3.13 is dropped. -def get_reference(schema, serializer): - try: - return schema.get_reference(serializer) - except AttributeError: # pragma: no cover - return schema._get_reference(serializer) diff --git a/rest_framework_json_api/metadata.py b/rest_framework_json_api/metadata.py index c7f7b7b4..e761e919 100644 --- a/rest_framework_json_api/metadata.py +++ b/rest_framework_json_api/metadata.py @@ -5,7 +5,6 @@ from rest_framework.settings import api_settings from rest_framework.utils.field_mapping import ClassLookupDict -from rest_framework_json_api.compat import NullBooleanField from rest_framework_json_api.utils import format_field_name, get_related_resource_type @@ -22,7 +21,6 @@ class JSONAPIMetadata(SimpleMetadata): serializers.Field: "GenericField", serializers.RelatedField: "Relationship", serializers.BooleanField: "Boolean", - NullBooleanField: "Boolean", serializers.CharField: "String", serializers.URLField: "URL", serializers.EmailField: "Email", diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index c0d9ca3a..650876e6 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -7,7 +7,6 @@ from rest_framework.schemas.utils import is_list_view from rest_framework_json_api import serializers, views -from rest_framework_json_api.compat import get_reference from rest_framework_json_api.relations import ManySerializerMethodResourceRelatedField from rest_framework_json_api.utils import format_field_name @@ -533,12 +532,10 @@ def _get_toplevel_200_response(self, operation, path, method, collection=True): if collection: data = { "type": "array", - "items": get_reference( - self, self.get_response_serializer(path, method) - ), + "items": self.get_reference(self.get_response_serializer(path, method)), } else: - data = get_reference(self, self.get_response_serializer(path, method)) + data = self.get_reference(self.get_response_serializer(path, method)) return { "description": operation["operationId"], diff --git a/setup.cfg b/setup.cfg index a02f67e6..8f0317c8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -63,8 +63,6 @@ DJANGO_SETTINGS_MODULE=example.settings.test filterwarnings = error::DeprecationWarning error::PendingDeprecationWarning - # Remove when DRF is not depending on it anymore - ignore:The django.utils.timezone.utc alias is deprecated. # Django filter schema generation. Can be removed once we remove # schema support ignore:Built-in schema generation is deprecated. diff --git a/setup.py b/setup.py index 359324fb..4ff629e4 100755 --- a/setup.py +++ b/setup.py @@ -106,7 +106,7 @@ def get_package_data(package): }, install_requires=[ "inflection>=0.5.0", - "djangorestframework>=3.13", + "djangorestframework>=3.14", "django>=3.2", ], extras_require={ diff --git a/tox.ini b/tox.ini index f7f83820..e2d842db 100644 --- a/tox.ini +++ b/tox.ini @@ -1,9 +1,9 @@ [tox] envlist = - py{38,39,310}-django32-drf{313,314,master}, - py{38,39,310,311}-django41-drf{314,master}, - py{38,39,310,311,312}-django42-drf{314,master}, - py{310,311,312}-django50-drf{314,master}, + py{38,39,310}-django32-drf{314,315,master}, + py{38,39,310,311}-django41-drf{314,315,master}, + py{38,39,310,311,312}-django42-drf{314,315,master}, + py{310,311,312}-django50-drf{314,315,master}, black, docs, lint @@ -14,8 +14,8 @@ deps = django41: Django>=4.1,<4.2 django42: Django>=4.2,<4.3 django50: Django>=5.0,<5.1 - drf313: djangorestframework>=3.13,<3.14 drf314: djangorestframework>=3.14,<3.15 + drf315: djangorestframework>=3.15,<3.16 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip -rrequirements/requirements-testing.txt -rrequirements/requirements-optionals.txt From 9da7b9ffc3ad62074b18492734b90a78f4d0d542 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 19 Mar 2024 15:24:44 +0400 Subject: [PATCH 202/252] Removed support for Django 4.1 (#1210) --- CHANGELOG.md | 3 ++- README.rst | 2 +- docs/getting-started.md | 2 +- tox.ini | 5 ----- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b832850f..ee8a8f07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,12 +27,13 @@ any parts of the framework not mentioned in the documentation should generally b * Removed support for Python 3.7. * Removed support for Django 4.0. +* Removed support for Django 4.1. * Removed support for Django REST framework 3.13. * Removed obsolete compat `NullBooleanField` and `get_reference` definitions. ## [6.1.0] - 2023-08-25 -This is the last release supporting Python 3.7, Django 4.0 and Django REST framework 3.13. +This is the last release supporting Python 3.7, Django 4.0, Django 4.1 and Django REST framework 3.13. ### Added diff --git a/README.rst b/README.rst index e283f7bf..afe00f85 100644 --- a/README.rst +++ b/README.rst @@ -93,7 +93,7 @@ Requirements ------------ 1. Python (3.8, 3.9, 3.10, 3.11, 3.12) -2. Django (3.2, 4.1, 4.2, 5.0) +2. Django (3.2, 4.2, 5.0) 3. Django REST framework (3.14, 3.15) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index aeccd46f..5aec6ffe 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.8, 3.9, 3.10, 3.11, 3.12) -2. Django (3.2, 4.1, 4.2, 5.0) +2. Django (3.2, 4.2, 5.0) 3. Django REST framework (3.14, 3.15) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/tox.ini b/tox.ini index e2d842db..354e328c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,6 @@ [tox] envlist = py{38,39,310}-django32-drf{314,315,master}, - py{38,39,310,311}-django41-drf{314,315,master}, py{38,39,310,311,312}-django42-drf{314,315,master}, py{310,311,312}-django50-drf{314,315,master}, black, @@ -11,7 +10,6 @@ envlist = [testenv] deps = django32: Django>=3.2,<3.3 - django41: Django>=4.1,<4.2 django42: Django>=4.2,<4.3 django50: Django>=5.0,<5.1 drf314: djangorestframework>=3.14,<3.15 @@ -53,9 +51,6 @@ commands = [testenv:py{38,39,310}-django32-drfmaster] ignore_outcome = true -[testenv:py{38,39,310,311}-django41-drfmaster] -ignore_outcome = true - [testenv:py{38,39,310,311,312}-django42-drfmaster] ignore_outcome = true From 2cbae19d40fdd440f160e148f1527fde813ba502 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 Mar 2024 23:44:07 +0400 Subject: [PATCH 203/252] Bump black from 24.2.0 to 24.3.0 in /requirements (#1211) Bumps [black](https://github.com/psf/black) from 24.2.0 to 24.3.0. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/24.2.0...24.3.0) --- updated-dependencies: - dependency-name: black dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/requirements-codestyle.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index df3e0608..491d81d6 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==24.2.0 +black==24.3.0 flake8==7.0.0 flake8-bugbear==24.2.6 flake8-isort==6.1.1 From f34fb421f1076ff3b2410f099e493add8b607efa Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 17 Apr 2024 22:47:23 +0200 Subject: [PATCH 204/252] Migrated some included tests to pytest style (#1218) Migrated included tests to pytest style --- example/tests/integration/test_includes.py | 65 ---------- tests/conftest.py | 35 +++++ tests/models.py | 6 +- tests/serializers.py | 36 ++++-- tests/test_utils.py | 2 +- tests/test_views.py | 143 ++++++++++++++++++++- tests/views.py | 33 ++++- 7 files changed, 237 insertions(+), 83 deletions(-) diff --git a/example/tests/integration/test_includes.py b/example/tests/integration/test_includes.py index f8fe8604..d32ff7e3 100644 --- a/example/tests/integration/test_includes.py +++ b/example/tests/integration/test_includes.py @@ -4,71 +4,6 @@ pytestmark = pytest.mark.django_db -def test_included_data_on_list(multiple_entries, client): - response = client.get( - reverse("entry-list"), data={"include": "comments", "page[size]": 5} - ) - included = response.json().get("included") - - assert len(response.json()["data"]) == len( - multiple_entries - ), "Incorrect entry count" - assert [x.get("type") for x in included] == [ - "comments", - "comments", - ], "List included types are incorrect" - - comment_count = len( - [resource for resource in included if resource["type"] == "comments"] - ) - expected_comment_count = sum(entry.comments.count() for entry in multiple_entries) - assert comment_count == expected_comment_count, "List comment count is incorrect" - - -def test_included_data_on_list_with_one_to_one_relations(multiple_entries, client): - response = client.get( - reverse("entry-list"), data={"include": "authors.bio.metadata", "page[size]": 5} - ) - included = response.json().get("included") - - assert len(response.json()["data"]) == len( - multiple_entries - ), "Incorrect entry count" - expected_include_types = [ - "authorBioMetadata", - "authorBioMetadata", - "authorBios", - "authorBios", - "authors", - "authors", - ] - include_types = [x.get("type") for x in included] - assert include_types == expected_include_types, "List included types are incorrect" - - -def test_default_included_data_on_detail(single_entry, client): - return test_included_data_on_detail( - single_entry=single_entry, client=client, query="" - ) - - -def test_included_data_on_detail(single_entry, client, query="?include=comments"): - response = client.get( - reverse("entry-detail", kwargs={"pk": single_entry.pk}) + query - ) - included = response.json().get("included") - - assert [x.get("type") for x in included] == [ - "comments" - ], "Detail included types are incorrect" - - comment_count = len( - [resource for resource in included if resource["type"] == "comments"] - ) - expected_comment_count = single_entry.comments.count() - assert comment_count == expected_comment_count, "Detail comment count is incorrect" - - def test_dynamic_related_data_is_included(single_entry, entry_factory, client): entry_factory() response = client.get( diff --git a/tests/conftest.py b/tests/conftest.py index ebdf5348..682d8342 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,9 +3,11 @@ from tests.models import ( BasicModel, + ForeignKeySource, ForeignKeyTarget, ManyToManySource, ManyToManyTarget, + NestedRelatedSource, ) @@ -39,6 +41,11 @@ def foreign_key_target(db): return ForeignKeyTarget.objects.create(name="Target") +@pytest.fixture +def foreign_key_source(db, foreign_key_target): + return ForeignKeySource.objects.create(name="Source", target=foreign_key_target) + + @pytest.fixture def many_to_many_source(db, many_to_many_targets): source = ManyToManySource.objects.create(name="Source") @@ -54,6 +61,34 @@ def many_to_many_targets(db): ] +@pytest.fixture +def many_to_many_sources(db, many_to_many_targets): + source1 = ManyToManySource.objects.create(name="Source1") + source2 = ManyToManySource.objects.create(name="Source2") + + source1.targets.add(*many_to_many_targets) + source2.targets.add(*many_to_many_targets) + + return [source1, source2] + + +@pytest.fixture +def nested_related_source( + db, + foreign_key_source, + foreign_key_target, + many_to_many_targets, + many_to_many_sources, +): + source = NestedRelatedSource.objects.create( + fk_source=foreign_key_source, fk_target=foreign_key_target + ) + source.m2m_targets.add(*many_to_many_targets) + source.m2m_sources.add(*many_to_many_sources) + + return source + + @pytest.fixture def client(): return APIClient() diff --git a/tests/models.py b/tests/models.py index a483f2d0..812ee5bf 100644 --- a/tests/models.py +++ b/tests/models.py @@ -42,11 +42,11 @@ class ForeignKeySource(DJAModel): class NestedRelatedSource(DJAModel): - m2m_source = models.ManyToManyField(ManyToManySource, related_name="nested_source") + m2m_sources = models.ManyToManyField(ManyToManySource, related_name="nested_source") fk_source = models.ForeignKey( ForeignKeySource, related_name="nested_source", on_delete=models.CASCADE ) - m2m_target = models.ManyToManyField(ManyToManySource, related_name="nested_target") + m2m_targets = models.ManyToManyField(ManyToManyTarget, related_name="nested_target") fk_target = models.ForeignKey( - ForeignKeySource, related_name="nested_target", on_delete=models.CASCADE + ForeignKeyTarget, related_name="nested_target", on_delete=models.CASCADE ) diff --git a/tests/serializers.py b/tests/serializers.py index e0f7c03b..4159039d 100644 --- a/tests/serializers.py +++ b/tests/serializers.py @@ -1,11 +1,11 @@ from rest_framework_json_api import serializers -from rest_framework_json_api.relations import ResourceRelatedField from tests.models import ( BasicModel, ForeignKeySource, ForeignKeyTarget, ManyToManySource, ManyToManyTarget, + NestedRelatedSource, ) @@ -15,33 +15,51 @@ class Meta: model = BasicModel +class ForeignKeyTargetSerializer(serializers.ModelSerializer): + class Meta: + fields = ("name",) + model = ForeignKeyTarget + + class ForeignKeySourceSerializer(serializers.ModelSerializer): - target = ResourceRelatedField(queryset=ForeignKeyTarget.objects) + included_serializers = {"target": ForeignKeyTargetSerializer} class Meta: model = ForeignKeySource fields = ("target",) +class ManyToManyTargetSerializer(serializers.ModelSerializer): + class Meta: + fields = ("name",) + model = ManyToManyTarget + + class ManyToManySourceSerializer(serializers.ModelSerializer): - targets = ResourceRelatedField(many=True, queryset=ManyToManyTarget.objects) + included_serializers = {"targets": "tests.serializers.ManyToManyTargetSerializer"} class Meta: model = ManyToManySource fields = ("targets",) -class ManyToManyTargetSerializer(serializers.ModelSerializer): +class ManyToManySourceReadOnlySerializer(serializers.ModelSerializer): class Meta: - model = ManyToManyTarget + model = ManyToManySource + fields = ("targets",) -class ManyToManySourceReadOnlySerializer(serializers.ModelSerializer): - targets = ResourceRelatedField(many=True, read_only=True) +class NestedRelatedSourceSerializer(serializers.ModelSerializer): + included_serializers = { + "m2m_sources": ManyToManySourceSerializer, + "fk_source": ForeignKeySourceSerializer, + "m2m_targets": ManyToManyTargetSerializer, + "fk_target": ForeignKeyTargetSerializer, + } class Meta: - model = ManyToManySource - fields = ("targets",) + model = NestedRelatedSource + fields = ("m2m_sources", "fk_source", "m2m_targets", "fk_target") class CallableDefaultSerializer(serializers.Serializer): diff --git a/tests/test_utils.py b/tests/test_utils.py index f2a3d176..4e103ae2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -325,7 +325,7 @@ class Meta: {"many": True, "queryset": ManyToManyTarget.objects.all()}, ), ( - "m2m_target.sources.", + "m2m_target.sources", "ManyToManySource", {"many": True, "queryset": ManyToManySource.objects.all()}, ), diff --git a/tests/test_views.py b/tests/test_views.py index 47fec02a..de5d1b7a 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -12,9 +12,14 @@ from rest_framework_json_api.renderers import JSONRenderer from rest_framework_json_api.utils import format_link_segment from rest_framework_json_api.views import ModelViewSet, ReadOnlyModelViewSet -from tests.models import BasicModel -from tests.serializers import BasicModelSerializer -from tests.views import BasicModelViewSet +from tests.models import BasicModel, ForeignKeySource +from tests.serializers import BasicModelSerializer, ForeignKeyTargetSerializer +from tests.views import ( + BasicModelViewSet, + ForeignKeySourceViewSet, + ManyToManySourceViewSet, + NestedRelatedSourceViewSet, +) class TestModelViewSet: @@ -80,6 +85,90 @@ def test_list(self, client, model): "meta": {"pagination": {"count": 1, "page": 1, "pages": 1}}, } + @pytest.mark.urls(__name__) + def test_list_with_include_foreign_key(self, client, foreign_key_source): + url = reverse("foreign-key-source-list") + response = client.get(url, data={"include": "target"}) + assert response.status_code == status.HTTP_200_OK + result = response.json() + assert "included" in result + assert [ + { + "type": "ForeignKeyTarget", + "id": str(foreign_key_source.target.pk), + "attributes": {"name": foreign_key_source.target.name}, + } + ] == result["included"] + + @pytest.mark.urls(__name__) + def test_list_with_include_many_to_many_field( + self, client, many_to_many_source, many_to_many_targets + ): + url = reverse("many-to-many-source-list") + response = client.get(url, data={"include": "targets"}) + assert response.status_code == status.HTTP_200_OK + result = response.json() + assert "included" in result + assert [ + { + "type": "ManyToManyTarget", + "id": str(target.pk), + "attributes": {"name": target.name}, + } + for target in many_to_many_targets + ] == result["included"] + + @pytest.mark.urls(__name__) + def test_list_with_include_nested_related_field( + self, client, nested_related_source, many_to_many_sources, many_to_many_targets + ): + url = reverse("nested-related-source-list") + response = client.get(url, data={"include": "m2m_sources,m2m_sources.targets"}) + assert response.status_code == status.HTTP_200_OK + result = response.json() + assert "included" in result + + assert [ + { + "type": "ManyToManySource", + "id": str(source.pk), + "relationships": { + "targets": { + "data": [ + {"id": str(target.pk), "type": "ManyToManyTarget"} + for target in source.targets.all() + ], + "meta": {"count": source.targets.count()}, + } + }, + } + for source in many_to_many_sources + ] + [ + { + "type": "ManyToManyTarget", + "id": str(target.pk), + "attributes": {"name": target.name}, + } + for target in many_to_many_targets + ] == result[ + "included" + ] + + @pytest.mark.urls(__name__) + def test_list_with_default_included_resources(self, client, foreign_key_source): + url = reverse("default-included-resources-list") + response = client.get(url) + assert response.status_code == status.HTTP_200_OK + result = response.json() + assert "included" in result + assert [ + { + "type": "ForeignKeyTarget", + "id": str(foreign_key_source.target.pk), + "attributes": {"name": foreign_key_source.target.name}, + } + ] == result["included"] + @pytest.mark.urls(__name__) def test_retrieve(self, client, model): url = reverse("basic-model-detail", kwargs={"pk": model.pk}) @@ -93,6 +182,21 @@ def test_retrieve(self, client, model): } } + @pytest.mark.urls(__name__) + def test_retrieve_with_include_foreign_key(self, client, foreign_key_source): + url = reverse("foreign-key-source-detail", kwargs={"pk": foreign_key_source.pk}) + response = client.get(url, data={"include": "target"}) + assert response.status_code == status.HTTP_200_OK + result = response.json() + assert "included" in result + assert [ + { + "type": "ForeignKeyTarget", + "id": str(foreign_key_source.target.pk), + "attributes": {"name": foreign_key_source.target.name}, + } + ] == result["included"] + @pytest.mark.urls(__name__) def test_patch(self, client, model): data = { @@ -231,6 +335,23 @@ def test_patch_with_custom_id(self, client): # Routing setup +class DefaultIncludedResourcesSerializer(serializers.ModelSerializer): + included_serializers = {"target": ForeignKeyTargetSerializer} + + class Meta: + model = ForeignKeySource + fields = ("target",) + + class JSONAPIMeta: + included_resources = ["target"] + + +class DefaultIncludedResourcesViewSet(ModelViewSet): + serializer_class = DefaultIncludedResourcesSerializer + queryset = ForeignKeySource.objects.all() + ordering = ["id"] + + class CustomModel: def __init__(self, response_dict): for k, v in response_dict.items(): @@ -280,6 +401,22 @@ def patch(self, request, *args, **kwargs): router = SimpleRouter() router.register(r"basic_models", BasicModelViewSet, basename="basic-model") +router.register( + r"foreign_key_sources", ForeignKeySourceViewSet, basename="foreign-key-source" +) +router.register( + r"many_to_many_sources", ManyToManySourceViewSet, basename="many-to-many-source" +) +router.register( + r"nested_related_sources", + NestedRelatedSourceViewSet, + basename="nested-related-source", +) +router.register( + r"default_included_resources", + DefaultIncludedResourcesViewSet, + basename="default-included-resources", +) urlpatterns = [ path("custom", CustomAPIView.as_view(), name="custom"), diff --git a/tests/views.py b/tests/views.py index 42d8a0b0..72a7ea59 100644 --- a/tests/views.py +++ b/tests/views.py @@ -1,8 +1,37 @@ from rest_framework_json_api.views import ModelViewSet -from tests.models import BasicModel -from tests.serializers import BasicModelSerializer +from tests.models import ( + BasicModel, + ForeignKeySource, + ManyToManySource, + NestedRelatedSource, +) +from tests.serializers import ( + BasicModelSerializer, + ForeignKeySourceSerializer, + ManyToManySourceSerializer, + NestedRelatedSourceSerializer, +) class BasicModelViewSet(ModelViewSet): serializer_class = BasicModelSerializer queryset = BasicModel.objects.all() + ordering = ["text"] + + +class ForeignKeySourceViewSet(ModelViewSet): + serializer_class = ForeignKeySourceSerializer + queryset = ForeignKeySource.objects.all() + ordering = ["name"] + + +class ManyToManySourceViewSet(ModelViewSet): + serializer_class = ManyToManySourceSerializer + queryset = ManyToManySource.objects.all() + ordering = ["name"] + + +class NestedRelatedSourceViewSet(ModelViewSet): + serializer_class = NestedRelatedSourceSerializer + queryset = NestedRelatedSource.objects.all() + ordering = ["id"] From fc0d0b5a44aaa977dc8f7947edc82be9104a23e6 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 17 Apr 2024 23:21:41 +0200 Subject: [PATCH 205/252] Drop support for Django 3.2 (#1219) Run django-upgrade --target-version 4.2 --- CHANGELOG.md | 3 ++- README.rst | 2 +- docs/getting-started.md | 2 +- example/urls_test.py | 6 +++--- setup.py | 2 +- tox.ini | 5 ----- 6 files changed, 8 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee8a8f07..964f6b16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Removed * Removed support for Python 3.7. +* Removed support for Django 3.2. * Removed support for Django 4.0. * Removed support for Django 4.1. * Removed support for Django REST framework 3.13. @@ -33,7 +34,7 @@ any parts of the framework not mentioned in the documentation should generally b ## [6.1.0] - 2023-08-25 -This is the last release supporting Python 3.7, Django 4.0, Django 4.1 and Django REST framework 3.13. +This is the last release supporting Python 3.7, Django 3.2, Django 4.0, Django 4.1 and Django REST framework 3.13. ### Added diff --git a/README.rst b/README.rst index afe00f85..fd340af6 100644 --- a/README.rst +++ b/README.rst @@ -93,7 +93,7 @@ Requirements ------------ 1. Python (3.8, 3.9, 3.10, 3.11, 3.12) -2. Django (3.2, 4.2, 5.0) +2. Django (4.2, 5.0) 3. Django REST framework (3.14, 3.15) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index 5aec6ffe..fd70c79a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.8, 3.9, 3.10, 3.11, 3.12) -2. Django (3.2, 4.2, 5.0) +2. Django (4.2, 5.0) 3. Django REST framework (3.14, 3.15) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/example/urls_test.py b/example/urls_test.py index bb8fbecf..26d4db21 100644 --- a/example/urls_test.py +++ b/example/urls_test.py @@ -1,4 +1,4 @@ -from django.urls import re_path +from django.urls import path, re_path from rest_framework import routers from .api.resources.identity import GenericIdentity, Identity @@ -46,8 +46,8 @@ urlpatterns = [ # old tests - re_path( - r"identities/default/(?P\d+)$", + path( + "identities/default/", GenericIdentity.as_view(), name="user-default", ), diff --git a/setup.py b/setup.py index 4ff629e4..95d5ca0b 100755 --- a/setup.py +++ b/setup.py @@ -107,7 +107,7 @@ def get_package_data(package): install_requires=[ "inflection>=0.5.0", "djangorestframework>=3.14", - "django>=3.2", + "django>=4.2", ], extras_require={ "django-polymorphic": ["django-polymorphic>=3.0"], diff --git a/tox.ini b/tox.ini index 354e328c..68646fb4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,5 @@ [tox] envlist = - py{38,39,310}-django32-drf{314,315,master}, py{38,39,310,311,312}-django42-drf{314,315,master}, py{310,311,312}-django50-drf{314,315,master}, black, @@ -9,7 +8,6 @@ envlist = [testenv] deps = - django32: Django>=3.2,<3.3 django42: Django>=4.2,<4.3 django50: Django>=5.0,<5.1 drf314: djangorestframework>=3.14,<3.15 @@ -48,9 +46,6 @@ deps = commands = sphinx-build -W -b html -d docs/_build/doctrees docs docs/_build/html -[testenv:py{38,39,310}-django32-drfmaster] -ignore_outcome = true - [testenv:py{38,39,310,311,312}-django42-drfmaster] ignore_outcome = true From 2568417f1940834c058ccf8319cf3e4bc0913bee Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Thu, 18 Apr 2024 09:29:43 -0700 Subject: [PATCH 206/252] Scheduled biweekly dependency update for week 15 (#1217) * Update black from 24.3.0 to 24.4.0 * Update django-filter from 23.5 to 24.2 * Update faker from 23.3.0 to 24.9.0 * Update pytest from 8.1.0 to 8.1.1 * Update pytest-cov from 4.1.0 to 5.0.0 * Update pytest-factoryboy from 2.6.1 to 2.7.0 --------- Co-authored-by: Oliver Sauder --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-optionals.txt | 2 +- requirements/requirements-testing.txt | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 491d81d6..dc65b0ef 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==24.3.0 +black==24.4.0 flake8==7.0.0 flake8-bugbear==24.2.6 flake8-isort==6.1.1 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 0e4a92da..efc82a58 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,4 +1,4 @@ -django-filter==23.5 +django-filter==24.2 # once next version has been released (>3.1.0) this # should be set to pinned version again # see https://github.com/django-polymorphic/django-polymorphic/pull/541 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index b15dd948..d2c31244 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.3.0 -Faker==23.3.0 -pytest==8.1.0 -pytest-cov==4.1.0 +Faker==24.9.0 +pytest==8.1.1 +pytest-cov==5.0.0 pytest-django==4.8.0 -pytest-factoryboy==2.6.1 +pytest-factoryboy==2.7.0 syrupy==4.6.1 From 6ec7ad49352e822707459d1a1bb79c9333f3d107 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 18 Apr 2024 23:07:02 +0200 Subject: [PATCH 207/252] Avoided shadowing of exception when rendering errors (#1220) --- CHANGELOG.md | 1 + rest_framework_json_api/utils.py | 13 +++++++------ tests/test_views.py | 11 +++++++++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 964f6b16..0b05600b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ any parts of the framework not mentioned in the documentation should generally b * `ModelSerializer` fields are now returned in the same order than DRF * Avoided that an empty attributes dict is rendered in case serializer does not provide any attribute fields. +* Avoided shadowing of exception when rendering errors (regression since 4.3.0). ### Removed diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 2e57fbbd..e12080ac 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -381,11 +381,7 @@ def format_drf_errors(response, context, exc): errors.extend(format_error_object(message, "/data", response)) # handle all errors thrown from serializers else: - # Avoid circular deps - from rest_framework import generics - - has_serializer = isinstance(context["view"], generics.GenericAPIView) - if has_serializer: + try: serializer = context["view"].get_serializer() fields = get_serializer_fields(serializer) or dict() relationship_fields = [ @@ -393,6 +389,11 @@ def format_drf_errors(response, context, exc): for name, field in fields.items() if is_relationship_field(field) ] + except Exception: + # ignore potential errors when retrieving serializer + # as it might shadow error which is currently being + # formatted + serializer = None for field, error in response.data.items(): non_field_error = field == api_settings.NON_FIELD_ERRORS_KEY @@ -401,7 +402,7 @@ def format_drf_errors(response, context, exc): if non_field_error: # Serializer error does not refer to a specific field. pointer = "/data" - elif has_serializer: + elif serializer: # pointer can be determined only if there's a serializer. rel = "relationships" if field in relationship_fields else "attributes" pointer = f"/data/{rel}/{field}" diff --git a/tests/test_views.py b/tests/test_views.py index de5d1b7a..acba7e66 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -154,6 +154,17 @@ def test_list_with_include_nested_related_field( "included" ] + @pytest.mark.urls(__name__) + def test_list_with_invalid_include(self, client, foreign_key_source): + url = reverse("foreign-key-source-list") + response = client.get(url, data={"include": "invalid"}) + assert response.status_code == status.HTTP_400_BAD_REQUEST + result = response.json() + assert ( + result["errors"][0]["detail"] + == "This endpoint does not support the include parameter for path invalid" + ) + @pytest.mark.urls(__name__) def test_list_with_default_included_resources(self, client, foreign_key_source): url = reverse("default-included-resources-list") From 0eabc3909c4a76ea37b7518404a625918cabb671 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 30 Apr 2024 21:31:11 +0200 Subject: [PATCH 208/252] Updated Codecov GitHub action (#1222) --- .github/workflows/tests.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 17c6a9a5..2f966adc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,8 +30,9 @@ jobs: - name: Run tox targets for ${{ matrix.python-version }} run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d .) - name: Upload coverage report - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v4 with: + token: ${{ secrets.CODECOV_TOKEN }} env_vars: PYTHON check: name: Run check From 6c609f86502e98f37ff67ca849c8bd79e88b5615 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 1 May 2024 15:07:45 +0200 Subject: [PATCH 209/252] Ensured that sparse fields only applies when rendering not when parsing (#1221) * Added missing name field to ForeignKeySourceSerializer * Only extract attributes provided by serialized data * Added changelog --- CHANGELOG.md | 2 + .../test_non_paginated_responses.py | 4 +- example/tests/integration/test_pagination.py | 2 +- .../integration/test_sparse_fieldsets.py | 1 + example/tests/test_filters.py | 2 +- .../tests/unit/test_renderer_class_methods.py | 4 +- example/views.py | 5 +- rest_framework_json_api/renderers.py | 122 ++++++++++-------- rest_framework_json_api/serializers.py | 45 +++---- tests/serializers.py | 5 +- tests/test_relations.py | 13 +- tests/test_views.py | 37 ++++++ 12 files changed, 156 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b05600b..186c58c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ any parts of the framework not mentioned in the documentation should generally b * Avoided that an empty attributes dict is rendered in case serializer does not provide any attribute fields. * Avoided shadowing of exception when rendering errors (regression since 4.3.0). +* Ensured that sparse fields only applies when rendering, not when parsing. +* Adjusted that sparse fields properly removes meta fields when not defined. ### Removed diff --git a/example/tests/integration/test_non_paginated_responses.py b/example/tests/integration/test_non_paginated_responses.py index 92d26de3..6434b7a7 100644 --- a/example/tests/integration/test_non_paginated_responses.py +++ b/example/tests/integration/test_non_paginated_responses.py @@ -14,7 +14,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): expected = { "data": [ { - "type": "posts", + "type": "entries", "id": "1", "attributes": { "headline": multiple_entries[0].headline, @@ -70,7 +70,7 @@ def test_multiple_entries_no_pagination(multiple_entries, client): }, }, { - "type": "posts", + "type": "entries", "id": "2", "attributes": { "headline": multiple_entries[1].headline, diff --git a/example/tests/integration/test_pagination.py b/example/tests/integration/test_pagination.py index 1a4bd056..0f5ac17e 100644 --- a/example/tests/integration/test_pagination.py +++ b/example/tests/integration/test_pagination.py @@ -14,7 +14,7 @@ def test_pagination_with_single_entry(single_entry, client): expected = { "data": [ { - "type": "posts", + "type": "entries", "id": "1", "attributes": { "headline": single_entry.headline, diff --git a/example/tests/integration/test_sparse_fieldsets.py b/example/tests/integration/test_sparse_fieldsets.py index 605d218d..cf9cee20 100644 --- a/example/tests/integration/test_sparse_fieldsets.py +++ b/example/tests/integration/test_sparse_fieldsets.py @@ -15,6 +15,7 @@ def test_sparse_fieldset_valid_fields(client, entry): entry = data[0] assert entry["attributes"].keys() == {"headline"} assert entry["relationships"].keys() == {"blog"} + assert "meta" not in entry @pytest.mark.parametrize( diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index 87f9d059..8e45ded1 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -470,7 +470,7 @@ def test_search_keywords(self): expected_result = { "data": [ { - "type": "posts", + "type": "entries", "id": "7", "attributes": { "headline": "ANTH3868X", diff --git a/example/tests/unit/test_renderer_class_methods.py b/example/tests/unit/test_renderer_class_methods.py index 6e7c9ea1..838f6819 100644 --- a/example/tests/unit/test_renderer_class_methods.py +++ b/example/tests/unit/test_renderer_class_methods.py @@ -102,8 +102,8 @@ def test_extract_attributes(): assert sorted(JSONRenderer.extract_attributes(fields, resource)) == sorted( expected ), "Regular fields should be extracted" - assert sorted(JSONRenderer.extract_attributes(fields, {})) == sorted( - {"username": ""} + assert ( + JSONRenderer.extract_attributes(fields, {}) == {} ), "Should not extract read_only fields on empty serializer" diff --git a/example/views.py b/example/views.py index 9c949684..da171698 100644 --- a/example/views.py +++ b/example/views.py @@ -112,7 +112,10 @@ class BlogCustomViewSet(JsonApiViewSet): class EntryViewSet(ModelViewSet): queryset = Entry.objects.all() - resource_name = "posts" + # TODO it should not be supported to overwrite resource name + # of endpoints with serializers as includes and sparse fields + # cannot be aware of it + # resource_name = "posts" def get_serializer_class(self): return EntrySerializer diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 639f0b11..8c632f6a 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -17,13 +17,26 @@ from rest_framework.settings import api_settings import rest_framework_json_api -from rest_framework_json_api import utils from rest_framework_json_api.relations import ( HyperlinkedMixin, ManySerializerMethodResourceRelatedField, ResourceRelatedField, SkipDataMixin, ) +from rest_framework_json_api.utils import ( + format_errors, + format_field_name, + format_field_names, + get_included_resources, + get_related_resource_type, + get_relation_instance, + get_resource_id, + get_resource_name, + get_resource_type_from_instance, + get_resource_type_from_serializer, + get_serializer_fields, + is_relationship_field, +) class JSONRenderer(renderers.JSONRenderer): @@ -57,31 +70,20 @@ class JSONRenderer(renderers.JSONRenderer): def extract_attributes(cls, fields, resource): """ Builds the `attributes` object of the JSON:API resource object. - """ - data = {} - for field_name, field in iter(fields.items()): - # ID is always provided in the root of JSON:API so remove it from attributes - if field_name == "id": - continue - # don't output a key for write only fields - if fields[field_name].write_only: - continue - # Skip fields with relations - if utils.is_relationship_field(field): - continue - # Skip read_only attribute fields when `resource` is an empty - # serializer. Prevents the "Raw Data" form of the browsable API - # from rendering `"foo": null` for read only fields - try: - resource[field_name] - except KeyError: - if fields[field_name].read_only: - continue + Ensures that ID which is always provided in a JSON:API resource object + and relationships are not returned. + """ - data.update({field_name: resource.get(field_name)}) + invalid_fields = {"id", api_settings.URL_FIELD_NAME} - return utils.format_field_names(data) + return { + format_field_name(field_name): value + for field_name, value in resource.items() + if field_name in fields + and field_name not in invalid_fields + and not is_relationship_field(fields[field_name]) + } @classmethod def extract_relationships(cls, fields, resource, resource_instance): @@ -107,14 +109,14 @@ def extract_relationships(cls, fields, resource, resource_instance): continue # Skip fields without relations - if not utils.is_relationship_field(field): + if not is_relationship_field(field): continue source = field.source - relation_type = utils.get_related_resource_type(field) + relation_type = get_related_resource_type(field) if isinstance(field, relations.HyperlinkedIdentityField): - resolved, relation_instance = utils.get_relation_instance( + resolved, relation_instance = get_relation_instance( resource_instance, source, field.parent ) if not resolved: @@ -166,7 +168,7 @@ def extract_relationships(cls, fields, resource, resource_instance): field, (relations.PrimaryKeyRelatedField, relations.HyperlinkedRelatedField), ): - resolved, relation = utils.get_relation_instance( + resolved, relation = get_relation_instance( resource_instance, f"{source}_id", field.parent ) if not resolved: @@ -189,7 +191,7 @@ def extract_relationships(cls, fields, resource, resource_instance): continue if isinstance(field, relations.ManyRelatedField): - resolved, relation_instance = utils.get_relation_instance( + resolved, relation_instance = get_relation_instance( resource_instance, source, field.parent ) if not resolved: @@ -222,9 +224,7 @@ def extract_relationships(cls, fields, resource, resource_instance): for nested_resource_instance in relation_instance: nested_resource_instance_type = ( relation_type - or utils.get_resource_type_from_instance( - nested_resource_instance - ) + or get_resource_type_from_instance(nested_resource_instance) ) relation_data.append( @@ -243,7 +243,7 @@ def extract_relationships(cls, fields, resource, resource_instance): ) continue - return utils.format_field_names(data) + return format_field_names(data) @classmethod def extract_relation_instance(cls, field, resource_instance): @@ -289,7 +289,7 @@ def extract_included( continue # Skip fields without relations - if not utils.is_relationship_field(field): + if not is_relationship_field(field): continue try: @@ -341,7 +341,7 @@ def extract_included( if isinstance(field, ListSerializer): serializer = field.child - relation_type = utils.get_resource_type_from_serializer(serializer) + relation_type = get_resource_type_from_serializer(serializer) relation_queryset = list(relation_instance) if serializer_data: @@ -350,11 +350,9 @@ def extract_included( nested_resource_instance = relation_queryset[position] resource_type = ( relation_type - or utils.get_resource_type_from_instance( - nested_resource_instance - ) + or get_resource_type_from_instance(nested_resource_instance) ) - serializer_fields = utils.get_serializer_fields( + serializer_fields = get_serializer_fields( serializer.__class__( nested_resource_instance, context=serializer.context ) @@ -378,10 +376,10 @@ def extract_included( ) if isinstance(field, Serializer): - relation_type = utils.get_resource_type_from_serializer(field) + relation_type = get_resource_type_from_serializer(field) # Get the serializer fields - serializer_fields = utils.get_serializer_fields(field) + serializer_fields = get_serializer_fields(field) if serializer_data: new_item = cls.build_json_resource_obj( serializer_fields, @@ -414,7 +412,8 @@ def extract_meta(cls, serializer, resource): meta_fields = getattr(meta, "meta_fields", []) data = {} for field_name in meta_fields: - data.update({field_name: resource.get(field_name)}) + if field_name in resource: + data.update({field_name: resource[field_name]}) return data @classmethod @@ -434,6 +433,24 @@ def extract_root_meta(cls, serializer, resource): data.update(json_api_meta) return data + @classmethod + def _filter_sparse_fields(cls, serializer, fields, resource_name): + request = serializer.context.get("request") + if request: + sparse_fieldset_query_param = f"fields[{resource_name}]" + sparse_fieldset_value = request.query_params.get( + sparse_fieldset_query_param + ) + if sparse_fieldset_value: + sparse_fields = sparse_fieldset_value.split(",") + return { + field_name: field + for field_name, field, in fields.items() + if field_name in sparse_fields + } + + return fields + @classmethod def build_json_resource_obj( cls, @@ -449,11 +466,15 @@ def build_json_resource_obj( """ # Determine type from the instance if the underlying model is polymorphic if force_type_resolution: - resource_name = utils.get_resource_type_from_instance(resource_instance) + resource_name = get_resource_type_from_instance(resource_instance) resource_data = { "type": resource_name, - "id": utils.get_resource_id(resource_instance, resource), + "id": get_resource_id(resource_instance, resource), } + + # TODO remove this filter by rewriting extract_relationships + # so it uses the serialized data as a basis + fields = cls._filter_sparse_fields(serializer, fields, resource_name) attributes = cls.extract_attributes(fields, resource) if attributes: resource_data["attributes"] = attributes @@ -468,7 +489,7 @@ def build_json_resource_obj( meta = cls.extract_meta(serializer, resource) if meta: - resource_data["meta"] = utils.format_field_names(meta) + resource_data["meta"] = format_field_names(meta) return resource_data @@ -485,7 +506,7 @@ def render_relationship_view( def render_errors(self, data, accepted_media_type=None, renderer_context=None): return super().render( - utils.format_errors(data), accepted_media_type, renderer_context + format_errors(data), accepted_media_type, renderer_context ) def render(self, data, accepted_media_type=None, renderer_context=None): @@ -495,7 +516,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): request = renderer_context.get("request", None) # Get the resource name. - resource_name = utils.get_resource_name(renderer_context) + resource_name = get_resource_name(renderer_context) # If this is an error response, skip the rest. if resource_name == "errors": @@ -531,7 +552,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): serializer = getattr(serializer_data, "serializer", None) - included_resources = utils.get_included_resources(request, serializer) + included_resources = get_included_resources(request, serializer) if serializer is not None: # Extract root meta for any type of serializer @@ -558,7 +579,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): else: resource_serializer_class = serializer.child - fields = utils.get_serializer_fields(resource_serializer_class) + fields = get_serializer_fields(resource_serializer_class) force_type_resolution = getattr( resource_serializer_class, "_poly_force_type_resolution", False ) @@ -581,7 +602,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): included_cache, ) else: - fields = utils.get_serializer_fields(serializer) + fields = get_serializer_fields(serializer) force_type_resolution = getattr( serializer, "_poly_force_type_resolution", False ) @@ -640,7 +661,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): ) if json_api_meta: - render_data["meta"] = utils.format_field_names(json_api_meta) + render_data["meta"] = format_field_names(json_api_meta) return super().render(render_data, accepted_media_type, renderer_context) @@ -690,7 +711,6 @@ def get_includes_form(self, view): serializer_class = view.get_serializer_class() except AttributeError: return - if not hasattr(serializer_class, "included_serializers"): return diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index c680f60a..66650caf 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -75,35 +75,32 @@ class SparseFieldsetsMixin: Specification: https://jsonapi.org/format/#fetching-sparse-fieldsets """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - context = kwargs.get("context") - request = context.get("request") if context else None + @property + def _readable_fields(self): + request = self.context.get("request") if self.context else None + readable_fields = super()._readable_fields if request: - sparse_fieldset_query_param = "fields[{}]".format( - get_resource_type_from_serializer(self) - ) try: - param_name = next( - key - for key in request.query_params - if sparse_fieldset_query_param == key + resource_type = get_resource_type_from_serializer(self) + sparse_fieldset_query_param = f"fields[{resource_type}]" + + sparse_fieldset_value = request.query_params.get( + sparse_fieldset_query_param ) - except StopIteration: + if sparse_fieldset_value: + sparse_fields = sparse_fieldset_value.split(",") + return ( + field + for field in readable_fields + if field.field_name in sparse_fields + or field.field_name == api_settings.URL_FIELD_NAME + ) + except AttributeError: + # no type on serializer, must be used only as only nested pass - else: - fieldset = request.query_params.get(param_name).split(",") - # iterate over a *copy* of self.fields' underlying dict, because we may - # modify the original during the iteration. - # self.fields is a `rest_framework.utils.serializer_helpers.BindingDict` - for field_name, _field in self.fields.fields.copy().items(): - if ( - field_name == api_settings.URL_FIELD_NAME - ): # leave self link there - continue - if field_name not in fieldset: - self.fields.pop(field_name) + + return readable_fields class IncludedResourcesValidationMixin: diff --git a/tests/serializers.py b/tests/serializers.py index 4159039d..ddf28f98 100644 --- a/tests/serializers.py +++ b/tests/serializers.py @@ -26,7 +26,10 @@ class ForeignKeySourceSerializer(serializers.ModelSerializer): class Meta: model = ForeignKeySource - fields = ("target",) + fields = ( + "name", + "target", + ) class ManyToManyTargetSerializer(serializers.ModelSerializer): diff --git a/tests/test_relations.py b/tests/test_relations.py index ad4bebcb..5f8883be1 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -39,7 +39,9 @@ def test_serialize( settings.JSON_API_FORMAT_TYPES = format_type settings.JSON_API_PLURALIZE_TYPES = pluralize_type - serializer = ForeignKeySourceSerializer(instance={"target": foreign_key_target}) + serializer = ForeignKeySourceSerializer( + instance={"target": foreign_key_target, "name": "Test"} + ) expected = { "type": resource_type, "id": str(foreign_key_target.pk), @@ -85,7 +87,10 @@ def test_deserialize( settings.JSON_API_PLURALIZE_TYPES = pluralize_type serializer = ForeignKeySourceSerializer( - data={"target": {"type": resource_type, "id": str(foreign_key_target.pk)}} + data={ + "target": {"type": resource_type, "id": str(foreign_key_target.pk)}, + "name": "Test", + } ) assert serializer.is_valid() @@ -191,7 +196,9 @@ def test_deserialize_many_to_many_relation( ], ) def test_invalid_resource_id_object(self, resource_identifier, error): - serializer = ForeignKeySourceSerializer(data={"target": resource_identifier}) + serializer = ForeignKeySourceSerializer( + data={"target": resource_identifier, "name": "Test"} + ) assert not serializer.is_valid() assert serializer.errors == {"target": [error]} diff --git a/tests/test_views.py b/tests/test_views.py index acba7e66..468c2cbd 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -237,6 +237,43 @@ def test_delete(self, client, model): assert BasicModel.objects.count() == 0 assert len(response.rendered_content) == 0 + @pytest.mark.urls(__name__) + def test_create_with_sparse_fields(self, client, foreign_key_target): + url = reverse("foreign-key-source-list") + data = { + "data": { + "id": None, + "type": "ForeignKeySource", + "attributes": {"name": "Test"}, + "relationships": { + "target": { + "data": { + "id": str(foreign_key_target.pk), + "type": "ForeignKeyTarget", + } + } + }, + } + } + response = client.post(f"{url}?fields[ForeignKeySource]=target", data=data) + assert response.status_code == status.HTTP_201_CREATED + foreign_key_source = ForeignKeySource.objects.first() + assert foreign_key_source.name == "Test" + assert response.json() == { + "data": { + "id": str(foreign_key_source.pk), + "type": "ForeignKeySource", + "relationships": { + "target": { + "data": { + "id": str(foreign_key_target.pk), + "type": "ForeignKeyTarget", + } + } + }, + } + } + class TestReadonlyModelViewSet: @pytest.mark.parametrize( From ce516c9bddeab436efc22eadbe729be68903f6fd Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 2 May 2024 21:04:02 +0200 Subject: [PATCH 210/252] Removed obsolete warning filter (#1223) --- setup.cfg | 3 --- 1 file changed, 3 deletions(-) diff --git a/setup.cfg b/setup.cfg index 8f0317c8..4230dcbb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -66,9 +66,6 @@ filterwarnings = # Django filter schema generation. Can be removed once we remove # schema support ignore:Built-in schema generation is deprecated. - # can be removed once django filter has released a new version including - # https://github.com/carltongibson/django-filter/pull/1623 - ignore:'pkgutil.find_loader' is deprecated and slated for removal testpaths = example tests From 366769e55e821eba11928125c903ad5043f9ff43 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 2 May 2024 21:33:30 +0200 Subject: [PATCH 211/252] Release 7.0.0 (#1224) --- CHANGELOG.md | 2 +- rest_framework_json_api/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 186c58c7..e1d08de6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [7.0.0] - 2024-05-02 ### Added diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index 96e6db9d..6d2c35b4 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,5 +1,5 @@ __title__ = "djangorestframework-jsonapi" -__version__ = "6.1.0" +__version__ = "7.0.0" __author__ = "" __license__ = "BSD" __copyright__ = "" From 9d9e47b6915037c220b77746cc7d1b5d8f0f36b1 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 7 May 2024 14:10:31 -0700 Subject: [PATCH 212/252] Scheduled biweekly dependency update for week 18 (#1225) * Update black from 24.4.0 to 24.4.2 * Update flake8-bugbear from 24.2.6 to 24.4.26 * Update sphinx from 7.2.6 to 7.3.7 * Update faker from 24.9.0 to 25.0.1 * Update pytest from 8.1.1 to 8.2.0 --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-documentation.txt | 2 +- requirements/requirements-testing.txt | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index dc65b0ef..4c270607 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ -black==24.4.0 +black==24.4.2 flake8==7.0.0 -flake8-bugbear==24.2.6 +flake8-bugbear==24.4.26 flake8-isort==6.1.1 isort==5.13.2 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 49668994..13a66219 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==7.2.6 +Sphinx==7.3.7 sphinx_rtd_theme==2.0.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index d2c31244..1dfa20d4 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,6 +1,6 @@ factory-boy==3.3.0 -Faker==24.9.0 -pytest==8.1.1 +Faker==25.0.1 +pytest==8.2.0 pytest-cov==5.0.0 pytest-django==4.8.0 pytest-factoryboy==2.7.0 From 21493c1abda91af80de13202407485ef1a21dc50 Mon Sep 17 00:00:00 2001 From: js-nh Date: Wed, 29 May 2024 10:08:18 +0200 Subject: [PATCH 213/252] Added HTTP 429 Too Many Requests as a possible generic error response (#1226) * Add HTTP 429 Too Many Requests as a possible generic error response * Update CHANGELOG.md --------- Co-authored-by: Oliver Sauder --- CHANGELOG.md | 6 ++ example/tests/__snapshots__/test_openapi.ambr | 60 +++++++++++++++++++ rest_framework_json_api/schemas/openapi.py | 1 + 3 files changed, 67 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1d08de6..a61d5faf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## [Unreleased] + +### Added + +* Added `429 Too Many Requests` as a possible error response in the OpenAPI schema. + ## [7.0.0] - 2024-05-02 ### Added diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr index 1de8057b..f72c6ff8 100644 --- a/example/tests/__snapshots__/test_openapi.ambr +++ b/example/tests/__snapshots__/test_openapi.ambr @@ -68,6 +68,16 @@ } }, "description": "[Resource does not exist](https://jsonapi.org/format/#crud-deleting-responses-404)" + }, + "429": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "too many requests" } }, "tags": [ @@ -264,6 +274,16 @@ } }, "description": "[Conflict]([Conflict](https://jsonapi.org/format/#crud-updating-responses-409)" + }, + "429": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "too many requests" } }, "tags": [ @@ -399,6 +419,16 @@ } }, "description": "not found" + }, + "429": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "too many requests" } }, "tags": [ @@ -546,6 +576,16 @@ } }, "description": "not found" + }, + "429": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "too many requests" } }, "tags": [ @@ -754,6 +794,16 @@ } }, "description": "[Conflict](https://jsonapi.org/format/#crud-creating-responses-409)" + }, + "429": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "too many requests" } }, "tags": [ @@ -1341,6 +1391,16 @@ } }, "description": "not found" + }, + "429": { + "content": { + "application/vnd.api+json": { + "schema": { + "$ref": "#/components/schemas/failure" + } + } + }, + "description": "too many requests" } }, "tags": [ diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index 650876e6..b44ce7a4 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -807,6 +807,7 @@ def _add_generic_failure_responses(self, operation): for code, reason in [ ("400", "bad request"), ("401", "not authorized"), + ("429", "too many requests"), ]: operation["responses"][code] = self._failure_response(reason) From dbe939a5cd23144f46dc1b191f290b4aa59dd8a4 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 4 Jun 2024 23:49:35 +0200 Subject: [PATCH 214/252] Ensured that URL and id field are kept when using sparse fields (#1231) Ensured that URL and id field are not filtered out when using sparse fields URL field is considered a field in DRF but is not in JSON:API spec therefore we may not exclude it. ID on the other hand is a required field and may not be filtered. --- CHANGELOG.md | 4 ++ rest_framework_json_api/renderers.py | 5 +- rest_framework_json_api/serializers.py | 7 ++- tests/serializers.py | 12 +++++ tests/test_views.py | 64 +++++++++++++++++++++++--- tests/views.py | 15 ++++++ 6 files changed, 98 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a61d5faf..47a6dec7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ any parts of the framework not mentioned in the documentation should generally b * Added `429 Too Many Requests` as a possible error response in the OpenAPI schema. +### Fixed + +* Ensured that URL and id field are kept when using sparse fields (regression since 7.0.0) + ## [7.0.0] - 2024-05-02 ### Added diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 8c632f6a..5980b95d 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -446,7 +446,10 @@ def _filter_sparse_fields(cls, serializer, fields, resource_name): return { field_name: field for field_name, field, in fields.items() - if field_name in sparse_fields + if field.field_name in sparse_fields + # URL field is not considered a field in JSON:API spec + # but a link so need to keep it + or field.field_name == api_settings.URL_FIELD_NAME } return fields diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 66650caf..3ba9de86 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -94,10 +94,15 @@ def _readable_fields(self): field for field in readable_fields if field.field_name in sparse_fields + # URL field is not considered a field in JSON:API spec + # but a link so need to keep it or field.field_name == api_settings.URL_FIELD_NAME + # ID is a required field which might have been overwritten + # so need to keep it + or field.field_name == "id" ) except AttributeError: - # no type on serializer, must be used only as only nested + # no type on serializer, may only be used nested pass return readable_fields diff --git a/tests/serializers.py b/tests/serializers.py index ddf28f98..c312b83a 100644 --- a/tests/serializers.py +++ b/tests/serializers.py @@ -1,3 +1,5 @@ +from rest_framework.settings import api_settings + from rest_framework_json_api import serializers from tests.models import ( BasicModel, @@ -32,6 +34,16 @@ class Meta: ) +class ForeignKeySourcetHyperlinkedSerializer(serializers.HyperlinkedModelSerializer): + class Meta: + model = ForeignKeySource + fields = ( + "name", + "target", + api_settings.URL_FIELD_NAME, + ) + + class ManyToManyTargetSerializer(serializers.ModelSerializer): class Meta: fields = ("name",) diff --git a/tests/test_views.py b/tests/test_views.py index 468c2cbd..45f8aaca 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -16,7 +16,9 @@ from tests.serializers import BasicModelSerializer, ForeignKeyTargetSerializer from tests.views import ( BasicModelViewSet, + ForeignKeySourcetHyperlinkedViewSet, ForeignKeySourceViewSet, + ForeignKeyTargetViewSet, ManyToManySourceViewSet, NestedRelatedSourceViewSet, ) @@ -87,7 +89,7 @@ def test_list(self, client, model): @pytest.mark.urls(__name__) def test_list_with_include_foreign_key(self, client, foreign_key_source): - url = reverse("foreign-key-source-list") + url = reverse("foreignkeysource-list") response = client.get(url, data={"include": "target"}) assert response.status_code == status.HTTP_200_OK result = response.json() @@ -156,7 +158,7 @@ def test_list_with_include_nested_related_field( @pytest.mark.urls(__name__) def test_list_with_invalid_include(self, client, foreign_key_source): - url = reverse("foreign-key-source-list") + url = reverse("foreignkeysource-list") response = client.get(url, data={"include": "invalid"}) assert response.status_code == status.HTTP_400_BAD_REQUEST result = response.json() @@ -195,7 +197,7 @@ def test_retrieve(self, client, model): @pytest.mark.urls(__name__) def test_retrieve_with_include_foreign_key(self, client, foreign_key_source): - url = reverse("foreign-key-source-detail", kwargs={"pk": foreign_key_source.pk}) + url = reverse("foreignkeysource-detail", kwargs={"pk": foreign_key_source.pk}) response = client.get(url, data={"include": "target"}) assert response.status_code == status.HTTP_200_OK result = response.json() @@ -208,6 +210,20 @@ def test_retrieve_with_include_foreign_key(self, client, foreign_key_source): } ] == result["included"] + @pytest.mark.urls(__name__) + def test_retrieve_hyperlinked_with_sparse_fields(self, client, foreign_key_source): + url = reverse( + "foreignkeysourcehyperlinked-detail", kwargs={"pk": foreign_key_source.pk} + ) + response = client.get(url, data={"fields[ForeignKeySource]": "name"}) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data["attributes"] == {"name": foreign_key_source.name} + assert "relationships" not in data + assert data["links"] == { + "self": f"http://testserver/foreign_key_sources/{foreign_key_source.pk}/" + } + @pytest.mark.urls(__name__) def test_patch(self, client, model): data = { @@ -239,7 +255,7 @@ def test_delete(self, client, model): @pytest.mark.urls(__name__) def test_create_with_sparse_fields(self, client, foreign_key_target): - url = reverse("foreign-key-source-list") + url = reverse("foreignkeysource-list") data = { "data": { "id": None, @@ -379,6 +395,28 @@ def test_patch_with_custom_id(self, client): } } + @pytest.mark.urls(__name__) + def test_patch_with_custom_id_with_sparse_fields(self, client): + data = { + "data": { + "id": 2_193_102, + "type": "custom", + "attributes": {"body": "hello"}, + } + } + + url = reverse("custom-id") + + response = client.patch(f"{url}?fields[custom]=body", data=data) + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "data": { + "type": "custom", + "id": "2176ce", # get_id() -> hex + "attributes": {"body": "hello"}, + } + } + # Routing setup @@ -415,13 +453,16 @@ class CustomModelSerializer(serializers.Serializer): id = serializers.IntegerField() -class CustomIdModelSerializer(serializers.Serializer): +class CustomIdSerializer(serializers.Serializer): id = serializers.SerializerMethodField() body = serializers.CharField() def get_id(self, obj): return hex(obj.id)[2:] + class Meta: + resource_name = "custom" + class CustomAPIView(APIView): parser_classes = [JSONParser] @@ -443,14 +484,23 @@ class CustomIdAPIView(APIView): resource_name = "custom" def patch(self, request, *args, **kwargs): - serializer = CustomIdModelSerializer(CustomModel(request.data)) + serializer = CustomIdSerializer( + CustomModel(request.data), context={"request": self.request} + ) return Response(status=status.HTTP_200_OK, data=serializer.data) +# TODO remove basename and use default (lowercase of model) +# this makes using HyperlinkedIdentityField easier and reduces +# configuration in general router = SimpleRouter() router.register(r"basic_models", BasicModelViewSet, basename="basic-model") +router.register(r"foreign_key_sources", ForeignKeySourceViewSet) +router.register(r"foreign_key_targets", ForeignKeyTargetViewSet) router.register( - r"foreign_key_sources", ForeignKeySourceViewSet, basename="foreign-key-source" + r"foreign_key_sources_hyperlinked", + ForeignKeySourcetHyperlinkedViewSet, + "foreignkeysourcehyperlinked", ) router.register( r"many_to_many_sources", ManyToManySourceViewSet, basename="many-to-many-source" diff --git a/tests/views.py b/tests/views.py index 72a7ea59..dba769a6 100644 --- a/tests/views.py +++ b/tests/views.py @@ -2,12 +2,15 @@ from tests.models import ( BasicModel, ForeignKeySource, + ForeignKeyTarget, ManyToManySource, NestedRelatedSource, ) from tests.serializers import ( BasicModelSerializer, ForeignKeySourceSerializer, + ForeignKeySourcetHyperlinkedSerializer, + ForeignKeyTargetSerializer, ManyToManySourceSerializer, NestedRelatedSourceSerializer, ) @@ -25,6 +28,18 @@ class ForeignKeySourceViewSet(ModelViewSet): ordering = ["name"] +class ForeignKeySourcetHyperlinkedViewSet(ModelViewSet): + serializer_class = ForeignKeySourcetHyperlinkedSerializer + queryset = ForeignKeySource.objects.all() + ordering = ["name"] + + +class ForeignKeyTargetViewSet(ModelViewSet): + serializer_class = ForeignKeyTargetSerializer + queryset = ForeignKeyTarget.objects.all() + ordering = ["name"] + + class ManyToManySourceViewSet(ModelViewSet): serializer_class = ManyToManySourceSerializer queryset = ManyToManySource.objects.all() From 3f1ea673d4469f1094d9b1c26616babf32be465d Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 6 Jun 2024 15:02:58 +0200 Subject: [PATCH 215/252] Release 7.0.1 (#1232) New release as for regression #1228 --- CHANGELOG.md | 2 +- rest_framework_json_api/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47a6dec7..12ca5017 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [7.0.1] - 2024-06-06 ### Added diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index 6d2c35b4..9a8c48a3 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,5 +1,5 @@ __title__ = "djangorestframework-jsonapi" -__version__ = "7.0.0" +__version__ = "7.0.1" __author__ = "" __license__ = "BSD" __copyright__ = "" From 53469f355dcf5c1dd1bf064ed64abc21325f11b0 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 24 Jun 2024 21:13:34 +0200 Subject: [PATCH 216/252] Re-enabled overwriting of URL field (#1237) Enabled overwriting of URL field URL_FIELD_NAME is usually used as self-link in links. However it should be allowed to be overwritten as long as not HyperlinkedIdentifyField has been used. --- CHANGELOG.md | 6 ++++ rest_framework_json_api/renderers.py | 11 ++++---- rest_framework_json_api/serializers.py | 10 +++++-- tests/conftest.py | 6 ++++ tests/models.py | 8 ++++++ tests/serializers.py | 10 +++++++ tests/test_views.py | 38 ++++++++++++++++++++++++++ tests/views.py | 8 ++++++ 8 files changed, 89 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12ca5017..07ea2cd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## [Unreleased] + +### Fixed + +* Re-enabled overwriting of url field (regression since 7.0.0) + ## [7.0.1] - 2024-06-06 ### Added diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 5980b95d..03a95b30 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -75,13 +75,11 @@ def extract_attributes(cls, fields, resource): and relationships are not returned. """ - invalid_fields = {"id", api_settings.URL_FIELD_NAME} - return { format_field_name(field_name): value for field_name, value in resource.items() if field_name in fields - and field_name not in invalid_fields + and field_name != "id" and not is_relationship_field(fields[field_name]) } @@ -449,7 +447,10 @@ def _filter_sparse_fields(cls, serializer, fields, resource_name): if field.field_name in sparse_fields # URL field is not considered a field in JSON:API spec # but a link so need to keep it - or field.field_name == api_settings.URL_FIELD_NAME + or ( + field.field_name == api_settings.URL_FIELD_NAME + and isinstance(field, relations.HyperlinkedIdentityField) + ) } return fields @@ -486,7 +487,7 @@ def build_json_resource_obj( resource_data["relationships"] = relationships # Add 'self' link if field is present and valid if api_settings.URL_FIELD_NAME in resource and isinstance( - fields[api_settings.URL_FIELD_NAME], relations.RelatedField + fields[api_settings.URL_FIELD_NAME], relations.HyperlinkedIdentityField ): resource_data["links"] = {"self": resource[api_settings.URL_FIELD_NAME]} diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 3ba9de86..370ae37b 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -6,6 +6,7 @@ from django.utils.module_loading import import_string as import_class_from_dotted_path from django.utils.translation import gettext_lazy as _ from rest_framework.exceptions import ParseError +from rest_framework.relations import HyperlinkedIdentityField # star import defined so `rest_framework_json_api.serializers` can be # a simple drop in for `rest_framework.serializers` @@ -94,9 +95,12 @@ def _readable_fields(self): field for field in readable_fields if field.field_name in sparse_fields - # URL field is not considered a field in JSON:API spec - # but a link so need to keep it - or field.field_name == api_settings.URL_FIELD_NAME + # URL_FIELD_NAME is the field used as self-link to resource + # however only when it is a HyperlinkedIdentityField + or ( + field.field_name == api_settings.URL_FIELD_NAME + and isinstance(field, HyperlinkedIdentityField) + ) # ID is a required field which might have been overwritten # so need to keep it or field.field_name == "id" diff --git a/tests/conftest.py b/tests/conftest.py index 682d8342..865244e0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,7 @@ ManyToManySource, ManyToManyTarget, NestedRelatedSource, + URLModel, ) @@ -36,6 +37,11 @@ def model(db): return BasicModel.objects.create(text="Model") +@pytest.fixture +def url_instance(db): + return URLModel.objects.create(text="Url", url="https://example.com") + + @pytest.fixture def foreign_key_target(db): return ForeignKeyTarget.objects.create(name="Target") diff --git a/tests/models.py b/tests/models.py index 812ee5bf..63cb6434 100644 --- a/tests/models.py +++ b/tests/models.py @@ -18,6 +18,14 @@ class Meta: ordering = ("id",) +class URLModel(DJAModel): + url = models.URLField() + text = models.CharField(max_length=100) + + class Meta: + ordering = ("id",) + + # Models for relations tests # ManyToMany class ManyToManyTarget(DJAModel): diff --git a/tests/serializers.py b/tests/serializers.py index c312b83a..ef8a51cf 100644 --- a/tests/serializers.py +++ b/tests/serializers.py @@ -8,6 +8,7 @@ ManyToManySource, ManyToManyTarget, NestedRelatedSource, + URLModel, ) @@ -17,6 +18,15 @@ class Meta: model = BasicModel +class URLModelSerializer(serializers.ModelSerializer): + class Meta: + fields = ( + "text", + "url", + ) + model = URLModel + + class ForeignKeyTargetSerializer(serializers.ModelSerializer): class Meta: fields = ("name",) diff --git a/tests/test_views.py b/tests/test_views.py index 45f8aaca..6dfde90b 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -21,6 +21,7 @@ ForeignKeyTargetViewSet, ManyToManySourceViewSet, NestedRelatedSourceViewSet, + URLModelViewSet, ) @@ -182,6 +183,42 @@ def test_list_with_default_included_resources(self, client, foreign_key_source): } ] == result["included"] + @pytest.mark.urls(__name__) + def test_list_allow_overwriting_url_field(self, client, url_instance): + """ + Test overwriting of url is possible. + + URL_FIELD_NAME which is set to 'url' per default is used as self in links. + However if field is overwritten and not a HyperlinkedIdentityField it should be allowed + to use as a attribute as well. + """ + + url = reverse("urlmodel-list") + response = client.get(url) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data == [ + { + "type": "URLModel", + "id": str(url_instance.pk), + "attributes": {"text": "Url", "url": "https://example.com"}, + } + ] + + @pytest.mark.urls(__name__) + def test_list_allow_overwiritng_url_with_sparse_fields(self, client, url_instance): + url = reverse("urlmodel-list") + response = client.get(url, data={"fields[URLModel]": "text"}) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data == [ + { + "type": "URLModel", + "id": str(url_instance.pk), + "attributes": {"text": "Url"}, + } + ] + @pytest.mark.urls(__name__) def test_retrieve(self, client, model): url = reverse("basic-model-detail", kwargs={"pk": model.pk}) @@ -495,6 +532,7 @@ def patch(self, request, *args, **kwargs): # configuration in general router = SimpleRouter() router.register(r"basic_models", BasicModelViewSet, basename="basic-model") +router.register(r"url_models", URLModelViewSet) router.register(r"foreign_key_sources", ForeignKeySourceViewSet) router.register(r"foreign_key_targets", ForeignKeyTargetViewSet) router.register( diff --git a/tests/views.py b/tests/views.py index dba769a6..7958c6b9 100644 --- a/tests/views.py +++ b/tests/views.py @@ -5,6 +5,7 @@ ForeignKeyTarget, ManyToManySource, NestedRelatedSource, + URLModel, ) from tests.serializers import ( BasicModelSerializer, @@ -13,6 +14,7 @@ ForeignKeyTargetSerializer, ManyToManySourceSerializer, NestedRelatedSourceSerializer, + URLModelSerializer, ) @@ -22,6 +24,12 @@ class BasicModelViewSet(ModelViewSet): ordering = ["text"] +class URLModelViewSet(ModelViewSet): + serializer_class = URLModelSerializer + queryset = URLModel.objects.all() + ordering = ["url"] + + class ForeignKeySourceViewSet(ModelViewSet): serializer_class = ForeignKeySourceSerializer queryset = ForeignKeySource.objects.all() From d1163cedd81a62cb55f6acab3e85bb7f9e7d1c0f Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 28 Jun 2024 13:33:41 +0200 Subject: [PATCH 217/252] Ensured that no fields are returned when sparse fields is set to an empty value (#1240) --- CHANGELOG.md | 1 + rest_framework_json_api/renderers.py | 2 +- rest_framework_json_api/serializers.py | 2 +- tests/test_views.py | 13 +++++++++++++ 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07ea2cd8..d60e4861 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Fixed * Re-enabled overwriting of url field (regression since 7.0.0) +* Ensured that no fields are rendered when sparse fields is set to an empty value. (regression since 7.0.0) ## [7.0.1] - 2024-06-06 diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 03a95b30..8c19934f 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -439,7 +439,7 @@ def _filter_sparse_fields(cls, serializer, fields, resource_name): sparse_fieldset_value = request.query_params.get( sparse_fieldset_query_param ) - if sparse_fieldset_value: + if sparse_fieldset_value is not None: sparse_fields = sparse_fieldset_value.split(",") return { field_name: field diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 370ae37b..d59dbd88 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -89,7 +89,7 @@ def _readable_fields(self): sparse_fieldset_value = request.query_params.get( sparse_fieldset_query_param ) - if sparse_fieldset_value: + if sparse_fieldset_value is not None: sparse_fields = sparse_fieldset_value.split(",") return ( field diff --git a/tests/test_views.py b/tests/test_views.py index 6dfde90b..349d37da 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -219,6 +219,19 @@ def test_list_allow_overwiritng_url_with_sparse_fields(self, client, url_instanc } ] + @pytest.mark.urls(__name__) + def test_list_with_sparse_fields_empty_value(self, client, model): + url = reverse("basic-model-list") + response = client.get(url, data={"fields[BasicModel]": ""}) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data == [ + { + "type": "BasicModel", + "id": str(model.pk), + } + ] + @pytest.mark.urls(__name__) def test_retrieve(self, client, model): url = reverse("basic-model-detail", kwargs={"pk": model.pk}) From 971a8796b79fc4fb9daf26c83d406c19219b428d Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 28 Jun 2024 17:08:57 +0200 Subject: [PATCH 218/252] Release 7.0.2 (#1241) --- CHANGELOG.md | 4 ++-- rest_framework_json_api/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d60e4861..8c3c7720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [7.0.2] - 2024-06-28 ### Fixed -* Re-enabled overwriting of url field (regression since 7.0.0) +* Allow overwriting of url field again (regression since 7.0.0) * Ensured that no fields are rendered when sparse fields is set to an empty value. (regression since 7.0.0) ## [7.0.1] - 2024-06-06 diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index 9a8c48a3..3008837c 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,5 +1,5 @@ __title__ = "djangorestframework-jsonapi" -__version__ = "7.0.1" +__version__ = "7.0.2" __author__ = "" __license__ = "BSD" __copyright__ = "" From 6eff72e1be5fd8359f58c519091016f3e6709e23 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 1 Jul 2024 10:39:28 -0700 Subject: [PATCH 219/252] Scheduled biweekly dependency update for week 26 (#1242) * Update flake8 from 7.0.0 to 7.1.0 * Update twine from 5.0.0 to 5.1.1 * Update faker from 25.0.1 to 26.0.0 * Update pytest from 8.2.0 to 8.2.2 --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 4c270607..50c073e5 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ black==24.4.2 -flake8==7.0.0 +flake8==7.1.0 flake8-bugbear==24.4.26 flake8-isort==6.1.1 isort==5.13.2 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index 489eeb83..e957043a 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==5.0.0 +twine==5.1.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 1dfa20d4..317a12b0 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,6 +1,6 @@ factory-boy==3.3.0 -Faker==25.0.1 -pytest==8.2.0 +Faker==26.0.0 +pytest==8.2.2 pytest-cov==5.0.0 pytest-django==4.8.0 pytest-factoryboy==2.7.0 From 75a5424fb429196675e437889000a9ba40287670 Mon Sep 17 00:00:00 2001 From: Humayun Ahmad Date: Tue, 23 Jul 2024 11:52:45 +0500 Subject: [PATCH 220/252] Handle zero as valid pk/id in get_resource_id util method (#1245) --------- Co-authored-by: Oliver Sauder --- AUTHORS | 1 + CHANGELOG.md | 6 ++++++ rest_framework_json_api/utils.py | 10 ++++------ tests/test_utils.py | 7 +++++++ 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/AUTHORS b/AUTHORS index 7f4a8237..d421d5e6 100644 --- a/AUTHORS +++ b/AUTHORS @@ -15,6 +15,7 @@ David Guillot, for Contexte David Vogt Felix Viernickel Greg Aker +Humayun Ahmad Jamie Bliss Jason Housley Jeppe Fihl-Pearson diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c3c7720..4fb30d1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## [Unreleased] + +### Fixed + +* Handled zero as a valid ID for resource (regression since 6.1.0) + ## [7.0.2] - 2024-06-28 ### Fixed diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index e12080ac..805f5f09 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -307,13 +307,11 @@ def get_resource_type_from_serializer(serializer): def get_resource_id(resource_instance, resource): """Returns the resource identifier for a given instance (`id` takes priority over `pk`).""" if resource and "id" in resource: - return resource["id"] and encoding.force_str(resource["id"]) or None + _id = resource["id"] + return encoding.force_str(_id) if _id is not None else None if resource_instance: - return ( - hasattr(resource_instance, "pk") - and encoding.force_str(resource_instance.pk) - or None - ) + pk = getattr(resource_instance, "pk", None) + return encoding.force_str(pk) if pk is not None else None return None diff --git a/tests/test_utils.py b/tests/test_utils.py index 4e103ae2..a3beb12e 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -403,6 +403,13 @@ class SerializerWithoutResourceName(serializers.Serializer): (None, {"id": 11}, "11"), (object(), {"pk": 11}, None), (BasicModel(id=6), {"id": 11}, "11"), + (BasicModel(id=0), None, "0"), + (None, {"id": 0}, "0"), + ( + BasicModel(id=0), + {"id": 0}, + "0", + ), ], ) def test_get_resource_id(resource_instance, resource, expected): From e4c587177483a6cc0bc3248c6183da6734e97289 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 5 Aug 2024 10:54:51 -0700 Subject: [PATCH 221/252] Scheduled biweekly dependency update for week 31 (#1246) * Update black from 24.4.2 to 24.8.0 * Update flake8 from 7.1.0 to 7.1.1 * Update sphinx from 7.3.7 to 7.4.7 * Update django-filter from 24.2 to 24.3 * Update faker from 26.0.0 to 26.1.0 * Update pytest from 8.2.2 to 8.3.2 --------- Co-authored-by: Oliver Sauder --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-documentation.txt | 2 +- requirements/requirements-optionals.txt | 2 +- requirements/requirements-testing.txt | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 50c073e5..d826f918 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ -black==24.4.2 -flake8==7.1.0 +black==24.8.0 +flake8==7.1.1 flake8-bugbear==24.4.26 flake8-isort==6.1.1 isort==5.13.2 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 13a66219..ea5200ab 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==7.3.7 +Sphinx==7.4.7 sphinx_rtd_theme==2.0.0 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index efc82a58..df0ef24d 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,4 +1,4 @@ -django-filter==24.2 +django-filter==24.3 # once next version has been released (>3.1.0) this # should be set to pinned version again # see https://github.com/django-polymorphic/django-polymorphic/pull/541 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 317a12b0..9c7ebb66 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,6 +1,6 @@ factory-boy==3.3.0 -Faker==26.0.0 -pytest==8.2.2 +Faker==26.1.0 +pytest==8.3.2 pytest-cov==5.0.0 pytest-django==4.8.0 pytest-factoryboy==2.7.0 From cecd31faa99575202f9223f8566e27383a05cb64 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sun, 1 Sep 2024 01:15:31 +0400 Subject: [PATCH 222/252] Added support for Django 5.1 (#1249) --- CHANGELOG.md | 4 ++++ README.rst | 2 +- docs/getting-started.md | 2 +- tox.ini | 5 +++-- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fb30d1b..3b200cdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ any parts of the framework not mentioned in the documentation should generally b * Handled zero as a valid ID for resource (regression since 6.1.0) +### Added + +* Added support for Django 5.1 + ## [7.0.2] - 2024-06-28 ### Fixed diff --git a/README.rst b/README.rst index fd340af6..423f132a 100644 --- a/README.rst +++ b/README.rst @@ -93,7 +93,7 @@ Requirements ------------ 1. Python (3.8, 3.9, 3.10, 3.11, 3.12) -2. Django (4.2, 5.0) +2. Django (4.2, 5.0, 5.1) 3. Django REST framework (3.14, 3.15) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index fd70c79a..cef2c5b4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.8, 3.9, 3.10, 3.11, 3.12) -2. Django (4.2, 5.0) +2. Django (4.2, 5.0, 5.1) 3. Django REST framework (3.14, 3.15) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/tox.ini b/tox.ini index 68646fb4..0f65b588 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist = py{38,39,310,311,312}-django42-drf{314,315,master}, - py{310,311,312}-django50-drf{314,315,master}, + py{310,311,312}-django{50,51}-drf{314,315,master}, black, docs, lint @@ -10,6 +10,7 @@ envlist = deps = django42: Django>=4.2,<4.3 django50: Django>=5.0,<5.1 + django51: Django>=5.1,<5.2 drf314: djangorestframework>=3.14,<3.15 drf315: djangorestframework>=3.15,<3.16 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip @@ -49,5 +50,5 @@ commands = [testenv:py{38,39,310,311,312}-django42-drfmaster] ignore_outcome = true -[testenv:py{310,311,312}-django50-drfmaster] +[testenv:py{310,311,312}-django{50,51}-drfmaster] ignore_outcome = true From e745d6bee508a7ea10080cd51f7f311d5de6e585 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 10 Sep 2024 15:32:16 +0400 Subject: [PATCH 223/252] Ensured that patching a To-Many relationship correctly raises request error (#1251) --- CHANGELOG.md | 2 ++ example/tests/test_views.py | 21 ++++++++++++++++++--- rest_framework_json_api/parsers.py | 2 +- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b200cdf..ae0aaf32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ any parts of the framework not mentioned in the documentation should generally b ### Fixed * Handled zero as a valid ID for resource (regression since 6.1.0) +* Ensured that patching a To-Many relationship with the `RelationshipView` correctly raises request error when passing in `None`. + For emptying a To-Many relationship an empty array should be used as per [JSON:API spec](https://jsonapi.org/format/#crud-updating-to-many-relationships) ### Added diff --git a/example/tests/test_views.py b/example/tests/test_views.py index ad3fe124..47d4adb1 100644 --- a/example/tests/test_views.py +++ b/example/tests/test_views.py @@ -2,6 +2,7 @@ from django.test import RequestFactory, override_settings from django.utils import timezone +from rest_framework import status from rest_framework.exceptions import NotFound from rest_framework.request import Request from rest_framework.reverse import reverse @@ -174,16 +175,30 @@ def test_patch_one_to_many_relationship(self): response = self.client.get(url) assert response.data == request_data["data"] - def test_patch_one_to_many_relaitonship_with_none(self): + def test_patch_one_to_many_relaitonship_with_empty(self): url = f"/blogs/{self.first_entry.id}/relationships/entry_set" - request_data = {"data": None} + + request_data = {"data": []} response = self.client.patch(url, data=request_data) - assert response.status_code == 200, response.content.decode() + assert response.status_code == status.HTTP_200_OK assert response.data == [] response = self.client.get(url) assert response.data == [] + def test_patch_one_to_many_relaitonship_with_none(self): + """ + None for a to many relationship is invalid and should return a request error. + + see https://jsonapi.org/format/#crud-updating-to-many-relationships + """ + + url = f"/blogs/{self.first_entry.id}/relationships/entry_set" + + request_data = {"data": None} + response = self.client.patch(url, data=request_data) + assert response.status_code == status.HTTP_400_BAD_REQUEST + def test_patch_many_to_many_relationship(self): url = f"/entries/{self.first_entry.id}/relationships/authors" request_data = { diff --git a/rest_framework_json_api/parsers.py b/rest_framework_json_api/parsers.py index a0a2aeb2..8940c653 100644 --- a/rest_framework_json_api/parsers.py +++ b/rest_framework_json_api/parsers.py @@ -98,7 +98,7 @@ def parse_data(self, result, parser_context): "Received data contains one or more malformed JSON:API " "Resource Identifier Object(s)" ) - elif not (data.get("id") and data.get("type")): + elif isinstance(data, dict) and not (data.get("id") and data.get("type")): raise ParseError( "Received data is not a valid JSON:API Resource Identifier Object" ) From 5123a267be5abc37dd45c3f982e54c39fe335314 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Tue, 10 Sep 2024 11:48:39 -0700 Subject: [PATCH 224/252] Scheduled biweekly dependency update for week 35 (#1250) * Update flake8-bugbear from 24.4.26 to 24.8.19 * Update pyyaml from 6.0.1 to 6.0.2 * Update factory-boy from 3.3.0 to 3.3.1 * Update faker from 26.1.0 to 28.1.0 * Update syrupy from 4.6.1 to 4.7.1 * Updated to Python 3.10 for checks * As for sphinx-rtd-theme Sphinx needs to be lower than 8 --------- Co-authored-by: Oliver Sauder --- .github/workflows/tests.yml | 4 ++-- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-optionals.txt | 2 +- requirements/requirements-testing.txt | 6 +++--- tox.ini | 6 +++--- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2f966adc..622e45b9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -43,10 +43,10 @@ jobs: tox-env: ["black", "lint", "docs"] steps: - uses: actions/checkout@v2 - - name: Set up Python 3.9 + - name: Set up Python 3.10 uses: actions/setup-python@v2 with: - python-version: 3.9 + python-version: "3.10" - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index d826f918..fcbf26ec 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ black==24.8.0 flake8==7.1.1 -flake8-bugbear==24.4.26 +flake8-bugbear==24.8.19 flake8-isort==6.1.1 isort==5.13.2 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index df0ef24d..589636e6 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -3,5 +3,5 @@ django-filter==24.3 # should be set to pinned version again # see https://github.com/django-polymorphic/django-polymorphic/pull/541 django-polymorphic@git+https://github.com/django-polymorphic/django-polymorphic@master # pyup: ignore -pyyaml==6.0.1 +pyyaml==6.0.2 uritemplate==4.1.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 9c7ebb66..b95d04d8 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ -factory-boy==3.3.0 -Faker==26.1.0 +factory-boy==3.3.1 +Faker==28.1.0 pytest==8.3.2 pytest-cov==5.0.0 pytest-django==4.8.0 pytest-factoryboy==2.7.0 -syrupy==4.6.1 +syrupy==4.7.1 diff --git a/tox.ini b/tox.ini index 0f65b588..2b89dc40 100644 --- a/tox.ini +++ b/tox.ini @@ -25,13 +25,13 @@ commands = pytest --cov --no-cov-on-fail --cov-report xml {posargs} [testenv:black] -basepython = python3.9 +basepython = python3.10 deps = -rrequirements/requirements-codestyle.txt commands = black --check . [testenv:lint] -basepython = python3.9 +basepython = python3.10 deps = -rrequirements/requirements-codestyle.txt -rrequirements/requirements-testing.txt @@ -40,7 +40,7 @@ commands = flake8 [testenv:docs] # keep in sync with .readthedocs.yml -basepython = python3.9 +basepython = python3.10 deps = -rrequirements/requirements-optionals.txt -rrequirements/requirements-documentation.txt From 4ceee03893434056d28318fb6bd4ee85f26d57fe Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 3 Oct 2024 20:42:29 +0400 Subject: [PATCH 225/252] Deprecated built-in support for OpenAPI schema generation (#1253) --- CHANGELOG.md | 4 ++++ docs/usage.md | 20 +++++++++++++++++++- example/tests/test_openapi.py | 3 +++ rest_framework_json_api/schemas/openapi.py | 10 ++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae0aaf32..cebd3f8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ any parts of the framework not mentioned in the documentation should generally b * Added support for Django 5.1 +### Deprecated + +* Deprecated built-in support for generating OpenAPI schema. Use [drf-spectacular-json-api](https://github.com/jokiefer/drf-spectacular-json-api/) instead. + ## [7.0.2] - 2024-06-28 ### Fixed diff --git a/docs/usage.md b/docs/usage.md index a50a97f7..1a2cb195 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -31,7 +31,6 @@ REST_FRAMEWORK = { 'rest_framework_json_api.renderers.BrowsableAPIRenderer' ), 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', - 'DEFAULT_SCHEMA_CLASS': 'rest_framework_json_api.schemas.openapi.AutoSchema', 'DEFAULT_FILTER_BACKENDS': ( 'rest_framework_json_api.filters.QueryParameterValidationFilter', 'rest_framework_json_api.filters.OrderingFilter', @@ -1062,6 +1061,20 @@ DRF has a [OAS schema functionality](https://www.django-rest-framework.org/api-g DJA extends DRF's schema support to generate an OAS schema in the JSON:API format. +--- + +**Deprecation notice:** + +REST framework's built-in support for generating OpenAPI schemas is +**deprecated** in favor of 3rd party packages that can provide this +functionality instead. Therefore we have also deprecated the schema support in +Django REST framework JSON:API. The built-in support will be retired over the +next releases. + +As a full-fledged replacement, we recommend the [drf-spectacular-json-api] package. + +--- + ### AutoSchema Settings In order to produce an OAS schema that properly represents the JSON:API structure @@ -1187,3 +1200,8 @@ We aim to make creating third party packages as easy as possible, whilst keeping To submit new content, [open an issue](https://github.com/django-json-api/django-rest-framework-json-api/issues/new/choose) or [create a pull request](https://github.com/django-json-api/django-rest-framework-json-api/compare). * [drf-yasg-json-api](https://github.com/glowka/drf-yasg-json-api) - Automated generation of Swagger/OpenAPI 2.0 from Django REST framework JSON:API endpoints. +* [drf-spectacular-json-api] - OpenAPI 3 schema generator for Django REST framework JSON:API based on drf-spectacular. + + + +[drf-spectacular-json-api]: https://github.com/jokiefer/drf-spectacular-json-api/ diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py index 2333dd6a..fa2f9c73 100644 --- a/example/tests/test_openapi.py +++ b/example/tests/test_openapi.py @@ -1,6 +1,7 @@ # largely based on DRF's test_openapi import json +import pytest from django.test import RequestFactory, override_settings from django.urls import re_path from rest_framework.request import Request @@ -9,6 +10,8 @@ from example import views +pytestmark = pytest.mark.filterwarnings("ignore:Built-in support") + def create_request(path): factory = RequestFactory() diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py index b44ce7a4..6892e991 100644 --- a/rest_framework_json_api/schemas/openapi.py +++ b/rest_framework_json_api/schemas/openapi.py @@ -423,6 +423,16 @@ def get_operation(self, path, method): - collections - special handling for POST, PATCH, DELETE """ + + warnings.warn( + DeprecationWarning( + "Built-in support for generating OpenAPI schema is deprecated. " + "Use drf-spectacular-json-api instead see " + "https://github.com/jokiefer/drf-spectacular-json-api/" + ), + stacklevel=2, + ) + operation = {} operation["operationId"] = self.get_operation_id(path, method) operation["description"] = self.get_description(path, method) From b0c6aee1e7e7edc5e32bf5ae992eaa6b3b40840a Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 7 Oct 2024 08:17:57 -0700 Subject: [PATCH 226/252] Scheduled biweekly dependency update for week 40 (#1254) * Update sphinx from 7.4.7 to 8.0.2 * Update sphinx_rtd_theme from 2.0.0 to 3.0.0 * Update faker from 28.1.0 to 30.1.0 * Update pytest from 8.3.2 to 8.3.3 * Update pytest-django from 4.8.0 to 4.9.0 * Update syrupy from 4.7.1 to 4.7.2 --- requirements/requirements-documentation.txt | 4 ++-- requirements/requirements-testing.txt | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index ea5200ab..4a2139cd 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==7.4.7 -sphinx_rtd_theme==2.0.0 +Sphinx==8.0.2 +sphinx_rtd_theme==3.0.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index b95d04d8..05c423b4 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.3.1 -Faker==28.1.0 -pytest==8.3.2 +Faker==30.1.0 +pytest==8.3.3 pytest-cov==5.0.0 -pytest-django==4.8.0 +pytest-django==4.9.0 pytest-factoryboy==2.7.0 -syrupy==4.7.1 +syrupy==4.7.2 From c82ea18d5eb71c1cc0bceee6869eb426c53060f0 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 8 Oct 2024 15:26:26 +0400 Subject: [PATCH 227/252] Bumped docs Python version to 3.10 (#1255) --- .readthedocs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 997afe3f..fa503604 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -3,7 +3,7 @@ version: 2 build: os: "ubuntu-22.04" tools: - python: "3.9" + python: "3.10" sphinx: configuration: docs/conf.py From c686ded0559dcb6cddc630c72ab163c720175970 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 8 Oct 2024 15:51:59 +0400 Subject: [PATCH 228/252] Added support for 3.13 (#1256) * Added support for 3.13 This will only be supported on Django 5.1 and above. see https://forum.djangoproject.com/t/backport-python-3-13-support-in-django-5-0/34671 * As Github as not officially released 3.13 yet use latest dev version * There was an upstream change in DRF which as not been release yet so only master till it is released. see https://github.com/encode/django-rest-framework/pull/9527/files * Remove ignoring with drfmaster --- .github/workflows/tests.yml | 4 ++-- CHANGELOG.md | 1 + README.rst | 2 +- docs/getting-started.md | 2 +- setup.py | 1 + tox.ini | 7 +------ 6 files changed, 7 insertions(+), 10 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 622e45b9..29a36a3c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13-dev"] env: PYTHON: ${{ matrix.python-version }} steps: @@ -28,7 +28,7 @@ jobs: python -m pip install --upgrade pip pip install tox - name: Run tox targets for ${{ matrix.python-version }} - run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d .) + run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d . | cut -f 1 -d '-') - name: Upload coverage report uses: codecov/codecov-action@v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index cebd3f8a..a0ad16ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ any parts of the framework not mentioned in the documentation should generally b ### Added * Added support for Django 5.1 +* Added support for Python 3.13 ### Deprecated diff --git a/README.rst b/README.rst index 423f132a..0c9b842f 100644 --- a/README.rst +++ b/README.rst @@ -92,7 +92,7 @@ As a Django REST framework JSON:API (short DJA) we are trying to address followi Requirements ------------ -1. Python (3.8, 3.9, 3.10, 3.11, 3.12) +1. Python (3.8, 3.9, 3.10, 3.11, 3.12, 3.13) 2. Django (4.2, 5.0, 5.1) 3. Django REST framework (3.14, 3.15) diff --git a/docs/getting-started.md b/docs/getting-started.md index cef2c5b4..a7de353a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -51,7 +51,7 @@ like the following: ## Requirements -1. Python (3.8, 3.9, 3.10, 3.11, 3.12) +1. Python (3.8, 3.9, 3.10, 3.11, 3.12, 3.13) 2. Django (4.2, 5.0, 5.1) 3. Django REST framework (3.14, 3.15) diff --git a/setup.py b/setup.py index 95d5ca0b..652ab85b 100755 --- a/setup.py +++ b/setup.py @@ -91,6 +91,7 @@ def get_package_data(package): "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Internet :: WWW/HTTP", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", diff --git a/tox.ini b/tox.ini index 2b89dc40..a2accaad 100644 --- a/tox.ini +++ b/tox.ini @@ -2,6 +2,7 @@ envlist = py{38,39,310,311,312}-django42-drf{314,315,master}, py{310,311,312}-django{50,51}-drf{314,315,master}, + py313-django51-drf{master}, black, docs, lint @@ -46,9 +47,3 @@ deps = -rrequirements/requirements-documentation.txt commands = sphinx-build -W -b html -d docs/_build/doctrees docs docs/_build/html - -[testenv:py{38,39,310,311,312}-django42-drfmaster] -ignore_outcome = true - -[testenv:py{310,311,312}-django{50,51}-drfmaster] -ignore_outcome = true From 9598b0c259b3cb0bc9a35e8c0b1acddca948c47b Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 9 Oct 2024 15:21:14 +0400 Subject: [PATCH 229/252] Remove obsolete CodeQL Workflow (#1257) --- .github/workflows/codeql-analysis.yml | 74 --------------------------- 1 file changed, 74 deletions(-) delete mode 100644 .github/workflows/codeql-analysis.yml diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index 8faf1a2f..00000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,74 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [ "main" ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ "main" ] - schedule: - - cron: '39 4 * * 2' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] - # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 - - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 - with: - category: "/language:${{matrix.language}}" From dc2354bf221e965cb43c96d4b1fdbc7a5d1b29df Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 9 Oct 2024 21:26:26 +0400 Subject: [PATCH 230/252] Use final release of 3.13 (#1258) This has been released now on Github. --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 29a36a3c..ca736986 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13-dev"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] env: PYTHON: ${{ matrix.python-version }} steps: From cedeb3b5bd7c4b342109b0180e39eb15109ac78e Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 21 Oct 2024 08:02:08 -0700 Subject: [PATCH 231/252] Scheduled biweekly dependency update for week 42 (#1259) * Update black from 24.8.0 to 24.10.0 * Update sphinx from 8.0.2 to 8.1.3 * Update sphinx_rtd_theme from 3.0.0 to 3.0.1 * Update faker from 30.1.0 to 30.6.0 --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-documentation.txt | 4 ++-- requirements/requirements-testing.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index fcbf26ec..f5e5e92c 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,4 +1,4 @@ -black==24.8.0 +black==24.10.0 flake8==7.1.1 flake8-bugbear==24.8.19 flake8-isort==6.1.1 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index 4a2139cd..aa120a8e 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 -Sphinx==8.0.2 -sphinx_rtd_theme==3.0.0 +Sphinx==8.1.3 +sphinx_rtd_theme==3.0.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 05c423b4..b56d8185 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,5 +1,5 @@ factory-boy==3.3.1 -Faker==30.1.0 +Faker==30.6.0 pytest==8.3.3 pytest-cov==5.0.0 pytest-django==4.9.0 From f2c489bff211cd38d4f5850b2cbc9373958e4037 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 25 Oct 2024 14:26:59 +0400 Subject: [PATCH 232/252] Release 7.1.0 (#1260) --- CHANGELOG.md | 2 +- rest_framework_json_api/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0ad16ed..07cd7d8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [7.1.0] - 2024-10-25 ### Fixed diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index 3008837c..a69daa9f 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,5 +1,5 @@ __title__ = "djangorestframework-jsonapi" -__version__ = "7.0.2" +__version__ = "7.1.0" __author__ = "" __license__ = "BSD" __copyright__ = "" From 1654ae1f407dcf44a66a11fc19d68a9d8e2e5de3 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 18 Nov 2024 08:27:37 -0800 Subject: [PATCH 233/252] Scheduled biweekly dependency update for week 46 (#1263) * Update flake8-bugbear from 24.8.19 to 24.10.31 * Update sphinx_rtd_theme from 3.0.1 to 3.0.2 * Update faker from 30.6.0 to 33.0.0 * Update pytest-cov from 5.0.0 to 6.0.0 * Revert pytest-cov currently still need to support 3.8 --------- Co-authored-by: Oliver Sauder --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-documentation.txt | 2 +- requirements/requirements-testing.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index f5e5e92c..83eab224 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ black==24.10.0 flake8==7.1.1 -flake8-bugbear==24.8.19 +flake8-bugbear==24.10.31 flake8-isort==6.1.1 isort==5.13.2 diff --git a/requirements/requirements-documentation.txt b/requirements/requirements-documentation.txt index aa120a8e..76f3bf9a 100644 --- a/requirements/requirements-documentation.txt +++ b/requirements/requirements-documentation.txt @@ -1,3 +1,3 @@ recommonmark==0.7.1 Sphinx==8.1.3 -sphinx_rtd_theme==3.0.1 +sphinx_rtd_theme==3.0.2 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index b56d8185..63ed4a7c 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,5 +1,5 @@ factory-boy==3.3.1 -Faker==30.6.0 +Faker==33.0.0 pytest==8.3.3 pytest-cov==5.0.0 pytest-django==4.9.0 From 1b5fb9c8846bff9e3b6ba56d01a7cb033cd0a281 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sun, 12 Jan 2025 23:57:26 -0500 Subject: [PATCH 234/252] Removed support for Python 3.8 (#1266) --- .github/workflows/tests.yml | 2 +- CHANGELOG.md | 9 +++++++++ README.rst | 2 +- docs/getting-started.md | 2 +- setup.py | 3 +-- tox.ini | 2 +- 6 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ca736986..0852bba1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] env: PYTHON: ${{ matrix.python-version }} steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 07cd7d8f..f1825191 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## [unreleased] + +### Removed + +* Removed support for Python 3.8. + + ## [7.1.0] - 2024-10-25 +This is the last release supporting Python 3.8. + ### Fixed * Handled zero as a valid ID for resource (regression since 6.1.0) diff --git a/README.rst b/README.rst index 0c9b842f..bf5daa73 100644 --- a/README.rst +++ b/README.rst @@ -92,7 +92,7 @@ As a Django REST framework JSON:API (short DJA) we are trying to address followi Requirements ------------ -1. Python (3.8, 3.9, 3.10, 3.11, 3.12, 3.13) +1. Python (3.9, 3.10, 3.11, 3.12, 3.13) 2. Django (4.2, 5.0, 5.1) 3. Django REST framework (3.14, 3.15) diff --git a/docs/getting-started.md b/docs/getting-started.md index a7de353a..1799337b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -51,7 +51,7 @@ like the following: ## Requirements -1. Python (3.8, 3.9, 3.10, 3.11, 3.12, 3.13) +1. Python (3.9, 3.10, 3.11, 3.12, 3.13) 2. Django (4.2, 5.0, 5.1) 3. Django REST framework (3.14, 3.15) diff --git a/setup.py b/setup.py index 652ab85b..779d00c1 100755 --- a/setup.py +++ b/setup.py @@ -86,7 +86,6 @@ def get_package_data(package): "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -116,6 +115,6 @@ def get_package_data(package): "openapi": ["pyyaml>=5.4", "uritemplate>=3.0.1"], }, setup_requires=wheel, - python_requires=">=3.8", + python_requires=">=3.9", zip_safe=False, ) diff --git a/tox.ini b/tox.ini index a2accaad..504a9d2e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{38,39,310,311,312}-django42-drf{314,315,master}, + py{39,310,311,312}-django42-drf{314,315,master}, py{310,311,312}-django{50,51}-drf{314,315,master}, py313-django51-drf{master}, black, From d297405f79fab39b379e69da8fc93986c36105ed Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 20 Jan 2025 08:32:01 -0800 Subject: [PATCH 235/252] Scheduled biweekly dependency update for week 03 (#1268) * Update flake8-bugbear from 24.10.31 to 24.12.12 * Update twine from 5.1.1 to 6.0.1 * Update faker from 33.0.0 to 33.3.1 * Update pytest from 8.3.3 to 8.3.4 * Update pytest-cov from 5.0.0 to 6.0.0 * Update syrupy from 4.7.2 to 4.8.1 --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 83eab224..025c050b 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ black==24.10.0 flake8==7.1.1 -flake8-bugbear==24.10.31 +flake8-bugbear==24.12.12 flake8-isort==6.1.1 isort==5.13.2 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index e957043a..09ce4dfb 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==5.1.1 +twine==6.0.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 63ed4a7c..e2f9b287 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.3.1 -Faker==33.0.0 -pytest==8.3.3 -pytest-cov==5.0.0 +Faker==33.3.1 +pytest==8.3.4 +pytest-cov==6.0.0 pytest-django==4.9.0 pytest-factoryboy==2.7.0 -syrupy==4.7.2 +syrupy==4.8.1 From abfda8fd48cb6758dcda5f60f95db7e09cb87ee4 Mon Sep 17 00:00:00 2001 From: Vitaliy Date: Thu, 23 Jan 2025 21:43:50 +0500 Subject: [PATCH 236/252] Update documentation to include --pythonpath to set up the example app (#1270) Add --pythonpath to instructions for setting up example app --- README.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index bf5daa73..83d5d7ab 100644 --- a/README.rst +++ b/README.rst @@ -149,9 +149,9 @@ installed and activated: $ git clone https://github.com/django-json-api/django-rest-framework-json-api.git $ cd django-rest-framework-json-api $ pip install -Ur requirements.txt - $ django-admin migrate --settings=example.settings - $ django-admin loaddata drf_example --settings=example.settings - $ django-admin runserver --settings=example.settings + $ django-admin migrate --settings=example.settings --pythonpath . + $ django-admin loaddata drf_example --settings=example.settings --pythonpath . + $ django-admin runserver --settings=example.settings --pythonpath . Browse to From 0476cbc9f5f5bee226481506340a5f5c06479e24 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 3 Feb 2025 10:13:47 -0800 Subject: [PATCH 237/252] Scheduled biweekly dependency update for week 05 (#1271) * Update black from 24.10.0 to 25.1.0 * Update flake8-isort from 6.1.1 to 6.1.2 * Update isort from 5.13.2 to 6.0.0 * Update twine from 6.0.1 to 6.1.0 * Update factory-boy from 3.3.1 to 3.3.3 * Update faker from 33.3.1 to 35.2.0 --- requirements/requirements-codestyle.txt | 6 +++--- requirements/requirements-packaging.txt | 2 +- requirements/requirements-testing.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 025c050b..1799b010 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ -black==24.10.0 +black==25.1.0 flake8==7.1.1 flake8-bugbear==24.12.12 -flake8-isort==6.1.1 -isort==5.13.2 +flake8-isort==6.1.2 +isort==6.0.0 diff --git a/requirements/requirements-packaging.txt b/requirements/requirements-packaging.txt index 09ce4dfb..54882779 100644 --- a/requirements/requirements-packaging.txt +++ b/requirements/requirements-packaging.txt @@ -1 +1 @@ -twine==6.0.1 +twine==6.1.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index e2f9b287..0f58e66a 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,5 +1,5 @@ -factory-boy==3.3.1 -Faker==33.3.1 +factory-boy==3.3.3 +Faker==35.2.0 pytest==8.3.4 pytest-cov==6.0.0 pytest-django==4.9.0 From eac2f9f766aa97c6ff27c56b7bdae831366c10d7 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Wed, 16 Apr 2025 20:44:32 +0400 Subject: [PATCH 238/252] Added support for Django REST framework 3.16 (#1279) --- CHANGELOG.md | 9 +++++++-- README.rst | 2 +- docs/getting-started.md | 2 +- setup.py | 2 +- tox.ini | 6 +++--- 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1825191..7a6ae7e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,16 +8,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [unreleased] +## [Unreleased] + +### Added + +* Added support for Django REST framework 3.16. ### Removed * Removed support for Python 3.8. +* Removed support for Django REST framework 3.14. ## [7.1.0] - 2024-10-25 -This is the last release supporting Python 3.8. +This is the last release supporting Python 3.8 and Django REST framework 3.14. ### Fixed diff --git a/README.rst b/README.rst index 83d5d7ab..21b2e3f0 100644 --- a/README.rst +++ b/README.rst @@ -94,7 +94,7 @@ Requirements 1. Python (3.9, 3.10, 3.11, 3.12, 3.13) 2. Django (4.2, 5.0, 5.1) -3. Django REST framework (3.14, 3.15) +3. Django REST framework (3.15, 3.16) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index 1799337b..461419c7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -53,7 +53,7 @@ like the following: 1. Python (3.9, 3.10, 3.11, 3.12, 3.13) 2. Django (4.2, 5.0, 5.1) -3. Django REST framework (3.14, 3.15) +3. Django REST framework (3.15, 3.16) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/setup.py b/setup.py index 779d00c1..de61b0d1 100755 --- a/setup.py +++ b/setup.py @@ -106,7 +106,7 @@ def get_package_data(package): }, install_requires=[ "inflection>=0.5.0", - "djangorestframework>=3.14", + "djangorestframework>=3.15", "django>=4.2", ], extras_require={ diff --git a/tox.ini b/tox.ini index 504a9d2e..45a4e20b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist = - py{39,310,311,312}-django42-drf{314,315,master}, - py{310,311,312}-django{50,51}-drf{314,315,master}, + py{39,310,311,312}-django42-drf{315,316,master}, + py{310,311,312}-django{50,51}-drf{315,316,master}, py313-django51-drf{master}, black, docs, @@ -12,8 +12,8 @@ deps = django42: Django>=4.2,<4.3 django50: Django>=5.0,<5.1 django51: Django>=5.1,<5.2 - drf314: djangorestframework>=3.14,<3.15 drf315: djangorestframework>=3.15,<3.16 + drf316: djangorestframework>=3.16,<3.17 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip -rrequirements/requirements-testing.txt -rrequirements/requirements-optionals.txt From 76b4c41944bc142ba4241d0fc258ce8070444b41 Mon Sep 17 00:00:00 2001 From: "pyup.io bot" Date: Mon, 21 Apr 2025 09:32:44 -0700 Subject: [PATCH 239/252] Scheduled biweekly dependency update for week 16 (#1280) * Update flake8 from 7.1.1 to 7.2.0 * Update isort from 6.0.0 to 6.0.1 * Update faker from 35.2.0 to 37.1.0 * Update pytest from 8.3.4 to 8.3.5 * Update pytest-cov from 6.0.0 to 6.1.1 * Update pytest-django from 4.9.0 to 4.11.1 * Update syrupy from 4.8.1 to 4.9.1 --- requirements/requirements-codestyle.txt | 4 ++-- requirements/requirements-testing.txt | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 1799b010..1d4184ad 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ black==25.1.0 -flake8==7.1.1 +flake8==7.2.0 flake8-bugbear==24.12.12 flake8-isort==6.1.2 -isort==6.0.0 +isort==6.0.1 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 0f58e66a..35caa0a9 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.3.3 -Faker==35.2.0 -pytest==8.3.4 -pytest-cov==6.0.0 -pytest-django==4.9.0 +Faker==37.1.0 +pytest==8.3.5 +pytest-cov==6.1.1 +pytest-django==4.11.1 pytest-factoryboy==2.7.0 -syrupy==4.8.1 +syrupy==4.9.1 From 0394cf91b87f6d488be3da8ef585df325786c73a Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 21 Apr 2025 21:21:50 +0400 Subject: [PATCH 240/252] Updated Django support (#1281) * Updated Django support Added support for Django 5.2 Removed outdated Django 5.0 * Python 3.13 should not run DRF 3.15 tests * Only DRF 3.16 supports Python 3.13 --- CHANGELOG.md | 4 +++- README.rst | 2 +- docs/getting-started.md | 2 +- tox.ini | 6 +++--- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a6ae7e9..3ec7ff7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,16 +13,18 @@ any parts of the framework not mentioned in the documentation should generally b ### Added * Added support for Django REST framework 3.16. +* Added support for Django 5.2. ### Removed * Removed support for Python 3.8. * Removed support for Django REST framework 3.14. +* Removed support for Django 5.0. ## [7.1.0] - 2024-10-25 -This is the last release supporting Python 3.8 and Django REST framework 3.14. +This is the last release supporting Python 3.8, Django 5.0 and Django REST framework 3.14. ### Fixed diff --git a/README.rst b/README.rst index 21b2e3f0..c0e95a19 100644 --- a/README.rst +++ b/README.rst @@ -93,7 +93,7 @@ Requirements ------------ 1. Python (3.9, 3.10, 3.11, 3.12, 3.13) -2. Django (4.2, 5.0, 5.1) +2. Django (4.2, 5.1, 5.2) 3. Django REST framework (3.15, 3.16) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/docs/getting-started.md b/docs/getting-started.md index 461419c7..4052450b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,7 +52,7 @@ like the following: ## Requirements 1. Python (3.9, 3.10, 3.11, 3.12, 3.13) -2. Django (4.2, 5.0, 5.1) +2. Django (4.2, 5.1, 5.2) 3. Django REST framework (3.15, 3.16) We **highly** recommend and only officially support the latest patch release of each Python, Django and REST framework series. diff --git a/tox.ini b/tox.ini index 45a4e20b..8dd0e759 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,8 @@ [tox] envlist = py{39,310,311,312}-django42-drf{315,316,master}, - py{310,311,312}-django{50,51}-drf{315,316,master}, - py313-django51-drf{master}, + py{310,311,312}-django{51,52}-drf{315,316,master}, + py{313}-django{51,52}-drf{316,master}, black, docs, lint @@ -10,8 +10,8 @@ envlist = [testenv] deps = django42: Django>=4.2,<4.3 - django50: Django>=5.0,<5.1 django51: Django>=5.1,<5.2 + django52: Django>=5.2,<5.3 drf315: djangorestframework>=3.15,<3.16 drf316: djangorestframework>=3.16,<3.17 drfmaster: https://github.com/encode/django-rest-framework/archive/master.zip From 87108d4d6472b0c76e8c8fff53a6f08a026dd319 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 14 Jul 2025 20:38:45 +0700 Subject: [PATCH 241/252] Ensured that `include` param is properly underscored (#1283) nsured that interpreting `include` query parameter is done in internal Python naming. --- CHANGELOG.md | 5 +++++ rest_framework_json_api/renderers.py | 4 ---- rest_framework_json_api/serializers.py | 3 +-- rest_framework_json_api/utils.py | 12 ++++++++++-- tests/conftest.py | 7 ++++++- tests/test_utils.py | 21 +++++++++++++++++++++ 6 files changed, 43 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ec7ff7d..eae89c70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ any parts of the framework not mentioned in the documentation should generally b * Added support for Django REST framework 3.16. * Added support for Django 5.2. +### Fixed + +* Ensured that interpreting `include` query parameter is done in internal Python naming. + This adds full support for using multipart field names for includes while configuring `JSON_API_FORMAT_FIELD_NAMES`. + ### Removed * Removed support for Python 3.8. diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index 8c19934f..b670338f 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -6,7 +6,6 @@ from collections import defaultdict from collections.abc import Iterable -import inflection from django.db.models import Manager from django.template import loader from django.utils.encoding import force_str @@ -277,9 +276,6 @@ def extract_included( current_serializer, "included_serializers", dict() ) included_resources = copy.copy(included_resources) - included_resources = [ - inflection.underscore(value) for value in included_resources - ] for field_name, field in iter(fields.items()): # Skip URL field diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index d59dbd88..75764a5d 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -1,6 +1,5 @@ from collections.abc import Mapping -import inflection from django.core.exceptions import ObjectDoesNotExist from django.db.models.query import QuerySet from django.utils.module_loading import import_string as import_class_from_dotted_path @@ -129,7 +128,7 @@ def validate_path(serializer_class, field_path, path): serializers = getattr(serializer_class, "included_serializers", None) if serializers is None: raise ParseError("This endpoint does not support the include parameter") - this_field_name = inflection.underscore(field_path[0]) + this_field_name = field_path[0] this_included_serializer = serializers.get(this_field_name) if this_included_serializer is None: raise ParseError( diff --git a/rest_framework_json_api/utils.py b/rest_framework_json_api/utils.py index 805f5f09..2dd79677 100644 --- a/rest_framework_json_api/utils.py +++ b/rest_framework_json_api/utils.py @@ -316,10 +316,18 @@ def get_resource_id(resource_instance, resource): def get_included_resources(request, serializer=None): - """Build a list of included resources.""" + """ + Build a list of included resources. + + This method ensures that returned includes are in Python internally used + format. + """ include_resources_param = request.query_params.get("include") if request else None if include_resources_param: - return include_resources_param.split(",") + return [ + undo_format_field_name(include) + for include in include_resources_param.split(",") + ] else: return get_default_included_resources_from_serializer(serializer) diff --git a/tests/conftest.py b/tests/conftest.py index 865244e0..77b3676b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,5 @@ import pytest -from rest_framework.test import APIClient +from rest_framework.test import APIClient, APIRequestFactory from tests.models import ( BasicModel, @@ -98,3 +98,8 @@ def nested_related_source( @pytest.fixture def client(): return APIClient() + + +@pytest.fixture +def rf(): + return APIRequestFactory() diff --git a/tests/test_utils.py b/tests/test_utils.py index a3beb12e..08e36b6a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,6 +2,7 @@ from rest_framework import status from rest_framework.fields import Field from rest_framework.generics import GenericAPIView +from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView @@ -13,6 +14,7 @@ format_link_segment, format_resource_type, format_value, + get_included_resources, get_related_resource_type, get_resource_id, get_resource_name, @@ -456,3 +458,22 @@ def test_get_resource_id(resource_instance, resource, expected): ) def test_format_error_object(message, pointer, response, result): assert result == format_error_object(message, pointer, response) + + +@pytest.mark.parametrize( + "format_type,include_param,expected_includes", + [ + ("dasherize", "author-bio", ["author_bio"]), + ("dasherize", "author-bio,author-type", ["author_bio", "author_type"]), + ("dasherize", "author-bio.author-type", ["author_bio.author_type"]), + ("camelize", "authorBio", ["author_bio"]), + ], +) +def test_get_included_resources( + rf, include_param, expected_includes, format_type, settings +): + settings.JSON_API_FORMAT_FIELD_NAMES = format_type + + request = Request(rf.get("/test/", {"include": include_param})) + includes = get_included_resources(request) + assert includes == expected_includes From 2dc557144954c99f7a97e940b253358218917502 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 18 Jul 2025 17:35:40 +0700 Subject: [PATCH 242/252] Ensured that sparse fieldset support formatted field names (#1286) --- CHANGELOG.md | 1 + rest_framework_json_api/serializers.py | 6 +++++- tests/test_serializers.py | 20 ++++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eae89c70..9502f476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ any parts of the framework not mentioned in the documentation should generally b * Ensured that interpreting `include` query parameter is done in internal Python naming. This adds full support for using multipart field names for includes while configuring `JSON_API_FORMAT_FIELD_NAMES`. +* Ensured that sparse fieldset fully supports `JSON_API_FORMAT_FIELD_NAMES`. ### Removed diff --git a/rest_framework_json_api/serializers.py b/rest_framework_json_api/serializers.py index 75764a5d..26f6b02e 100644 --- a/rest_framework_json_api/serializers.py +++ b/rest_framework_json_api/serializers.py @@ -26,6 +26,7 @@ get_resource_type_from_instance, get_resource_type_from_model, get_resource_type_from_serializer, + undo_format_field_name, ) @@ -89,7 +90,10 @@ def _readable_fields(self): sparse_fieldset_query_param ) if sparse_fieldset_value is not None: - sparse_fields = sparse_fieldset_value.split(",") + sparse_fields = [ + undo_format_field_name(sparse_field) + for sparse_field in sparse_fieldset_value.split(",") + ] return ( field for field in readable_fields diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 9d4200a3..98cb2850 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -1,5 +1,6 @@ import pytest from django.db import models +from rest_framework.request import Request from rest_framework.utils import model_meta from rest_framework_json_api import serializers @@ -84,3 +85,22 @@ class Meta: "verified", "uuid", ] + + +def test_readable_fields_with_sparse_fields(client, rf, settings): + class TestSerializer(serializers.Serializer): + name = serializers.CharField() + value = serializers.CharField() + multi_part_name = serializers.CharField() + + class Meta: + resource_name = "test" + + settings.JSON_API_FORMAT_FIELD_NAMES = "camelize" + request = Request(rf.get("/test/", {"fields[test]": "value,multiPartName"})) + context = {"request": request} + serializer = TestSerializer(context=context) + assert [field.field_name for field in serializer._readable_fields] == [ + "value", + "multi_part_name", + ] From 7bb4c086d8e682e771309bc11e01b48713737ad6 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Fri, 18 Jul 2025 20:08:54 +0700 Subject: [PATCH 243/252] Removed obsolete example requirements.txt file (#1287) --- example/requirements.txt | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 example/requirements.txt diff --git a/example/requirements.txt b/example/requirements.txt deleted file mode 100644 index 2f4213ee..00000000 --- a/example/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -# Requirements specifically for the example app -Django>=1.11 -django-debug-toolbar -django-polymorphic>=2.0 -djangorestframework -inflection -pluggy -py -pyparsing -pytz -sqlparse -django-filter>=2.0 From e9a7f5fbfda5860ffe9a69a7b494ca00cc2a1ec3 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 21 Jul 2025 21:47:16 +0700 Subject: [PATCH 244/252] Removed built-in OpenAPI support (#1288) * Removed built-in OpenAPI support * Removed django filter schema methods --- CHANGELOG.md | 2 +- README.rst | 3 - docs/getting-started.md | 3 - docs/usage.md | 133 -- example/settings/dev.py | 1 - example/templates/swagger-ui.html | 28 - example/tests/__snapshots__/test_openapi.ambr | 1414 ----------------- example/tests/test_openapi.py | 230 --- .../tests/unit/test_filter_schema_params.py | 107 -- example/urls.py | 22 - requirements/requirements-optionals.txt | 2 - .../django_filters/backends.py | 17 +- rest_framework_json_api/schemas/openapi.py | 905 ----------- setup.cfg | 3 - setup.py | 1 - tests/schemas/test_openapi.py | 11 - 16 files changed, 2 insertions(+), 2880 deletions(-) delete mode 100644 example/templates/swagger-ui.html delete mode 100644 example/tests/__snapshots__/test_openapi.ambr delete mode 100644 example/tests/test_openapi.py delete mode 100644 example/tests/unit/test_filter_schema_params.py delete mode 100644 rest_framework_json_api/schemas/openapi.py delete mode 100644 tests/schemas/test_openapi.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9502f476..63865fbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ any parts of the framework not mentioned in the documentation should generally b * Removed support for Python 3.8. * Removed support for Django REST framework 3.14. * Removed support for Django 5.0. - +* Removed built-in support for generating OpenAPI schema. Use [drf-spectacular-json-api](https://github.com/jokiefer/drf-spectacular-json-api/) instead. ## [7.1.0] - 2024-10-25 diff --git a/README.rst b/README.rst index c0e95a19..05c01c04 100644 --- a/README.rst +++ b/README.rst @@ -114,7 +114,6 @@ Install using ``pip``... $ # for optional package integrations $ pip install djangorestframework-jsonapi['django-filter'] $ pip install djangorestframework-jsonapi['django-polymorphic'] - $ pip install djangorestframework-jsonapi['openapi'] or from source... @@ -156,8 +155,6 @@ installed and activated: Browse to * http://localhost:8000 for the list of available collections (in a non-JSON:API format!), -* http://localhost:8000/swagger-ui/ for a Swagger user interface to the dynamic schema view, or -* http://localhost:8000/openapi for the schema view's OpenAPI specification document. ----- diff --git a/docs/getting-started.md b/docs/getting-started.md index 4052450b..81040e8e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -69,7 +69,6 @@ Install using `pip`... # for optional package integrations pip install djangorestframework-jsonapi['django-filter'] pip install djangorestframework-jsonapi['django-polymorphic'] - pip install djangorestframework-jsonapi['openapi'] or from source... @@ -100,8 +99,6 @@ and add `rest_framework_json_api` to your `INSTALLED_APPS` setting below `rest_f Browse to * [http://localhost:8000](http://localhost:8000) for the list of available collections (in a non-JSON:API format!), -* [http://localhost:8000/swagger-ui/](http://localhost:8000/swagger-ui/) for a Swagger user interface to the dynamic schema view, or -* [http://localhost:8000/openapi](http://localhost:8000/openapi) for the schema view's OpenAPI specification document. ## Running Tests diff --git a/docs/usage.md b/docs/usage.md index 1a2cb195..00c12ee8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1054,139 +1054,6 @@ The `prefetch_related` case will issue 4 queries, but they will be small and fas ### Errors --> -## Generating an OpenAPI Specification (OAS) 3.0 schema document - -DRF has a [OAS schema functionality](https://www.django-rest-framework.org/api-guide/schemas/) to generate an -[OAS 3.0 schema](https://www.openapis.org/) as a YAML or JSON file. - -DJA extends DRF's schema support to generate an OAS schema in the JSON:API format. - ---- - -**Deprecation notice:** - -REST framework's built-in support for generating OpenAPI schemas is -**deprecated** in favor of 3rd party packages that can provide this -functionality instead. Therefore we have also deprecated the schema support in -Django REST framework JSON:API. The built-in support will be retired over the -next releases. - -As a full-fledged replacement, we recommend the [drf-spectacular-json-api] package. - ---- - -### AutoSchema Settings - -In order to produce an OAS schema that properly represents the JSON:API structure -you have to either add a `schema` attribute to each view class or set the `REST_FRAMEWORK['DEFAULT_SCHEMA_CLASS']` -to DJA's version of AutoSchema. - -#### View-based - -```python -from rest_framework_json_api.schemas.openapi import AutoSchema - -class MyViewset(ModelViewSet): - schema = AutoSchema - ... -``` - -#### Default schema class - -```python -REST_FRAMEWORK = { - # ... - 'DEFAULT_SCHEMA_CLASS': 'rest_framework_json_api.schemas.openapi.AutoSchema', -} -``` - -### Adding additional OAS schema content - -You can extend the OAS schema document by subclassing -[`SchemaGenerator`](https://www.django-rest-framework.org/api-guide/schemas/#schemagenerator) -and extending `get_schema`. - - -Here's an example that adds OAS `info` and `servers` objects. - -```python -from rest_framework_json_api.schemas.openapi import SchemaGenerator as JSONAPISchemaGenerator - - -class MySchemaGenerator(JSONAPISchemaGenerator): - """ - Describe my OAS schema info in detail (overriding what DRF put in) and list the servers where it can be found. - """ - def get_schema(self, request, public): - schema = super().get_schema(request, public) - schema['info'] = { - 'version': '1.0', - 'title': 'my demo API', - 'description': 'A demonstration of [OAS 3.0](https://www.openapis.org)', - 'contact': { - 'name': 'my name' - }, - 'license': { - 'name': 'BSD 2 clause', - 'url': 'https://github.com/django-json-api/django-rest-framework-json-api/blob/main/LICENSE', - } - } - schema['servers'] = [ - {'url': 'http://localhost/v1', 'description': 'local docker'}, - {'url': 'http://localhost:8000/v1', 'description': 'local dev'}, - {'url': 'https://api.example.com/v1', 'description': 'demo server'}, - {'url': '{serverURL}', 'description': 'provide your server URL', - 'variables': {'serverURL': {'default': 'http://localhost:8000/v1'}}} - ] - return schema -``` - -### Generate a Static Schema on Command Line - -See [DRF documentation for generateschema](https://www.django-rest-framework.org/api-guide/schemas/#generating-a-static-schema-with-the-generateschema-management-command) -To generate a static OAS schema document, using the `generateschema` management command, you **must override DRF's default** `generator_class` with the DJA-specific version: - -```text -$ ./manage.py generateschema --generator_class rest_framework_json_api.schemas.openapi.SchemaGenerator -``` - -You can then use any number of OAS tools such as -[swagger-ui-watcher](https://www.npmjs.com/package/swagger-ui-watcher) -to render the schema: -```text -$ swagger-ui-watcher myschema.yaml -``` - -Note: Swagger-ui-watcher will complain that "DELETE operations cannot have a requestBody" -but it will still work. This [error](https://github.com/OAI/OpenAPI-Specification/pull/2117) -in the OAS specification will be fixed when [OAS 3.1.0](https://www.openapis.org/blog/2020/06/18/openapi-3-1-0-rc0-its-here) -is published. - -([swagger-ui](https://www.npmjs.com/package/swagger-ui) will work silently.) - -### Generate a Dynamic Schema in a View - -See [DRF documentation for a Dynamic Schema](https://www.django-rest-framework.org/api-guide/schemas/#generating-a-dynamic-schema-with-schemaview). - -```python -from rest_framework.schemas import get_schema_view - -urlpatterns = [ - ... - path('openapi', get_schema_view( - title="Example API", - description="API for all things …", - version="1.0.0", - generator_class=MySchemaGenerator, - ), name='openapi-schema'), - path('swagger-ui/', TemplateView.as_view( - template_name='swagger-ui.html', - extra_context={'schema_url': 'openapi-schema'} - ), name='swagger-ui'), - ... -] -``` - ## Third Party Packages ### About Third Party Packages diff --git a/example/settings/dev.py b/example/settings/dev.py index 7b40e61f..05cab4d1 100644 --- a/example/settings/dev.py +++ b/example/settings/dev.py @@ -83,7 +83,6 @@ "rest_framework_json_api.renderers.BrowsableAPIRenderer", ), "DEFAULT_METADATA_CLASS": "rest_framework_json_api.metadata.JSONAPIMetadata", - "DEFAULT_SCHEMA_CLASS": "rest_framework_json_api.schemas.openapi.AutoSchema", "DEFAULT_FILTER_BACKENDS": ( "rest_framework_json_api.filters.OrderingFilter", "rest_framework_json_api.django_filters.DjangoFilterBackend", diff --git a/example/templates/swagger-ui.html b/example/templates/swagger-ui.html deleted file mode 100644 index 29776491..00000000 --- a/example/templates/swagger-ui.html +++ /dev/null @@ -1,28 +0,0 @@ - - - - Swagger - - - - - -
- - - - \ No newline at end of file diff --git a/example/tests/__snapshots__/test_openapi.ambr b/example/tests/__snapshots__/test_openapi.ambr deleted file mode 100644 index f72c6ff8..00000000 --- a/example/tests/__snapshots__/test_openapi.ambr +++ /dev/null @@ -1,1414 +0,0 @@ -# serializer version: 1 -# name: test_delete_request - ''' - { - "description": "", - "operationId": "destroy/authors/{id}", - "parameters": [ - { - "description": "A unique integer value identifying this author.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/onlymeta" - } - } - }, - "description": "[OK](https://jsonapi.org/format/#crud-deleting-responses-200)" - }, - "202": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/datum" - } - } - }, - "description": "Accepted for [asynchronous processing](https://jsonapi.org/recommendations/#asynchronous-processing)" - }, - "204": { - "description": "[no content](https://jsonapi.org/format/#crud-deleting-responses-204)" - }, - "400": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "bad request" - }, - "401": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not authorized" - }, - "404": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Resource does not exist](https://jsonapi.org/format/#crud-deleting-responses-404)" - }, - "429": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "too many requests" - } - }, - "tags": [ - "authors" - ] - } - ''' -# --- -# name: test_patch_request - ''' - { - "description": "", - "operationId": "update/authors/{id}", - "parameters": [ - { - "description": "A unique integer value identifying this author.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "additionalProperties": false, - "properties": { - "attributes": { - "properties": { - "defaults": { - "default": "default", - "description": "help for defaults", - "maxLength": 20, - "minLength": 3, - "type": "string", - "writeOnly": true - }, - "email": { - "format": "email", - "maxLength": 254, - "type": "string" - }, - "fullName": { - "maxLength": 50, - "type": "string" - }, - "name": { - "maxLength": 50, - "type": "string" - } - }, - "type": "object" - }, - "id": { - "$ref": "#/components/schemas/id" - }, - "links": { - "properties": { - "self": { - "$ref": "#/components/schemas/link" - } - }, - "type": "object" - }, - "relationships": { - "properties": { - "authorType": { - "$ref": "#/components/schemas/reltoone" - }, - "bio": { - "$ref": "#/components/schemas/reltoone" - }, - "comments": { - "$ref": "#/components/schemas/reltomany" - }, - "entries": { - "$ref": "#/components/schemas/reltomany" - }, - "firstEntry": { - "$ref": "#/components/schemas/reltoone" - } - }, - "type": "object" - }, - "type": { - "$ref": "#/components/schemas/type" - } - }, - "required": [ - "type", - "id" - ], - "type": "object" - } - }, - "required": [ - "data" - ] - } - } - } - }, - "responses": { - "200": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/Author" - }, - "included": { - "items": { - "$ref": "#/components/schemas/include" - }, - "type": "array", - "uniqueItems": true - }, - "jsonapi": { - "$ref": "#/components/schemas/jsonapi" - }, - "links": { - "allOf": [ - { - "$ref": "#/components/schemas/links" - }, - { - "$ref": "#/components/schemas/pagination" - } - ], - "description": "Link members related to primary data" - } - }, - "required": [ - "data" - ], - "type": "object" - } - } - }, - "description": "update/authors/{id}" - }, - "400": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "bad request" - }, - "401": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not authorized" - }, - "403": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Forbidden](https://jsonapi.org/format/#crud-updating-responses-403)" - }, - "404": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Related resource does not exist](https://jsonapi.org/format/#crud-updating-responses-404)" - }, - "409": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Conflict]([Conflict](https://jsonapi.org/format/#crud-updating-responses-409)" - }, - "429": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "too many requests" - } - }, - "tags": [ - "authors" - ] - } - ''' -# --- -# name: test_path_with_id_parameter - ''' - { - "description": "", - "operationId": "retrieve/authors/{id}/", - "parameters": [ - { - "description": "A unique integer value identifying this author.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - }, - { - "$ref": "#/components/parameters/include" - }, - { - "$ref": "#/components/parameters/fields" - }, - { - "description": "[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)", - "in": "query", - "name": "sort", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "author_type", - "in": "query", - "name": "filter[authorType]", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "name", - "in": "query", - "name": "filter[name]", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "A search term.", - "in": "query", - "name": "filter[search]", - "required": false, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/AuthorDetail" - }, - "included": { - "items": { - "$ref": "#/components/schemas/include" - }, - "type": "array", - "uniqueItems": true - }, - "jsonapi": { - "$ref": "#/components/schemas/jsonapi" - }, - "links": { - "allOf": [ - { - "$ref": "#/components/schemas/links" - }, - { - "$ref": "#/components/schemas/pagination" - } - ], - "description": "Link members related to primary data" - } - }, - "required": [ - "data" - ], - "type": "object" - } - } - }, - "description": "retrieve/authors/{id}/" - }, - "400": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "bad request" - }, - "401": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not authorized" - }, - "404": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not found" - }, - "429": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "too many requests" - } - }, - "tags": [ - "authors" - ] - } - ''' -# --- -# name: test_path_without_parameters - ''' - { - "description": "", - "operationId": "List/authors/", - "parameters": [ - { - "$ref": "#/components/parameters/include" - }, - { - "$ref": "#/components/parameters/fields" - }, - { - "description": "A page number within the paginated result set.", - "in": "query", - "name": "page[number]", - "required": false, - "schema": { - "type": "integer" - } - }, - { - "description": "Number of results to return per page.", - "in": "query", - "name": "page[size]", - "required": false, - "schema": { - "type": "integer" - } - }, - { - "description": "[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)", - "in": "query", - "name": "sort", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "author_type", - "in": "query", - "name": "filter[authorType]", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "name", - "in": "query", - "name": "filter[name]", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "A search term.", - "in": "query", - "name": "filter[search]", - "required": false, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "items": { - "$ref": "#/components/schemas/AuthorList" - }, - "type": "array" - }, - "included": { - "items": { - "$ref": "#/components/schemas/include" - }, - "type": "array", - "uniqueItems": true - }, - "jsonapi": { - "$ref": "#/components/schemas/jsonapi" - }, - "links": { - "allOf": [ - { - "$ref": "#/components/schemas/links" - }, - { - "$ref": "#/components/schemas/pagination" - } - ], - "description": "Link members related to primary data" - } - }, - "required": [ - "data" - ], - "type": "object" - } - } - }, - "description": "List/authors/" - }, - "400": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "bad request" - }, - "401": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not authorized" - }, - "404": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not found" - }, - "429": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "too many requests" - } - }, - "tags": [ - "authors" - ] - } - ''' -# --- -# name: test_post_request - ''' - { - "description": "", - "operationId": "create/authors/", - "parameters": [], - "requestBody": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "additionalProperties": false, - "properties": { - "attributes": { - "properties": { - "defaults": { - "default": "default", - "description": "help for defaults", - "maxLength": 20, - "minLength": 3, - "type": "string", - "writeOnly": true - }, - "email": { - "format": "email", - "maxLength": 254, - "type": "string" - }, - "fullName": { - "maxLength": 50, - "type": "string" - }, - "name": { - "maxLength": 50, - "type": "string" - } - }, - "required": [ - "name", - "fullName", - "email" - ], - "type": "object" - }, - "id": { - "$ref": "#/components/schemas/id" - }, - "links": { - "properties": { - "self": { - "$ref": "#/components/schemas/link" - } - }, - "type": "object" - }, - "relationships": { - "properties": { - "authorType": { - "$ref": "#/components/schemas/reltoone" - }, - "bio": { - "$ref": "#/components/schemas/reltoone" - }, - "comments": { - "$ref": "#/components/schemas/reltomany" - }, - "entries": { - "$ref": "#/components/schemas/reltomany" - }, - "firstEntry": { - "$ref": "#/components/schemas/reltoone" - } - }, - "required": [ - "bio", - "entries", - "comments" - ], - "type": "object" - }, - "type": { - "$ref": "#/components/schemas/type" - } - }, - "required": [ - "type" - ], - "type": "object" - } - }, - "required": [ - "data" - ] - } - } - } - }, - "responses": { - "201": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "$ref": "#/components/schemas/Author" - }, - "included": { - "items": { - "$ref": "#/components/schemas/include" - }, - "type": "array", - "uniqueItems": true - }, - "jsonapi": { - "$ref": "#/components/schemas/jsonapi" - }, - "links": { - "allOf": [ - { - "$ref": "#/components/schemas/links" - }, - { - "$ref": "#/components/schemas/pagination" - } - ], - "description": "Link members related to primary data" - } - }, - "required": [ - "data" - ], - "type": "object" - } - } - }, - "description": "[Created](https://jsonapi.org/format/#crud-creating-responses-201). Assigned `id` and/or any other changes are in this response." - }, - "202": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/datum" - } - } - }, - "description": "Accepted for [asynchronous processing](https://jsonapi.org/recommendations/#asynchronous-processing)" - }, - "204": { - "description": "[Created](https://jsonapi.org/format/#crud-creating-responses-204) with the supplied `id`. No other changes from what was POSTed." - }, - "400": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "bad request" - }, - "401": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not authorized" - }, - "403": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Forbidden](https://jsonapi.org/format/#crud-creating-responses-403)" - }, - "404": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Related resource does not exist](https://jsonapi.org/format/#crud-creating-responses-404)" - }, - "409": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "[Conflict](https://jsonapi.org/format/#crud-creating-responses-409)" - }, - "429": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "too many requests" - } - }, - "tags": [ - "authors" - ] - } - ''' -# --- -# name: test_schema_construction - ''' - { - "components": { - "parameters": { - "fields": { - "description": "[sparse fieldsets](https://jsonapi.org/format/#fetching-sparse-fieldsets).\nUse fields[\\]=field1,field2,...,fieldN", - "explode": true, - "in": "query", - "name": "fields", - "required": false, - "schema": { - "type": "object" - }, - "style": "deepObject" - }, - "include": { - "description": "[list of included related resources](https://jsonapi.org/format/#fetching-includes)", - "in": "query", - "name": "include", - "required": false, - "schema": { - "type": "string" - }, - "style": "form" - } - }, - "schemas": { - "AuthorList": { - "additionalProperties": false, - "properties": { - "attributes": { - "properties": { - "defaults": { - "default": "default", - "description": "help for defaults", - "maxLength": 20, - "minLength": 3, - "type": "string", - "writeOnly": true - }, - "email": { - "format": "email", - "maxLength": 254, - "type": "string" - }, - "fullName": { - "maxLength": 50, - "type": "string" - }, - "initials": { - "readOnly": true, - "type": "string" - }, - "name": { - "maxLength": 50, - "type": "string" - } - }, - "required": [ - "name", - "fullName", - "email" - ], - "type": "object" - }, - "id": { - "$ref": "#/components/schemas/id" - }, - "links": { - "properties": { - "self": { - "$ref": "#/components/schemas/link" - } - }, - "type": "object" - }, - "relationships": { - "properties": { - "authorType": { - "$ref": "#/components/schemas/reltoone" - }, - "bio": { - "$ref": "#/components/schemas/reltoone" - }, - "comments": { - "$ref": "#/components/schemas/reltomany" - }, - "entries": { - "$ref": "#/components/schemas/reltomany" - }, - "firstEntry": { - "$ref": "#/components/schemas/reltoone" - } - }, - "required": [ - "bio", - "entries", - "comments" - ], - "type": "object" - }, - "type": { - "$ref": "#/components/schemas/type" - } - }, - "required": [ - "type", - "id" - ], - "type": "object" - }, - "ResourceIdentifierObject": { - "oneOf": [ - { - "$ref": "#/components/schemas/relationshipToOne" - }, - { - "$ref": "#/components/schemas/relationshipToMany" - } - ] - }, - "datum": { - "description": "singular item", - "properties": { - "data": { - "$ref": "#/components/schemas/resource" - } - } - }, - "error": { - "additionalProperties": false, - "properties": { - "code": { - "type": "string" - }, - "detail": { - "type": "string" - }, - "id": { - "type": "string" - }, - "links": { - "$ref": "#/components/schemas/links" - }, - "source": { - "properties": { - "meta": { - "$ref": "#/components/schemas/meta" - }, - "parameter": { - "description": "A string indicating which query parameter caused the error.", - "type": "string" - }, - "pointer": { - "description": "A [JSON Pointer](https://tools.ietf.org/html/rfc6901) to the associated entity in the request document [e.g. `/data` for a primary data object, or `/data/attributes/title` for a specific attribute.", - "type": "string" - } - }, - "type": "object" - }, - "status": { - "type": "string" - }, - "title": { - "type": "string" - } - }, - "type": "object" - }, - "errors": { - "items": { - "$ref": "#/components/schemas/error" - }, - "type": "array", - "uniqueItems": true - }, - "failure": { - "properties": { - "errors": { - "$ref": "#/components/schemas/errors" - }, - "jsonapi": { - "$ref": "#/components/schemas/jsonapi" - }, - "links": { - "$ref": "#/components/schemas/links" - }, - "meta": { - "$ref": "#/components/schemas/meta" - } - }, - "required": [ - "errors" - ], - "type": "object" - }, - "id": { - "description": "Each resource object\u2019s type and id pair MUST [identify](https://jsonapi.org/format/#document-resource-object-identification) a single, unique resource.", - "type": "string" - }, - "include": { - "additionalProperties": false, - "properties": { - "attributes": { - "additionalProperties": true, - "type": "object" - }, - "id": { - "$ref": "#/components/schemas/id" - }, - "links": { - "$ref": "#/components/schemas/links" - }, - "meta": { - "$ref": "#/components/schemas/meta" - }, - "relationships": { - "additionalProperties": true, - "type": "object" - }, - "type": { - "$ref": "#/components/schemas/type" - } - }, - "required": [ - "type", - "id" - ], - "type": "object" - }, - "jsonapi": { - "additionalProperties": false, - "description": "The server's implementation", - "properties": { - "meta": { - "$ref": "#/components/schemas/meta" - }, - "version": { - "type": "string" - } - }, - "type": "object" - }, - "link": { - "oneOf": [ - { - "description": "a string containing the link's URL", - "format": "uri-reference", - "type": "string" - }, - { - "properties": { - "href": { - "description": "a string containing the link's URL", - "format": "uri-reference", - "type": "string" - }, - "meta": { - "$ref": "#/components/schemas/meta" - } - }, - "required": [ - "href" - ], - "type": "object" - } - ] - }, - "linkage": { - "description": "the 'type' and 'id'", - "properties": { - "id": { - "$ref": "#/components/schemas/id" - }, - "meta": { - "$ref": "#/components/schemas/meta" - }, - "type": { - "$ref": "#/components/schemas/type" - } - }, - "required": [ - "type", - "id" - ], - "type": "object" - }, - "links": { - "additionalProperties": { - "$ref": "#/components/schemas/link" - }, - "type": "object" - }, - "meta": { - "additionalProperties": true, - "type": "object" - }, - "nulltype": { - "default": null, - "nullable": true, - "type": "object" - }, - "onlymeta": { - "additionalProperties": false, - "properties": { - "meta": { - "$ref": "#/components/schemas/meta" - } - } - }, - "pageref": { - "oneOf": [ - { - "format": "uri-reference", - "type": "string" - }, - { - "$ref": "#/components/schemas/nulltype" - } - ] - }, - "pagination": { - "properties": { - "first": { - "$ref": "#/components/schemas/pageref" - }, - "last": { - "$ref": "#/components/schemas/pageref" - }, - "next": { - "$ref": "#/components/schemas/pageref" - }, - "prev": { - "$ref": "#/components/schemas/pageref" - } - }, - "type": "object" - }, - "relationshipLinks": { - "additionalProperties": true, - "description": "optional references to other resource objects", - "properties": { - "related": { - "$ref": "#/components/schemas/link" - }, - "self": { - "$ref": "#/components/schemas/link" - } - }, - "type": "object" - }, - "relationshipToMany": { - "description": "An array of objects each containing the 'type' and 'id' for to-many relationships", - "items": { - "$ref": "#/components/schemas/linkage" - }, - "type": "array", - "uniqueItems": true - }, - "relationshipToOne": { - "anyOf": [ - { - "$ref": "#/components/schemas/nulltype" - }, - { - "$ref": "#/components/schemas/linkage" - } - ], - "description": "reference to other resource in a to-one relationship" - }, - "reltomany": { - "description": "a multiple 'to-many' relationship", - "properties": { - "data": { - "$ref": "#/components/schemas/relationshipToMany" - }, - "links": { - "$ref": "#/components/schemas/relationshipLinks" - }, - "meta": { - "$ref": "#/components/schemas/meta" - } - }, - "type": "object" - }, - "reltoone": { - "description": "a singular 'to-one' relationship", - "properties": { - "data": { - "$ref": "#/components/schemas/relationshipToOne" - }, - "links": { - "$ref": "#/components/schemas/relationshipLinks" - }, - "meta": { - "$ref": "#/components/schemas/meta" - } - }, - "type": "object" - }, - "resource": { - "additionalProperties": false, - "properties": { - "attributes": { - "type": "object" - }, - "id": { - "$ref": "#/components/schemas/id" - }, - "links": { - "$ref": "#/components/schemas/links" - }, - "meta": { - "$ref": "#/components/schemas/meta" - }, - "relationships": { - "type": "object" - }, - "type": { - "$ref": "#/components/schemas/type" - } - }, - "required": [ - "type", - "id" - ], - "type": "object" - }, - "type": { - "description": "The [type](https://jsonapi.org/format/#document-resource-object-identification) member is used to describe resource objects that share common attributes and relationships.", - "type": "string" - } - } - }, - "info": { - "title": "", - "version": "" - }, - "openapi": "3.0.2", - "paths": { - "/authors/": { - "get": { - "description": "", - "operationId": "List/authors/", - "parameters": [ - { - "$ref": "#/components/parameters/include" - }, - { - "$ref": "#/components/parameters/fields" - }, - { - "description": "A page number within the paginated result set.", - "in": "query", - "name": "page[number]", - "required": false, - "schema": { - "type": "integer" - } - }, - { - "description": "Number of results to return per page.", - "in": "query", - "name": "page[size]", - "required": false, - "schema": { - "type": "integer" - } - }, - { - "description": "[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)", - "in": "query", - "name": "sort", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "author_type", - "in": "query", - "name": "filter[authorType]", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "name", - "in": "query", - "name": "filter[name]", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "A search term.", - "in": "query", - "name": "filter[search]", - "required": false, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/vnd.api+json": { - "schema": { - "properties": { - "data": { - "items": { - "$ref": "#/components/schemas/AuthorList" - }, - "type": "array" - }, - "included": { - "items": { - "$ref": "#/components/schemas/include" - }, - "type": "array", - "uniqueItems": true - }, - "jsonapi": { - "$ref": "#/components/schemas/jsonapi" - }, - "links": { - "allOf": [ - { - "$ref": "#/components/schemas/links" - }, - { - "$ref": "#/components/schemas/pagination" - } - ], - "description": "Link members related to primary data" - } - }, - "required": [ - "data" - ], - "type": "object" - } - } - }, - "description": "List/authors/" - }, - "400": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "bad request" - }, - "401": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not authorized" - }, - "404": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "not found" - }, - "429": { - "content": { - "application/vnd.api+json": { - "schema": { - "$ref": "#/components/schemas/failure" - } - } - }, - "description": "too many requests" - } - }, - "tags": [ - "authors" - ] - } - } - } - } - ''' -# --- diff --git a/example/tests/test_openapi.py b/example/tests/test_openapi.py deleted file mode 100644 index fa2f9c73..00000000 --- a/example/tests/test_openapi.py +++ /dev/null @@ -1,230 +0,0 @@ -# largely based on DRF's test_openapi -import json - -import pytest -from django.test import RequestFactory, override_settings -from django.urls import re_path -from rest_framework.request import Request - -from rest_framework_json_api.schemas.openapi import AutoSchema, SchemaGenerator - -from example import views - -pytestmark = pytest.mark.filterwarnings("ignore:Built-in support") - - -def create_request(path): - factory = RequestFactory() - request = Request(factory.get(path)) - return request - - -def create_view_with_kw(view_cls, method, request, initkwargs): - generator = SchemaGenerator() - view = generator.create_view(view_cls.as_view(initkwargs), method, request) - return view - - -def test_path_without_parameters(snapshot): - path = "/authors/" - method = "GET" - - view = create_view_with_kw( - views.AuthorViewSet, method, create_request(path), {"get": "list"} - ) - inspector = AutoSchema() - inspector.view = view - - operation = inspector.get_operation(path, method) - assert snapshot == json.dumps(operation, indent=2, sort_keys=True) - - -def test_path_with_id_parameter(snapshot): - path = "/authors/{id}/" - method = "GET" - - view = create_view_with_kw( - views.AuthorViewSet, method, create_request(path), {"get": "retrieve"} - ) - inspector = AutoSchema() - inspector.view = view - - operation = inspector.get_operation(path, method) - assert snapshot == json.dumps(operation, indent=2, sort_keys=True) - - -def test_post_request(snapshot): - method = "POST" - path = "/authors/" - - view = create_view_with_kw( - views.AuthorViewSet, method, create_request(path), {"post": "create"} - ) - inspector = AutoSchema() - inspector.view = view - - operation = inspector.get_operation(path, method) - assert snapshot == json.dumps(operation, indent=2, sort_keys=True) - - -def test_patch_request(snapshot): - method = "PATCH" - path = "/authors/{id}" - - view = create_view_with_kw( - views.AuthorViewSet, method, create_request(path), {"patch": "update"} - ) - inspector = AutoSchema() - inspector.view = view - - operation = inspector.get_operation(path, method) - assert snapshot == json.dumps(operation, indent=2, sort_keys=True) - - -def test_delete_request(snapshot): - method = "DELETE" - path = "/authors/{id}" - - view = create_view_with_kw( - views.AuthorViewSet, method, create_request(path), {"delete": "delete"} - ) - inspector = AutoSchema() - inspector.view = view - - operation = inspector.get_operation(path, method) - assert snapshot == json.dumps(operation, indent=2, sort_keys=True) - - -@override_settings( - REST_FRAMEWORK={ - "DEFAULT_SCHEMA_CLASS": "rest_framework_json_api.schemas.openapi.AutoSchema" - } -) -def test_schema_construction(snapshot): - """Construction of the top level dictionary.""" - patterns = [ - re_path("^authors/?$", views.AuthorViewSet.as_view({"get": "list"})), - ] - generator = SchemaGenerator(patterns=patterns) - - request = create_request("/") - schema = generator.get_schema(request=request) - - assert snapshot == json.dumps(schema, indent=2, sort_keys=True) - - -def test_schema_id_field(): - """ID field is only included in the root, not the attributes.""" - patterns = [ - re_path("^companies/?$", views.CompanyViewset.as_view({"get": "list"})), - ] - generator = SchemaGenerator(patterns=patterns) - - request = create_request("/") - schema = generator.get_schema(request=request) - - company_properties = schema["components"]["schemas"]["Company"]["properties"] - assert company_properties["id"] == {"$ref": "#/components/schemas/id"} - assert "id" not in company_properties["attributes"]["properties"] - - -def test_schema_subserializers(): - """Schema for child Serializers reflects the actual response structure.""" - patterns = [ - re_path( - "^questionnaires/?$", views.QuestionnaireViewset.as_view({"get": "list"}) - ), - ] - generator = SchemaGenerator(patterns=patterns) - - request = create_request("/") - schema = generator.get_schema(request=request) - - assert { - "type": "object", - "properties": { - "metadata": { - "type": "object", - "properties": { - "author": {"type": "string"}, - "producer": {"type": "string"}, - }, - "required": ["author"], - }, - "questions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "text": {"type": "string"}, - "required": {"type": "boolean", "default": False}, - }, - "required": ["text"], - }, - }, - "name": {"type": "string", "maxLength": 100}, - }, - "required": ["name", "questions", "metadata"], - } == schema["components"]["schemas"]["Questionnaire"]["properties"]["attributes"] - - -def test_schema_parameters_include(): - """Include paramater is only used when serializer defines included_serializers.""" - patterns = [ - re_path("^authors/?$", views.AuthorViewSet.as_view({"get": "list"})), - re_path("^project-types/?$", views.ProjectTypeViewset.as_view({"get": "list"})), - ] - generator = SchemaGenerator(patterns=patterns) - - request = create_request("/") - schema = generator.get_schema(request=request) - - include_ref = {"$ref": "#/components/parameters/include"} - assert include_ref in schema["paths"]["/authors/"]["get"]["parameters"] - assert include_ref not in schema["paths"]["/project-types/"]["get"]["parameters"] - - -def test_schema_serializer_method_resource_related_field(): - """SerializerMethodResourceRelatedField fieds have the correct relation ref.""" - patterns = [ - re_path("^entries/?$", views.EntryViewSet.as_view({"get": "list"})), - ] - generator = SchemaGenerator(patterns=patterns) - - request = Request(RequestFactory().get("/", {"include": "featured"})) - schema = generator.get_schema(request=request) - - entry_schema = schema["components"]["schemas"]["Entry"] - entry_relationships = entry_schema["properties"]["relationships"]["properties"] - - rel_to_many_ref = {"$ref": "#/components/schemas/reltomany"} - assert entry_relationships["suggested"] == rel_to_many_ref - assert entry_relationships["suggestedHyperlinked"] == rel_to_many_ref - - rel_to_one_ref = {"$ref": "#/components/schemas/reltoone"} - assert entry_relationships["featured"] == rel_to_one_ref - assert entry_relationships["featuredHyperlinked"] == rel_to_one_ref - - -def test_schema_related_serializers(): - """ - Confirm that paths are generated for related fields. For example: - /authors/{pk}/{related_field>} - /authors/{id}/comments/ - /authors/{id}/entries/ - /authors/{id}/first_entry/ - and confirm that the schema for the related field is properly rendered - """ - generator = SchemaGenerator() - request = create_request("/") - schema = generator.get_schema(request=request) - # make sure the path's relationship and related {related_field}'s got expanded - assert "/authors/{id}/relationships/{related_field}" in schema["paths"] - assert "/authors/{id}/comments/" in schema["paths"] - assert "/authors/{id}/entries/" in schema["paths"] - assert "/authors/{id}/first_entry/" in schema["paths"] - first_get = schema["paths"]["/authors/{id}/first_entry/"]["get"]["responses"]["200"] - first_schema = first_get["content"]["application/vnd.api+json"]["schema"] - first_props = first_schema["properties"]["data"] - assert "$ref" in first_props - assert first_props["$ref"] == "#/components/schemas/Entry" diff --git a/example/tests/unit/test_filter_schema_params.py b/example/tests/unit/test_filter_schema_params.py deleted file mode 100644 index d7cb4fb8..00000000 --- a/example/tests/unit/test_filter_schema_params.py +++ /dev/null @@ -1,107 +0,0 @@ -from rest_framework import filters as drf_filters - -from rest_framework_json_api import filters as dja_filters -from rest_framework_json_api.django_filters import backends - -from example.views import EntryViewSet - - -class DummyEntryViewSet(EntryViewSet): - filter_backends = ( - dja_filters.QueryParameterValidationFilter, - dja_filters.OrderingFilter, - backends.DjangoFilterBackend, - drf_filters.SearchFilter, - ) - filterset_fields = { - "id": ("exact",), - "headline": ("exact", "contains"), - "blog__name": ("contains",), - } - - def __init__(self, **kwargs): - # dummy up self.request since PreloadIncludesMixin expects it to be defined - self.request = None - super().__init__(**kwargs) - - -def test_filters_get_schema_params(): - """ - test all my filters for `get_schema_operation_parameters()` - """ - # list of tuples: (filter, expected result) - filters = [ - (dja_filters.QueryParameterValidationFilter, []), - ( - backends.DjangoFilterBackend, - [ - { - "name": "filter[id]", - "required": False, - "in": "query", - "description": "id", - "schema": {"type": "string"}, - }, - { - "name": "filter[headline]", - "required": False, - "in": "query", - "description": "headline", - "schema": {"type": "string"}, - }, - { - "name": "filter[headline.contains]", - "required": False, - "in": "query", - "description": "headline__contains", - "schema": {"type": "string"}, - }, - { - "name": "filter[blog.name.contains]", - "required": False, - "in": "query", - "description": "blog__name__contains", - "schema": {"type": "string"}, - }, - ], - ), - ( - dja_filters.OrderingFilter, - [ - { - "name": "sort", - "required": False, - "in": "query", - "description": "[list of fields to sort by]" - "(https://jsonapi.org/format/#fetching-sorting)", - "schema": {"type": "string"}, - } - ], - ), - ( - drf_filters.SearchFilter, - [ - { - "name": "filter[search]", - "required": False, - "in": "query", - "description": "A search term.", - "schema": {"type": "string"}, - } - ], - ), - ] - view = DummyEntryViewSet() - - for c, expected in filters: - f = c() - result = f.get_schema_operation_parameters(view) - assert len(result) == len(expected) - if len(result) == 0: - continue - # py35: the result list/dict ordering isn't guaranteed - for res_item in result: - assert "name" in res_item - for exp_item in expected: - if res_item["name"] == exp_item["name"]: - assert res_item == exp_item diff --git a/example/urls.py b/example/urls.py index 413d058d..471fbe81 100644 --- a/example/urls.py +++ b/example/urls.py @@ -1,9 +1,5 @@ from django.urls import include, path, re_path -from django.views.generic import TemplateView from rest_framework import routers -from rest_framework.schemas import get_schema_view - -from rest_framework_json_api.schemas.openapi import SchemaGenerator from example.views import ( AuthorRelationshipView, @@ -87,22 +83,4 @@ AuthorRelationshipView.as_view(), name="author-relationships", ), - path( - "openapi", - get_schema_view( - title="Example API", - description="API for all things …", - version="1.0.0", - generator_class=SchemaGenerator, - ), - name="openapi-schema", - ), - path( - "swagger-ui/", - TemplateView.as_view( - template_name="swagger-ui.html", - extra_context={"schema_url": "openapi-schema"}, - ), - name="swagger-ui", - ), ] diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 589636e6..3db600e2 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -3,5 +3,3 @@ django-filter==24.3 # should be set to pinned version again # see https://github.com/django-polymorphic/django-polymorphic/pull/541 django-polymorphic@git+https://github.com/django-polymorphic/django-polymorphic@master # pyup: ignore -pyyaml==6.0.2 -uritemplate==4.1.1 diff --git a/rest_framework_json_api/django_filters/backends.py b/rest_framework_json_api/django_filters/backends.py index c0044839..70e543c1 100644 --- a/rest_framework_json_api/django_filters/backends.py +++ b/rest_framework_json_api/django_filters/backends.py @@ -4,7 +4,7 @@ from rest_framework.exceptions import ValidationError from rest_framework.settings import api_settings -from rest_framework_json_api.utils import format_field_name, undo_format_field_name +from rest_framework_json_api.utils import undo_format_field_name class DjangoFilterBackend(DjangoFilterBackend): @@ -129,18 +129,3 @@ def get_filterset_kwargs(self, request, queryset, view): "request": request, "filter_keys": filter_keys, } - - def get_schema_operation_parameters(self, view): - """ - Convert backend filter `name` to JSON:API-style `filter[name]`. - For filters that are relationship paths, rewrite ORM-style `__` to our preferred `.`. - For example: `blog__name__contains` becomes `filter[blog.name.contains]`. - - This is basically the reverse of `get_filterset_kwargs` above. - """ - result = super().get_schema_operation_parameters(view) - for res in result: - if "name" in res: - name = format_field_name(res["name"].replace("__", ".")) - res["name"] = f"filter[{name}]" - return result diff --git a/rest_framework_json_api/schemas/openapi.py b/rest_framework_json_api/schemas/openapi.py deleted file mode 100644 index 6892e991..00000000 --- a/rest_framework_json_api/schemas/openapi.py +++ /dev/null @@ -1,905 +0,0 @@ -import warnings -from urllib.parse import urljoin - -from rest_framework.fields import empty -from rest_framework.relations import ManyRelatedField -from rest_framework.schemas import openapi as drf_openapi -from rest_framework.schemas.utils import is_list_view - -from rest_framework_json_api import serializers, views -from rest_framework_json_api.relations import ManySerializerMethodResourceRelatedField -from rest_framework_json_api.utils import format_field_name - - -class SchemaGenerator(drf_openapi.SchemaGenerator): - """ - Extend DRF's SchemaGenerator to implement JSON:API flavored generateschema command. - """ - - #: These JSON:API component definitions are referenced by the generated OAS schema. - #: If you need to add more or change these static component definitions, extend this dict. - jsonapi_components = { - "schemas": { - "jsonapi": { - "type": "object", - "description": "The server's implementation", - "properties": { - "version": {"type": "string"}, - "meta": {"$ref": "#/components/schemas/meta"}, - }, - "additionalProperties": False, - }, - "resource": { - "type": "object", - "required": ["type", "id"], - "additionalProperties": False, - "properties": { - "type": {"$ref": "#/components/schemas/type"}, - "id": {"$ref": "#/components/schemas/id"}, - "attributes": { - "type": "object", - # ... - }, - "relationships": { - "type": "object", - # ... - }, - "links": {"$ref": "#/components/schemas/links"}, - "meta": {"$ref": "#/components/schemas/meta"}, - }, - }, - "include": { - "type": "object", - "required": ["type", "id"], - "additionalProperties": False, - "properties": { - "type": {"$ref": "#/components/schemas/type"}, - "id": {"$ref": "#/components/schemas/id"}, - "attributes": { - "type": "object", - "additionalProperties": True, - # ... - }, - "relationships": { - "type": "object", - "additionalProperties": True, - # ... - }, - "links": {"$ref": "#/components/schemas/links"}, - "meta": {"$ref": "#/components/schemas/meta"}, - }, - }, - "link": { - "oneOf": [ - { - "description": "a string containing the link's URL", - "type": "string", - "format": "uri-reference", - }, - { - "type": "object", - "required": ["href"], - "properties": { - "href": { - "description": "a string containing the link's URL", - "type": "string", - "format": "uri-reference", - }, - "meta": {"$ref": "#/components/schemas/meta"}, - }, - }, - ] - }, - "links": { - "type": "object", - "additionalProperties": {"$ref": "#/components/schemas/link"}, - }, - "reltoone": { - "description": "a singular 'to-one' relationship", - "type": "object", - "properties": { - "links": {"$ref": "#/components/schemas/relationshipLinks"}, - "data": {"$ref": "#/components/schemas/relationshipToOne"}, - "meta": {"$ref": "#/components/schemas/meta"}, - }, - }, - "relationshipToOne": { - "description": "reference to other resource in a to-one relationship", - "anyOf": [ - {"$ref": "#/components/schemas/nulltype"}, - {"$ref": "#/components/schemas/linkage"}, - ], - }, - "reltomany": { - "description": "a multiple 'to-many' relationship", - "type": "object", - "properties": { - "links": {"$ref": "#/components/schemas/relationshipLinks"}, - "data": {"$ref": "#/components/schemas/relationshipToMany"}, - "meta": {"$ref": "#/components/schemas/meta"}, - }, - }, - "relationshipLinks": { - "description": "optional references to other resource objects", - "type": "object", - "additionalProperties": True, - "properties": { - "self": {"$ref": "#/components/schemas/link"}, - "related": {"$ref": "#/components/schemas/link"}, - }, - }, - "relationshipToMany": { - "description": "An array of objects each containing the " - "'type' and 'id' for to-many relationships", - "type": "array", - "items": {"$ref": "#/components/schemas/linkage"}, - "uniqueItems": True, - }, - # A RelationshipView uses a ResourceIdentifierObjectSerializer (hence the name - # ResourceIdentifierObject returned by get_component_name()) which serializes type - # and id. These can be lists or individual items depending on whether the - # relationship is toMany or toOne so offer both options since we are not iterating - # over all the possible {related_field}'s but rather rendering one path schema - # which may represent toMany and toOne relationships. - "ResourceIdentifierObject": { - "oneOf": [ - {"$ref": "#/components/schemas/relationshipToOne"}, - {"$ref": "#/components/schemas/relationshipToMany"}, - ] - }, - "linkage": { - "type": "object", - "description": "the 'type' and 'id'", - "required": ["type", "id"], - "properties": { - "type": {"$ref": "#/components/schemas/type"}, - "id": {"$ref": "#/components/schemas/id"}, - "meta": {"$ref": "#/components/schemas/meta"}, - }, - }, - "pagination": { - "type": "object", - "properties": { - "first": {"$ref": "#/components/schemas/pageref"}, - "last": {"$ref": "#/components/schemas/pageref"}, - "prev": {"$ref": "#/components/schemas/pageref"}, - "next": {"$ref": "#/components/schemas/pageref"}, - }, - }, - "pageref": { - "oneOf": [ - {"type": "string", "format": "uri-reference"}, - {"$ref": "#/components/schemas/nulltype"}, - ] - }, - "failure": { - "type": "object", - "required": ["errors"], - "properties": { - "errors": {"$ref": "#/components/schemas/errors"}, - "meta": {"$ref": "#/components/schemas/meta"}, - "jsonapi": {"$ref": "#/components/schemas/jsonapi"}, - "links": {"$ref": "#/components/schemas/links"}, - }, - }, - "errors": { - "type": "array", - "items": {"$ref": "#/components/schemas/error"}, - "uniqueItems": True, - }, - "error": { - "type": "object", - "additionalProperties": False, - "properties": { - "id": {"type": "string"}, - "status": {"type": "string"}, - "links": {"$ref": "#/components/schemas/links"}, - "code": {"type": "string"}, - "title": {"type": "string"}, - "detail": {"type": "string"}, - "source": { - "type": "object", - "properties": { - "pointer": { - "type": "string", - "description": ( - "A [JSON Pointer](https://tools.ietf.org/html/rfc6901) " - "to the associated entity in the request document " - "[e.g. `/data` for a primary data object, or " - "`/data/attributes/title` for a specific attribute." - ), - }, - "parameter": { - "type": "string", - "description": "A string indicating which query parameter " - "caused the error.", - }, - "meta": {"$ref": "#/components/schemas/meta"}, - }, - }, - }, - }, - "onlymeta": { - "additionalProperties": False, - "properties": {"meta": {"$ref": "#/components/schemas/meta"}}, - }, - "meta": {"type": "object", "additionalProperties": True}, - "datum": { - "description": "singular item", - "properties": {"data": {"$ref": "#/components/schemas/resource"}}, - }, - "nulltype": {"type": "object", "nullable": True, "default": None}, - "type": { - "type": "string", - "description": "The [type]" - "(https://jsonapi.org/format/#document-resource-object-identification) " - "member is used to describe resource objects that share common attributes " - "and relationships.", - }, - "id": { - "type": "string", - "description": "Each resource object’s type and id pair MUST " - "[identify]" - "(https://jsonapi.org/format/#document-resource-object-identification) " - "a single, unique resource.", - }, - }, - "parameters": { - "include": { - "name": "include", - "in": "query", - "description": "[list of included related resources]" - "(https://jsonapi.org/format/#fetching-includes)", - "required": False, - "style": "form", - "schema": {"type": "string"}, - }, - # TODO: deepObject not well defined/supported: - # https://github.com/OAI/OpenAPI-Specification/issues/1706 - "fields": { - "name": "fields", - "in": "query", - "description": "[sparse fieldsets]" - "(https://jsonapi.org/format/#fetching-sparse-fieldsets).\n" - "Use fields[\\]=field1,field2,...,fieldN", - "required": False, - "style": "deepObject", - "schema": { - "type": "object", - }, - "explode": True, - }, - }, - } - - def get_schema(self, request=None, public=False): - """ - Generate a JSON:API OpenAPI schema. - Overrides upstream DRF's get_schema. - """ - # TODO: avoid copying so much of upstream get_schema() - schema = super().get_schema(request, public) - - components_schemas = {} - - # Iterate endpoints generating per method path operations. - paths = {} - _, view_endpoints = self._get_paths_and_endpoints(None if public else request) - - #: `expanded_endpoints` is like view_endpoints with one extra field tacked on: - #: - 'action' copy of current view.action (list/fetch) as this gets reset for - # each request. - expanded_endpoints = [] - for path, method, view in view_endpoints: - if hasattr(view, "action") and view.action == "retrieve_related": - expanded_endpoints += self._expand_related( - path, method, view, view_endpoints - ) - else: - expanded_endpoints.append( - (path, method, view, getattr(view, "action", None)) - ) - - for path, method, view, action in expanded_endpoints: - if not self.has_view_permissions(path, method, view): - continue - # kludge to preserve view.action as it is 'list' for the parent ViewSet - # but the related viewset that was expanded may be either 'fetch' (to_one) or 'list' - # (to_many). This patches the view.action appropriately so that - # view.schema.get_operation() "does the right thing" for fetch vs. list. - current_action = None - if hasattr(view, "action"): - current_action = view.action - view.action = action - operation = view.schema.get_operation(path, method) - components = view.schema.get_components(path, method) - for k in components.keys(): - if k not in components_schemas: - continue - if components_schemas[k] == components[k]: - continue - warnings.warn( - f'Schema component "{k}" has been overriden with a different value.', - stacklevel=1, - ) - - components_schemas.update(components) - - if hasattr(view, "action"): - view.action = current_action - # Normalise path for any provided mount url. - if path.startswith("/"): - path = path[1:] - path = urljoin(self.url or "/", path) - - paths.setdefault(path, {}) - paths[path][method.lower()] = operation - - self.check_duplicate_operation_id(paths) - - # Compile final schema, overriding stuff from super class. - schema["paths"] = paths - schema["components"] = self.jsonapi_components - schema["components"]["schemas"].update(components_schemas) - - return schema - - def _expand_related(self, path, method, view, view_endpoints): - """ - Expand path containing .../{id}/{related_field} into list of related fields - and **their** views, making sure toOne relationship's views are a 'fetch' and toMany - relationship's are a 'list'. - :param path - :param method - :param view - :param view_endpoints - :return:list[tuple(path, method, view, action)] - """ - result = [] - serializer = view.get_serializer() - # It's not obvious if it's allowed to have both included_ and related_ serializers, - # so just merge both dicts. - serializers = {} - if hasattr(serializer, "included_serializers"): - serializers = {**serializers, **serializer.included_serializers} - if hasattr(serializer, "related_serializers"): - serializers = {**serializers, **serializer.related_serializers} - related_fields = [fs for fs in serializers.items()] - - for field, related_serializer in related_fields: - related_view = self._find_related_view( - view_endpoints, related_serializer, view - ) - if related_view: - action = self._field_is_one_or_many(field, view) - result.append( - ( - path.replace("{related_field}", field), - method, - related_view, - action, - ) - ) - - return result - - def _find_related_view(self, view_endpoints, related_serializer, parent_view): - """ - For a given related_serializer, try to find it's "parent" view instance. - - :param view_endpoints: list of all view endpoints - :param related_serializer: the related serializer for a given related field - :param parent_view: the parent view (used to find toMany vs. toOne). - TODO: not actually used. - :return:view - """ - for _path, _method, view in view_endpoints: - view_serializer = view.get_serializer() - if isinstance(view_serializer, related_serializer): - return view - - return None - - def _field_is_one_or_many(self, field, view): - serializer = view.get_serializer() - if isinstance(serializer.fields[field], ManyRelatedField): - return "list" - else: - return "fetch" - - -class AutoSchema(drf_openapi.AutoSchema): - """ - Extend DRF's openapi.AutoSchema for JSON:API serialization. - """ - - #: ignore all the media types and only generate a JSON:API schema. - content_types = ["application/vnd.api+json"] - - def get_operation(self, path, method): - """ - JSON:API adds some standard fields to the API response that are not in upstream DRF: - - some that only apply to GET/HEAD methods. - - collections - - special handling for POST, PATCH, DELETE - """ - - warnings.warn( - DeprecationWarning( - "Built-in support for generating OpenAPI schema is deprecated. " - "Use drf-spectacular-json-api instead see " - "https://github.com/jokiefer/drf-spectacular-json-api/" - ), - stacklevel=2, - ) - - operation = {} - operation["operationId"] = self.get_operation_id(path, method) - operation["description"] = self.get_description(path, method) - - serializer = self.get_response_serializer(path, method) - - parameters = [] - parameters += self.get_path_parameters(path, method) - # pagination, filters only apply to GET/HEAD of collections and items - if method in ["GET", "HEAD"]: - parameters += self._get_include_parameters(path, method, serializer) - parameters += self._get_fields_parameters(path, method) - parameters += self.get_pagination_parameters(path, method) - parameters += self.get_filter_parameters(path, method) - operation["parameters"] = parameters - operation["tags"] = self.get_tags(path, method) - - # get request and response code schemas - if method == "GET": - if is_list_view(path, method, self.view): - self._add_get_collection_response(operation, path) - else: - self._add_get_item_response(operation, path) - elif method == "POST": - self._add_post_item_response(operation, path) - elif method == "PATCH": - self._add_patch_item_response(operation, path) - elif method == "DELETE": - # should only allow deleting a resource, not a collection - # TODO: implement delete of a relationship in future release. - self._add_delete_item_response(operation, path) - return operation - - def get_operation_id(self, path, method): - """ - The upstream DRF version creates non-unique operationIDs, because the same view is - used for the main path as well as such as related and relationships. - This concatenates the (mapped) method name and path as the spec allows most any - """ - method_name = getattr(self.view, "action", method.lower()) - if is_list_view(path, method, self.view): - action = "List" - elif method_name not in self.method_mapping: - action = method_name - else: - action = self.method_mapping[method.lower()] - return action + path - - def _get_include_parameters(self, path, method, serializer): - """ - includes parameter: https://jsonapi.org/format/#fetching-includes - """ - if getattr(serializer, "included_serializers", {}): - return [{"$ref": "#/components/parameters/include"}] - return [] - - def _get_fields_parameters(self, path, method): - """ - sparse fieldsets https://jsonapi.org/format/#fetching-sparse-fieldsets - """ - # TODO: See if able to identify the specific types for fields[type]=... and return this: - # name: fields - # in: query - # description: '[sparse fieldsets](https://jsonapi.org/format/#fetching-sparse-fieldsets)' # noqa: B950 - # required: true - # style: deepObject - # schema: - # type: object - # properties: - # hello: - # type: string # noqa F821 - # world: - # type: string # noqa F821 - # explode: true - return [{"$ref": "#/components/parameters/fields"}] - - def _add_get_collection_response(self, operation, path): - """ - Add GET 200 response for a collection to operation - """ - operation["responses"] = { - "200": self._get_toplevel_200_response( - operation, path, "GET", collection=True - ) - } - self._add_get_4xx_responses(operation) - - def _add_get_item_response(self, operation, path): - """ - add GET 200 response for an item to operation - """ - operation["responses"] = { - "200": self._get_toplevel_200_response( - operation, path, "GET", collection=False - ) - } - self._add_get_4xx_responses(operation) - - def _get_toplevel_200_response(self, operation, path, method, collection=True): - """ - return top-level JSON:API GET 200 response - - :param collection: True for collections; False for individual items. - - Uses a $ref to the components.schemas. component definition. - """ - if collection: - data = { - "type": "array", - "items": self.get_reference(self.get_response_serializer(path, method)), - } - else: - data = self.get_reference(self.get_response_serializer(path, method)) - - return { - "description": operation["operationId"], - "content": { - "application/vnd.api+json": { - "schema": { - "type": "object", - "required": ["data"], - "properties": { - "data": data, - "included": { - "type": "array", - "uniqueItems": True, - "items": {"$ref": "#/components/schemas/include"}, - }, - "links": { - "description": "Link members related to primary data", - "allOf": [ - {"$ref": "#/components/schemas/links"}, - {"$ref": "#/components/schemas/pagination"}, - ], - }, - "jsonapi": {"$ref": "#/components/schemas/jsonapi"}, - }, - } - } - }, - } - - def _add_post_item_response(self, operation, path): - """ - add response for POST of an item to operation - """ - operation["requestBody"] = self.get_request_body(path, "POST") - operation["responses"] = { - "201": self._get_toplevel_200_response( - operation, path, "POST", collection=False - ) - } - operation["responses"]["201"]["description"] = ( - "[Created](https://jsonapi.org/format/#crud-creating-responses-201). " - "Assigned `id` and/or any other changes are in this response." - ) - self._add_async_response(operation) - operation["responses"]["204"] = { - "description": "[Created](https://jsonapi.org/format/#crud-creating-responses-204) " - "with the supplied `id`. No other changes from what was POSTed." - } - self._add_post_4xx_responses(operation) - - def _add_patch_item_response(self, operation, path): - """ - Add PATCH response for an item to operation - """ - operation["requestBody"] = self.get_request_body(path, "PATCH") - operation["responses"] = { - "200": self._get_toplevel_200_response( - operation, path, "PATCH", collection=False - ) - } - self._add_patch_4xx_responses(operation) - - def _add_delete_item_response(self, operation, path): - """ - add DELETE response for item or relationship(s) to operation - """ - # Only DELETE of relationships has a requestBody - if isinstance(self.view, views.RelationshipView): - operation["requestBody"] = self.get_request_body(path, "DELETE") - self._add_delete_responses(operation) - - def get_request_body(self, path, method): - """ - A request body is required by JSON:API for POST, PATCH, and DELETE methods. - """ - serializer = self.get_request_serializer(path, method) - if not isinstance(serializer, (serializers.BaseSerializer,)): - return {} - is_relationship = isinstance(self.view, views.RelationshipView) - - # DRF uses a $ref to the component schema definition, but this - # doesn't work for JSON:API due to the different required fields based on - # the method, so make those changes and inline another copy of the schema. - - # TODO: A future improvement could make this DRYer with multiple component schemas: - # A base schema for each viewset that has no required fields - # One subclassed from the base that requires some fields (`type` but not `id` for POST) - # Another subclassed from base with required type/id but no required attributes (PATCH) - - if is_relationship: - item_schema = {"$ref": "#/components/schemas/ResourceIdentifierObject"} - else: - item_schema = self.map_serializer(serializer) - if method == "POST": - # 'type' and 'id' are both required for: - # - all relationship operations - # - regular PATCH or DELETE - # Only 'type' is required for POST: system may assign the 'id'. - item_schema["required"] = ["type"] - - if "properties" in item_schema and "attributes" in item_schema["properties"]: - # No required attributes for PATCH - if ( - method in ["PATCH", "PUT"] - and "required" in item_schema["properties"]["attributes"] - ): - del item_schema["properties"]["attributes"]["required"] - # No read_only fields for request. - for name, schema in ( - item_schema["properties"]["attributes"]["properties"].copy().items() - ): # noqa E501 - if "readOnly" in schema: - del item_schema["properties"]["attributes"]["properties"][name] - - if "properties" in item_schema and "relationships" in item_schema["properties"]: - # No required relationships for PATCH - if ( - method in ["PATCH", "PUT"] - and "required" in item_schema["properties"]["relationships"] - ): - del item_schema["properties"]["relationships"]["required"] - - return { - "content": { - ct: { - "schema": { - "required": ["data"], - "properties": {"data": item_schema}, - } - } - for ct in self.content_types - } - } - - def map_serializer(self, serializer): - """ - Custom map_serializer that serializes the schema using the JSON:API spec. - - Non-attributes like related and identity fields, are moved to 'relationships' - and 'links'. - """ - # TODO: remove attributes, etc. for relationshipView?? - if isinstance( - serializer.parent, (serializers.ListField, serializers.BaseSerializer) - ): - # Return plain non-JSON:API serializer schema for serializers nested inside - # a Serializer or a ListField, as those don't use the full JSON:API - # serializer schemas. - return super().map_serializer(serializer) - - required = [] - attributes = {} - relationships_required = [] - relationships = {} - - for field in serializer.fields.values(): - if isinstance(field, serializers.HyperlinkedIdentityField): - # the 'url' is not an attribute but rather a self.link, so don't map it here. - continue - if isinstance(field, serializers.HiddenField): - continue - if isinstance( - field, - ( - serializers.ManyRelatedField, - ManySerializerMethodResourceRelatedField, - ), - ): - if field.required: - relationships_required.append(format_field_name(field.field_name)) - relationships[format_field_name(field.field_name)] = { - "$ref": "#/components/schemas/reltomany" - } - continue - if isinstance(field, serializers.RelatedField): - if field.required: - relationships_required.append(format_field_name(field.field_name)) - relationships[format_field_name(field.field_name)] = { - "$ref": "#/components/schemas/reltoone" - } - continue - if field.field_name == "id": - # ID is always provided in the root of JSON:API and removed from the - # attributes in JSONRenderer. - continue - - if field.required: - required.append(format_field_name(field.field_name)) - - schema = self.map_field(field) - if field.read_only: - schema["readOnly"] = True - if field.write_only: - schema["writeOnly"] = True - if field.allow_null: - schema["nullable"] = True - if field.default and field.default != empty and not callable(field.default): - schema["default"] = field.default - if field.help_text: - # Ensure django gettext_lazy is rendered correctly - schema["description"] = str(field.help_text) - self.map_field_validators(field, schema) - - attributes[format_field_name(field.field_name)] = schema - - result = { - "type": "object", - "required": ["type", "id"], - "additionalProperties": False, - "properties": { - "type": {"$ref": "#/components/schemas/type"}, - "id": {"$ref": "#/components/schemas/id"}, - "links": { - "type": "object", - "properties": {"self": {"$ref": "#/components/schemas/link"}}, - }, - }, - } - if attributes: - result["properties"]["attributes"] = { - "type": "object", - "properties": attributes, - } - if required: - result["properties"]["attributes"]["required"] = required - - if relationships: - result["properties"]["relationships"] = { - "type": "object", - "properties": relationships, - } - if relationships_required: - result["properties"]["relationships"][ - "required" - ] = relationships_required - return result - - def _add_async_response(self, operation): - """ - Add async response to operation - """ - operation["responses"]["202"] = { - "description": "Accepted for [asynchronous processing]" - "(https://jsonapi.org/recommendations/#asynchronous-processing)", - "content": { - "application/vnd.api+json": { - "schema": {"$ref": "#/components/schemas/datum"} - } - }, - } - - def _failure_response(self, reason): - """ - Return failure response reason as the description - """ - return { - "description": reason, - "content": { - "application/vnd.api+json": { - "schema": {"$ref": "#/components/schemas/failure"} - } - }, - } - - def _add_generic_failure_responses(self, operation): - """ - Add generic failure response(s) to operation - """ - for code, reason in [ - ("400", "bad request"), - ("401", "not authorized"), - ("429", "too many requests"), - ]: - operation["responses"][code] = self._failure_response(reason) - - def _add_get_4xx_responses(self, operation): - """ - Add generic 4xx GET responses to operation - """ - self._add_generic_failure_responses(operation) - for code, reason in [("404", "not found")]: - operation["responses"][code] = self._failure_response(reason) - - def _add_post_4xx_responses(self, operation): - """ - Add POST 4xx error responses to operation - """ - self._add_generic_failure_responses(operation) - for code, reason in [ - ( - "403", - "[Forbidden](https://jsonapi.org/format/#crud-creating-responses-403)", - ), - ( - "404", - "[Related resource does not exist]" - "(https://jsonapi.org/format/#crud-creating-responses-404)", - ), - ( - "409", - "[Conflict](https://jsonapi.org/format/#crud-creating-responses-409)", - ), - ]: - operation["responses"][code] = self._failure_response(reason) - - def _add_patch_4xx_responses(self, operation): - """ - Add PATCH 4xx error responses to operation - """ - self._add_generic_failure_responses(operation) - for code, reason in [ - ( - "403", - "[Forbidden](https://jsonapi.org/format/#crud-updating-responses-403)", - ), - ( - "404", - "[Related resource does not exist]" - "(https://jsonapi.org/format/#crud-updating-responses-404)", - ), - ( - "409", - "[Conflict]([Conflict]" - "(https://jsonapi.org/format/#crud-updating-responses-409)", - ), - ]: - operation["responses"][code] = self._failure_response(reason) - - def _add_delete_responses(self, operation): - """ - Add generic DELETE responses to operation - """ - # the 2xx statuses: - operation["responses"] = { - "200": { - "description": "[OK](https://jsonapi.org/format/#crud-deleting-responses-200)", - "content": { - "application/vnd.api+json": { - "schema": {"$ref": "#/components/schemas/onlymeta"} - } - }, - } - } - self._add_async_response(operation) - operation["responses"]["204"] = { - "description": "[no content](https://jsonapi.org/format/#crud-deleting-responses-204)", # noqa: B950 - } - # the 4xx errors: - self._add_generic_failure_responses(operation) - for code, reason in [ - ( - "404", - "[Resource does not exist]" - "(https://jsonapi.org/format/#crud-deleting-responses-404)", - ), - ]: - operation["responses"][code] = self._failure_response(reason) diff --git a/setup.cfg b/setup.cfg index 4230dcbb..92606700 100644 --- a/setup.cfg +++ b/setup.cfg @@ -63,9 +63,6 @@ DJANGO_SETTINGS_MODULE=example.settings.test filterwarnings = error::DeprecationWarning error::PendingDeprecationWarning - # Django filter schema generation. Can be removed once we remove - # schema support - ignore:Built-in schema generation is deprecated. testpaths = example tests diff --git a/setup.py b/setup.py index de61b0d1..0b88f4c9 100755 --- a/setup.py +++ b/setup.py @@ -112,7 +112,6 @@ def get_package_data(package): extras_require={ "django-polymorphic": ["django-polymorphic>=3.0"], "django-filter": ["django-filter>=2.4"], - "openapi": ["pyyaml>=5.4", "uritemplate>=3.0.1"], }, setup_requires=wheel, python_requires=">=3.9", diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py deleted file mode 100644 index 427f18fc..00000000 --- a/tests/schemas/test_openapi.py +++ /dev/null @@ -1,11 +0,0 @@ -from rest_framework_json_api.schemas.openapi import AutoSchema -from tests.serializers import CallableDefaultSerializer - - -class TestAutoSchema: - def test_schema_callable_default(self): - inspector = AutoSchema() - result = inspector.map_serializer(CallableDefaultSerializer()) - assert result["properties"]["attributes"]["properties"]["field"] == { - "type": "string", - } From 8d209d9ab402d40639a0a107bf648c57b5aba342 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Mon, 21 Jul 2025 21:57:07 +0700 Subject: [PATCH 245/252] Increase required version of optional Polymorphic Models for Django (#1289) Co-authored-by: Alan Crosswell --- CHANGELOG.md | 4 ++++ requirements/requirements-optionals.txt | 2 +- setup.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63865fbc..f13d267f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ any parts of the framework not mentioned in the documentation should generally b This adds full support for using multipart field names for includes while configuring `JSON_API_FORMAT_FIELD_NAMES`. * Ensured that sparse fieldset fully supports `JSON_API_FORMAT_FIELD_NAMES`. +### Changed + +* Set minimum required version of optional Polymorphic Models for Django to 4.0.0. + ### Removed * Removed support for Python 3.8. diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 3db600e2..279fd95c 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -2,4 +2,4 @@ django-filter==24.3 # once next version has been released (>3.1.0) this # should be set to pinned version again # see https://github.com/django-polymorphic/django-polymorphic/pull/541 -django-polymorphic@git+https://github.com/django-polymorphic/django-polymorphic@master # pyup: ignore +django-polymorphic@git+https://github.com/django-polymorphic/django-polymorphic@master # pyup: ignore \ No newline at end of file diff --git a/setup.py b/setup.py index 0b88f4c9..2a2a28dd 100755 --- a/setup.py +++ b/setup.py @@ -110,7 +110,7 @@ def get_package_data(package): "django>=4.2", ], extras_require={ - "django-polymorphic": ["django-polymorphic>=3.0"], + "django-polymorphic": ["django-polymorphic>=4.0.0"], "django-filter": ["django-filter>=2.4"], }, setup_requires=wheel, From fa23bd5bb5a695ea09cfd2106f59e59251cf794d Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Tue, 22 Jul 2025 17:01:21 +0700 Subject: [PATCH 246/252] Set django polymorphic version to newest (#1290) --- requirements/requirements-optionals.txt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index 279fd95c..afbc4754 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,5 +1,2 @@ django-filter==24.3 -# once next version has been released (>3.1.0) this -# should be set to pinned version again -# see https://github.com/django-polymorphic/django-polymorphic/pull/541 -django-polymorphic@git+https://github.com/django-polymorphic/django-polymorphic@master # pyup: ignore \ No newline at end of file +django-polymorphic==4.1.0 From a6f1f44aeb286af748ac538fe765a097fc48c444 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Thu, 24 Jul 2025 18:37:04 +0400 Subject: [PATCH 247/252] Release 8.0.0 (#1291) --- CHANGELOG.md | 7 +++---- rest_framework_json_api/__init__.py | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f13d267f..fe7c9e14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. -## [Unreleased] +## [8.0.0] - 2025-07-24 ### Added @@ -17,9 +17,8 @@ any parts of the framework not mentioned in the documentation should generally b ### Fixed -* Ensured that interpreting `include` query parameter is done in internal Python naming. - This adds full support for using multipart field names for includes while configuring `JSON_API_FORMAT_FIELD_NAMES`. -* Ensured that sparse fieldset fully supports `JSON_API_FORMAT_FIELD_NAMES`. +* Ensured that compound documents' `include` query parameter fully support `JSON_API_FORMAT_FIELD_NAMES`. +* Ensured that sparse fieldset's `fields` query parameter fully supports `JSON_API_FORMAT_FIELD_NAMES`. ### Changed diff --git a/rest_framework_json_api/__init__.py b/rest_framework_json_api/__init__.py index a69daa9f..23f6b8f9 100644 --- a/rest_framework_json_api/__init__.py +++ b/rest_framework_json_api/__init__.py @@ -1,5 +1,5 @@ __title__ = "djangorestframework-jsonapi" -__version__ = "7.1.0" +__version__ = "8.0.0" __author__ = "" __license__ = "BSD" __copyright__ = "" From 7e7f1a047a70aa0912790b5f012056188d67cf0e Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sun, 3 Aug 2025 20:34:52 +0700 Subject: [PATCH 248/252] Added permissions to github workflow (#1292) --- .github/workflows/tests.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0852bba1..03a1db78 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,4 +1,6 @@ name: Tests +permissions: + contents: read on: push: branches: ["main"] @@ -6,7 +8,6 @@ on: branches: ["main"] schedule: - cron: '0 4 * * *' - jobs: test: name: Run test From b144953fcd1e556a572fcb2348c9e8f678a6b33f Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sun, 3 Aug 2025 20:41:44 +0700 Subject: [PATCH 249/252] Moved from pyup to dependabot (#1293) --- .github/dependabot.yml | 6 ++++++ .pyup.yml | 18 ------------------ 2 files changed, 6 insertions(+), 18 deletions(-) create mode 100644 .github/dependabot.yml delete mode 100644 .pyup.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..ce74c1f3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/requirements" + schedule: + interval: "monthly" diff --git a/.pyup.yml b/.pyup.yml deleted file mode 100644 index 7ee57975..00000000 --- a/.pyup.yml +++ /dev/null @@ -1,18 +0,0 @@ -search: False -schedule: "every two weeks" -requirements: - - requirements/requirements-codestyle.txt: - update: all - pin: True - - requirements/requirements-documentation.txt: - update: all - pin: True - - requirements/requirements-optionals.txt: - update: all - pin: True - - requirements/requirements-packaging.txt: - update: all - pin: True - - requirements/requirements-testing.txt: - update: all - pin: True From ec1fc3c24f18dfb7f1669917edc5f5be18de6d70 Mon Sep 17 00:00:00 2001 From: Oliver Sauder Date: Sun, 3 Aug 2025 23:41:37 +0700 Subject: [PATCH 250/252] Added missing grouping of dependencies with dependabot (#1299) --- .github/dependabot.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ce74c1f3..c2b66bc4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,3 +4,6 @@ updates: directory: "/requirements" schedule: interval: "monthly" + groups: + all-deps: + patterns: ["*"] From 142c76edadf7bf4d6c6692da307c03355103b1f6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Aug 2025 04:30:27 +0700 Subject: [PATCH 251/252] Bump the all-deps group in /requirements with 6 updates (#1300) Bumps the all-deps group in /requirements with 6 updates: | Package | From | To | | --- | --- | --- | | [flake8](https://github.com/pycqa/flake8) | `7.2.0` | `7.3.0` | | [faker](https://github.com/joke2k/faker) | `37.1.0` | `37.5.3` | | [pytest](https://github.com/pytest-dev/pytest) | `8.3.5` | `8.4.1` | | [pytest-cov](https://github.com/pytest-dev/pytest-cov) | `6.1.1` | `6.2.1` | | [pytest-factoryboy](https://github.com/pytest-dev/pytest-factoryboy) | `2.7.0` | `2.8.1` | | [django-filter](https://github.com/carltongibson/django-filter) | `24.3` | `25.1` | Updates `flake8` from 7.2.0 to 7.3.0 - [Commits](https://github.com/pycqa/flake8/compare/7.2.0...7.3.0) Updates `faker` from 37.1.0 to 37.5.3 - [Release notes](https://github.com/joke2k/faker/releases) - [Changelog](https://github.com/joke2k/faker/blob/master/CHANGELOG.md) - [Commits](https://github.com/joke2k/faker/compare/v37.1.0...v37.5.3) Updates `pytest` from 8.3.5 to 8.4.1 - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/8.3.5...8.4.1) Updates `pytest-cov` from 6.1.1 to 6.2.1 - [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-cov/compare/v6.1.1...v6.2.1) Updates `pytest-factoryboy` from 2.7.0 to 2.8.1 - [Changelog](https://github.com/pytest-dev/pytest-factoryboy/blob/master/CHANGES.rst) - [Commits](https://github.com/pytest-dev/pytest-factoryboy/compare/2.7.0...2.8.1) Updates `django-filter` from 24.3 to 25.1 - [Release notes](https://github.com/carltongibson/django-filter/releases) - [Changelog](https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst) - [Commits](https://github.com/carltongibson/django-filter/compare/24.3...25.1) --- updated-dependencies: - dependency-name: flake8 dependency-version: 7.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-deps - dependency-name: faker dependency-version: 37.5.3 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-deps - dependency-name: pytest dependency-version: 8.4.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-deps - dependency-name: pytest-cov dependency-version: 6.2.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-deps - dependency-name: pytest-factoryboy dependency-version: 2.8.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-deps - dependency-name: django-filter dependency-version: '25.1' dependency-type: direct:production update-type: version-update:semver-major dependency-group: all-deps ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/requirements-codestyle.txt | 2 +- requirements/requirements-optionals.txt | 2 +- requirements/requirements-testing.txt | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-codestyle.txt b/requirements/requirements-codestyle.txt index 1d4184ad..707bddb9 100644 --- a/requirements/requirements-codestyle.txt +++ b/requirements/requirements-codestyle.txt @@ -1,5 +1,5 @@ black==25.1.0 -flake8==7.2.0 +flake8==7.3.0 flake8-bugbear==24.12.12 flake8-isort==6.1.2 isort==6.0.1 diff --git a/requirements/requirements-optionals.txt b/requirements/requirements-optionals.txt index afbc4754..4eb517ff 100644 --- a/requirements/requirements-optionals.txt +++ b/requirements/requirements-optionals.txt @@ -1,2 +1,2 @@ -django-filter==24.3 +django-filter==25.1 django-polymorphic==4.1.0 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 35caa0a9..8fe1e366 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,7 +1,7 @@ factory-boy==3.3.3 -Faker==37.1.0 -pytest==8.3.5 -pytest-cov==6.1.1 +Faker==37.5.3 +pytest==8.4.1 +pytest-cov==6.2.1 pytest-django==4.11.1 -pytest-factoryboy==2.7.0 +pytest-factoryboy==2.8.1 syrupy==4.9.1 From 88721b9afc2f740d75a6abe812e5ab72ad6efcdc Mon Sep 17 00:00:00 2001 From: Harshal Kalewar Date: Wed, 27 Aug 2025 16:49:04 +0530 Subject: [PATCH 252/252] JSON:API 1.1: return empty 'included' array when ?include= is present (#1301) * JSON:API 1.1: return empty 'included' array when ?include= is present Fixes #1109. Ensures top-level 'included' is returned as [] when the query param ?include= is provided but no related resources exist. Updates integration tests to reflect JSON:API v1.1 spec compliance. * Fix tox lint environment to use setup.cfg flake8 config * fix: ensuring empty array is added when requested and updated tests * Reverted unrelated changes * Reverted unrelated tox changes --------- Co-authored-by: Oliver Sauder --- AUTHORS | 1 + CHANGELOG.md | 6 +++ example/tests/integration/test_includes.py | 48 +++++++++++++++++++++- example/tests/test_filters.py | 3 +- rest_framework_json_api/renderers.py | 2 +- 5 files changed, 57 insertions(+), 3 deletions(-) diff --git a/AUTHORS b/AUTHORS index d421d5e6..1cfc9206 100644 --- a/AUTHORS +++ b/AUTHORS @@ -15,6 +15,7 @@ David Guillot, for Contexte David Vogt Felix Viernickel Greg Aker +Harshal Kalewar Humayun Ahmad Jamie Bliss Jason Housley diff --git a/CHANGELOG.md b/CHANGELOG.md index fe7c9e14..2ec34d46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Note that in line with [Django REST framework policy](https://www.django-rest-framework.org/topics/release-notes/), any parts of the framework not mentioned in the documentation should generally be considered private API, and may be subject to change. +## [Unreleased] + +### Fixed + +* Ensured that an empty `included` array is returned in responses when the `include` query parameter is present but no related resources exist. + ## [8.0.0] - 2025-07-24 ### Added diff --git a/example/tests/integration/test_includes.py b/example/tests/integration/test_includes.py index d32ff7e3..238c3076 100644 --- a/example/tests/integration/test_includes.py +++ b/example/tests/integration/test_includes.py @@ -36,7 +36,9 @@ def test_missing_field_not_included(author_bio_factory, author_factory, client): # First author does not have a bio author = author_factory(bio=None) response = client.get(reverse("author-detail", args=[author.pk]) + "?include=bio") - assert "included" not in response.json() + content = response.json() + assert "included" in content + assert content["included"] == [] # Second author does author = author_factory() response = client.get(reverse("author-detail", args=[author.pk]) + "?include=bio") @@ -204,3 +206,47 @@ def test_meta_object_added_to_included_resources(single_entry, client): ) assert response.json()["included"][0].get("meta") assert response.json()["included"][1].get("meta") + + +def test_included_array_empty_when_requested_but_no_data(blog_factory, client): + blog = blog_factory() + response = client.get( + reverse("blog-detail", kwargs={"pk": blog.pk}) + "?include=tags" + ) + content = response.json() + + assert "included" in content + assert content["included"] == [] + + +def test_included_array_populated_when_related_data_exists( + blog_factory, tagged_item_factory, client +): + blog = blog_factory() + tag = tagged_item_factory(tag="django") + blog.tags.add(tag) + + response = client.get( + reverse("blog-detail", kwargs={"pk": blog.pk}) + "?include=tags" + ) + included = response.json()["included"] + + assert included, "Expected included array to be populated" + assert [x.get("type") for x in included] == [ + "taggedItems" + ], "Included types incorrect" + assert included[0]["attributes"]["tag"] == "django" + + +def test_included_array_present_via_jsonapimeta_defaults( + single_entry, comment_factory, author_factory, client +): + author = author_factory() + comment_factory(entry=single_entry, author=author) + + response = client.get(reverse("entry-detail", kwargs={"pk": single_entry.pk})) + + included = response.json()["included"] + + assert included, "Expected included array due to JSONAPIMeta defaults" + assert any(resource["type"] == "comments" for resource in included) diff --git a/example/tests/test_filters.py b/example/tests/test_filters.py index 8e45ded1..ff7bf263 100644 --- a/example/tests/test_filters.py +++ b/example/tests/test_filters.py @@ -530,7 +530,8 @@ def test_search_keywords(self): }, "meta": {"bodyFormat": "text"}, } - ] + ], + "included": [], } assert response.json() == expected_result diff --git a/rest_framework_json_api/renderers.py b/rest_framework_json_api/renderers.py index b670338f..f418b080 100644 --- a/rest_framework_json_api/renderers.py +++ b/rest_framework_json_api/renderers.py @@ -652,7 +652,7 @@ def render(self, data, accepted_media_type=None, renderer_context=None): if not included_cache[obj_type]: del included_cache[obj_type] - if included_cache: + if included_resources: render_data["included"] = list() for included_type in sorted(included_cache.keys()): for included_id in sorted(included_cache[included_type].keys()):