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