diff --git a/CHANGELOG.md b/CHANGELOG.md index e1d08de6..12ca5017 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](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. +## [7.0.1] - 2024-06-06 + +### Added + +* 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/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/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 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__ = "" 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/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) 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()