Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add helper to disable viewset list detection #1064 #1065

Merged
merged 2 commits into from
Aug 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/faq.rst
Original file line number Diff line number Diff line change
Expand Up @@ -414,3 +414,28 @@ response is a binary blob without further details on its structure.
"Content-Disposition": "attachment; filename=out.bin",
},
)

My ``ViewSet`` ``list`` does not return a list, but a single object.
--------------------------------------------------------------------

Generally, it is bad practice to use a ``ViewSet.list`` method to return single object,
because DRF specifically does a list conversion in the background for this method and only
this method. Using ``ApiView`` or ``GenericAPIView`` for this use-case would be cleaner.

However, if you insist on this behavior, you can circumvent the list detection by
creating a one-off copy of your serializer and marking it as forced non-list.
It is important to create a **copy** as
:py:func:`@extend_schema_serializer <drf_spectacular.utils.extend_schema_serializer>`
modifies the given serializer.

.. code-block:: python

from drf_spectacular.helpers import forced_singular_serializer

class YourViewSet(viewsets.ModelViewSet):
serializer_class = SimpleSerializer
queryset = SimpleModel.objects.none()

@extend_schema(responses=forced_singular_serializer(SimpleSerializer))
def list(self):
pass
14 changes: 13 additions & 1 deletion drf_spectacular/helpers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from django.utils.module_loading import import_string


def lazy_serializer(path):
def lazy_serializer(path: str):
""" simulate initiated object but actually load class and init on first usage """

class LazySerializer:
Expand All @@ -28,3 +28,15 @@ def __repr__(self):
return self.__getattr__('__repr__')()

return LazySerializer


def forced_singular_serializer(serializer_class):
from drf_spectacular.drainage import set_override
from drf_spectacular.utils import extend_schema_serializer

patched_serializer_class = type(serializer_class.__name__, (serializer_class,), {})

extend_schema_serializer(many=False)(patched_serializer_class)
set_override(patched_serializer_class, 'suppress_collision_warning', True)

return patched_serializer_class
8 changes: 6 additions & 2 deletions drf_spectacular/plumbing.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
from rest_framework.utils.serializer_helpers import ReturnDict, ReturnList
from uritemplate import URITemplate

from drf_spectacular.drainage import cache, error, warn
from drf_spectacular.drainage import cache, error, get_override, warn
from drf_spectacular.settings import spectacular_settings
from drf_spectacular.types import (
DJANGO_PATH_CONVERTER_MAPPING, OPENAPI_TYPE_MAPPING, PYTHON_TYPE_MAPPING, OpenApiTypes,
Expand Down Expand Up @@ -688,7 +688,11 @@ def __contains__(self, component):
query_class = query_obj if inspect.isclass(query_obj) else query_obj.__class__
registry_class = query_obj if inspect.isclass(registry_obj) else registry_obj.__class__

if query_class != registry_class:
suppress_collision_warning = (
get_override(registry_class, 'suppress_collision_warning', False)
or get_override(query_class, 'suppress_collision_warning', False)
)
if query_class != registry_class and not suppress_collision_warning:
warn(
f'Encountered 2 components with identical names "{component.name}" and '
f'different classes {query_class} and {registry_class}. This will very '
Expand Down
31 changes: 30 additions & 1 deletion tests/test_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from rest_framework.views import APIView

from drf_spectacular.extensions import OpenApiSerializerExtension
from drf_spectacular.helpers import forced_singular_serializer
from drf_spectacular.hooks import preprocess_exclude_path_format
from drf_spectacular.openapi import AutoSchema
from drf_spectacular.renderers import OpenApiJsonRenderer, OpenApiYamlRenderer
Expand Down Expand Up @@ -3014,7 +3015,7 @@ def test_slug_related_field_to_model_property(no_warnings):
class M10(models.Model):
@property
def property_field(self) -> float:
return 42
return 42 # pragma: no cover

class M11(models.Model):
field = models.ForeignKey(M10, on_delete=models.CASCADE)
Expand Down Expand Up @@ -3183,3 +3184,31 @@ class X3ViewSet(X2ViewSet):
assert '/x1/' not in schema['paths']
assert '/x2/' in schema['paths']
assert '/x3/' in schema['paths']


def test_disable_viewset_list_handling_as_one_off(no_warnings):

class X1ViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = SimpleSerializer
queryset = SimpleModel.objects.none()

@extend_schema(responses=forced_singular_serializer(SimpleSerializer))
def list(self):
pass # pragma: no cover

class X2ViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = SimpleSerializer
queryset = SimpleModel.objects.none()

schema1 = generate_schema('/x', X1ViewSet)
schema2 = generate_schema('/x', X2ViewSet)

# both list and retrieve are single-object
schema_list = get_response_schema(schema1['paths']['/x/']['get'])
schema_retrieve = get_response_schema(schema1['paths']['/x/{id}/']['get'])
assert schema_list == schema_retrieve == {'$ref': '#/components/schemas/Simple'}
# this patch does not bleed into other usages of the same serializer class
assert get_response_schema(schema2['paths']['/x/']['get']) == {
'type': 'array',
'items': {'$ref': '#/components/schemas/Simple'}
}