From 4776e2bb8a60e92ff4e6fbedc858e1b134b1f61f Mon Sep 17 00:00:00 2001 From: Kevin Partington Date: Thu, 24 Dec 2020 11:47:19 -0600 Subject: [PATCH] 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