From 4776e2bb8a60e92ff4e6fbedc858e1b134b1f61f Mon Sep 17 00:00:00 2001 From: Kevin Partington Date: Thu, 24 Dec 2020 11:47:19 -0600 Subject: [PATCH 1/6] Support formatting URL segments via new FORMAT_LINKS setting Fixes #790. --- AUTHORS | 1 + CHANGELOG.md | 1 + docs/usage.md | 38 +++++++++++++++++++++ rest_framework_json_api/relations.py | 10 +++--- rest_framework_json_api/settings.py | 1 + rest_framework_json_api/utils.py | 13 +++++++ rest_framework_json_api/views.py | 9 +++-- tests/test_relations.py | 46 +++++++++++++++++++++++++ tests/test_utils.py | 16 +++++++++ tests/test_views.py | 51 ++++++++++++++++++++++++++++ tests/urls.py | 22 ++++++++++++ tests/views.py | 12 +++++++ 12 files changed, 210 insertions(+), 10 deletions(-) create mode 100644 tests/test_relations.py create mode 100644 tests/test_views.py create mode 100644 tests/urls.py create mode 100644 tests/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..0165dc87 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -13,8 +13,10 @@ from rest_framework.serializers import Serializer from rest_framework_json_api.exceptions import Conflict +from rest_framework_json_api.settings import json_api_settings from rest_framework_json_api.utils import ( Hyperlink, + format_value, get_included_serializers, get_resource_type_from_instance, get_resource_type_from_queryset, @@ -112,13 +114,11 @@ 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 - } + {"related_field": format_value(field_name, json_api_settings.FORMAT_LINKS)} ) self_link = self.get_url("self", self.self_link_view_name, self_kwargs, request) 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..c46cdc71 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() @@ -227,7 +229,6 @@ class RelationshipView(generics.GenericAPIView): serializer_class = ResourceIdentifierObjectSerializer self_link_view_name = None related_link_view_name = None - field_name_mapping = {} http_method_names = ["get", "post", "patch", "delete", "head", "options"] def get_serializer_class(self): @@ -400,9 +401,7 @@ def get_related_instance(self): def get_related_field_name(self): field_name = self.kwargs["related_field"] - if field_name in self.field_name_mapping: - return self.field_name_mapping[field_name] - return field_name + return format_value(field_name, "underscore") def _instantiate_serializer(self, instance): if isinstance(instance, Model) or instance is None: diff --git a/tests/test_relations.py b/tests/test_relations.py new file mode 100644 index 00000000..f7526760 --- /dev/null +++ b/tests/test_relations.py @@ -0,0 +1,46 @@ +import pytest + +from rest_framework_json_api.relations import HyperlinkedRelatedField + +from .models import BasicModel + + +@pytest.mark.urls("tests.urls") +@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": "/basic_models/{}/relationships/{}/".format( + model.pk, + expected_url_segment, + ), + "related": "/basic_models/{}/{}/".format( + model.pk, + expected_url_segment, + ), + } + + actual = field.get_links(model) + + assert expected == actual 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..1d0cac51 --- /dev/null +++ b/tests/test_views.py @@ -0,0 +1,51 @@ +import pytest +from django.test import RequestFactory + +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.urls("tests.urls") +@pytest.mark.parametrize( + "format_links", + [ + None, + "dasherize", + "camelize", + "capitalize", + "underscore", + ], +) +def test_get_related_field_name_handles_formatted_link_segments(format_links): + url_segment = format_value(related_model_field_name, format_links) + + request = RequestFactory().get("/basic_models/1/{}".format(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 diff --git a/tests/urls.py b/tests/urls.py new file mode 100644 index 00000000..3c6d9797 --- /dev/null +++ b/tests/urls.py @@ -0,0 +1,22 @@ +from django.conf.urls import re_path +from rest_framework.routers import SimpleRouter + +from .views import BasicModelRelationshipView, BasicModelViewSet + +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/views.py b/tests/views.py new file mode 100644 index 00000000..af3ef4ec --- /dev/null +++ b/tests/views.py @@ -0,0 +1,12 @@ +from rest_framework_json_api.views import ModelViewSet, RelationshipView + +from .models import BasicModel + + +class BasicModelViewSet(ModelViewSet): + class Meta: + model = BasicModel + + +class BasicModelRelationshipView(RelationshipView): + queryset = BasicModel.objects From 81a6c7d40759e35792dfc8d4b2bb7b055d5f8f1b Mon Sep 17 00:00:00 2001 From: Kevin Partington Date: Sat, 26 Dec 2020 10:15:21 -0600 Subject: [PATCH 2/6] Fixup: Use utils.format_link_segment --- rest_framework_json_api/relations.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/rest_framework_json_api/relations.py b/rest_framework_json_api/relations.py index 0165dc87..c9dd765d 100644 --- a/rest_framework_json_api/relations.py +++ b/rest_framework_json_api/relations.py @@ -13,10 +13,9 @@ from rest_framework.serializers import Serializer from rest_framework_json_api.exceptions import Conflict -from rest_framework_json_api.settings import json_api_settings from rest_framework_json_api.utils import ( Hyperlink, - format_value, + format_link_segment, get_included_serializers, get_resource_type_from_instance, get_resource_type_from_queryset, @@ -117,9 +116,7 @@ def get_links(self, obj=None, lookup_field="pk"): field_name = self.field_name if self.field_name else self.parent.field_name self_kwargs = kwargs.copy() - self_kwargs.update( - {"related_field": format_value(field_name, json_api_settings.FORMAT_LINKS)} - ) + 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: From f67b46374b1bb9ae98b88d1da3a7f522aed5825e Mon Sep 17 00:00:00 2001 From: Kevin Partington Date: Sat, 26 Dec 2020 10:22:49 -0600 Subject: [PATCH 3/6] Fixup: Undo changes to field_name_mapping dict --- rest_framework_json_api/views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py index c46cdc71..2f061cbb 100644 --- a/rest_framework_json_api/views.py +++ b/rest_framework_json_api/views.py @@ -229,6 +229,7 @@ class RelationshipView(generics.GenericAPIView): serializer_class = ResourceIdentifierObjectSerializer self_link_view_name = None related_link_view_name = None + field_name_mapping = {} http_method_names = ["get", "post", "patch", "delete", "head", "options"] def get_serializer_class(self): @@ -401,7 +402,9 @@ def get_related_instance(self): def get_related_field_name(self): field_name = self.kwargs["related_field"] - return format_value(field_name, "underscore") + if field_name in self.field_name_mapping: + return self.field_name_mapping[field_name] + return field_name def _instantiate_serializer(self, instance): if isinstance(instance, Model) or instance is None: From 33f9ea77ebcd3d383dd00871841b860c42c83b31 Mon Sep 17 00:00:00 2001 From: Kevin Partington Date: Sat, 26 Dec 2020 18:31:23 -0600 Subject: [PATCH 4/6] Fixup: Use f-strings and pytest-django RequestFactory --- tests/test_relations.py | 10 ++-------- tests/test_views.py | 5 ++--- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/tests/test_relations.py b/tests/test_relations.py index f7526760..42f64b83 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -31,14 +31,8 @@ def test_relationship_urls_respect_format_links( field.field_name = "relatedField_name" expected = { - "self": "/basic_models/{}/relationships/{}/".format( - model.pk, - expected_url_segment, - ), - "related": "/basic_models/{}/{}/".format( - model.pk, - expected_url_segment, - ), + "self": f"/basic_models/{model.pk}/relationships/{expected_url_segment}/", + "related": f"/basic_models/{model.pk}/{expected_url_segment}/", } actual = field.get_links(model) diff --git a/tests/test_views.py b/tests/test_views.py index 1d0cac51..94a18f82 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,5 +1,4 @@ import pytest -from django.test import RequestFactory from rest_framework_json_api import serializers, views from rest_framework_json_api.relations import ResourceRelatedField @@ -21,10 +20,10 @@ "underscore", ], ) -def test_get_related_field_name_handles_formatted_link_segments(format_links): +def test_get_related_field_name_handles_formatted_link_segments(format_links, rf): url_segment = format_value(related_model_field_name, format_links) - request = RequestFactory().get("/basic_models/1/{}".format(url_segment)) + request = rf.get(f"/basic_models/1/{url_segment}") view = BasicModelFakeViewSet() view.setup(request, related_field=url_segment) From 6343e8a1c641b6ba36f1b4e295512e5614115969 Mon Sep 17 00:00:00 2001 From: Kevin Partington Date: Sat, 26 Dec 2020 18:35:59 -0600 Subject: [PATCH 5/6] Fixup: Remove unnecessary pytest urls mark call --- tests/test_views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_views.py b/tests/test_views.py index 94a18f82..8241a10e 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -9,7 +9,6 @@ related_model_field_name = "related_field_model" -@pytest.mark.urls("tests.urls") @pytest.mark.parametrize( "format_links", [ From 8a2656dd57688f903abf4b6527a7ab50af911d34 Mon Sep 17 00:00:00 2001 From: Kevin Partington Date: Sun, 27 Dec 2020 11:38:59 -0600 Subject: [PATCH 6/6] Fixup: Move urlconf fixture setup into test_relations --- tests/test_relations.py | 36 +++++++++++++++++++++++++++++++++++- tests/urls.py | 22 ---------------------- tests/views.py | 12 ------------ 3 files changed, 35 insertions(+), 35 deletions(-) delete mode 100644 tests/urls.py delete mode 100644 tests/views.py diff --git a/tests/test_relations.py b/tests/test_relations.py index 42f64b83..e9f3800b 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -1,11 +1,14 @@ 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("tests.urls") +@pytest.mark.urls(__name__) @pytest.mark.parametrize( "format_links,expected_url_segment", [ @@ -38,3 +41,34 @@ def test_relationship_urls_respect_format_links( 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/urls.py b/tests/urls.py deleted file mode 100644 index 3c6d9797..00000000 --- a/tests/urls.py +++ /dev/null @@ -1,22 +0,0 @@ -from django.conf.urls import re_path -from rest_framework.routers import SimpleRouter - -from .views import BasicModelRelationshipView, BasicModelViewSet - -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/views.py b/tests/views.py deleted file mode 100644 index af3ef4ec..00000000 --- a/tests/views.py +++ /dev/null @@ -1,12 +0,0 @@ -from rest_framework_json_api.views import ModelViewSet, RelationshipView - -from .models import BasicModel - - -class BasicModelViewSet(ModelViewSet): - class Meta: - model = BasicModel - - -class BasicModelRelationshipView(RelationshipView): - queryset = BasicModel.objects