diff --git a/docs/faq.rst b/docs/faq.rst index de4dc296..4cb257ad 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -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 ` +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 diff --git a/drf_spectacular/helpers.py b/drf_spectacular/helpers.py index 5d3115ff..2d60ea01 100644 --- a/drf_spectacular/helpers.py +++ b/drf_spectacular/helpers.py @@ -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: @@ -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 diff --git a/drf_spectacular/plumbing.py b/drf_spectacular/plumbing.py index 1ec451f0..1b14d380 100644 --- a/drf_spectacular/plumbing.py +++ b/drf_spectacular/plumbing.py @@ -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, @@ -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 ' diff --git a/tests/test_regressions.py b/tests/test_regressions.py index c3f915e9..456f715e 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -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 @@ -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 + + 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'} + }