From 1e8b172a77f7bafb739cc504dac17d1ec2b31bf8 Mon Sep 17 00:00:00 2001 From: Pedro Borges Date: Thu, 21 Sep 2023 13:30:54 +0200 Subject: [PATCH 01/10] Fix (#1079) crash when generating schema for field with UUID choices. The crash was fixed by replacing the JSON encoder used to hash elements for enum deduplication with DRF JSON encoder which is capable of handling type outside of the usual JSON standard types. --- drf_spectacular/plumbing.py | 3 ++- tests/test_postprocessing.py | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/drf_spectacular/plumbing.py b/drf_spectacular/plumbing.py index 1b14d380..85b61e96 100644 --- a/drf_spectacular/plumbing.py +++ b/drf_spectacular/plumbing.py @@ -37,6 +37,7 @@ from rest_framework.fields import empty from rest_framework.settings import api_settings from rest_framework.test import APIRequestFactory +from rest_framework.utils.encoders import JSONEncoder from rest_framework.utils.mediatypes import _MediaType from rest_framework.utils.serializer_helpers import ReturnDict, ReturnList from uritemplate import URITemplate @@ -839,7 +840,7 @@ def load_enum_name_overrides(): def list_hash(lst): - return hashlib.sha256(json.dumps(list(lst), sort_keys=True).encode()).hexdigest() + return hashlib.sha256(json.dumps(list(lst), sort_keys=True, cls=JSONEncoder).encode()).hexdigest() def anchor_pattern(pattern: str) -> str: diff --git a/tests/test_postprocessing.py b/tests/test_postprocessing.py index 3ef0c3fc..3fcf61e4 100644 --- a/tests/test_postprocessing.py +++ b/tests/test_postprocessing.py @@ -314,3 +314,29 @@ def get(self, request): 'items': {'$ref': '#/components/schemas/QualityLevelsEnum'}, 'readOnly': True } + + +def test_uuid_choices(no_warnings): + + import uuid + + class XSerializer(serializers.Serializer): + foo = serializers.ChoiceField( + choices=[ + (uuid.UUID('93d7527f-de3c-4a76-9cc2-5578675630d4'), 'baz'), + (uuid.UUID('47a4b873-409e-4e43-81d5-fafc3faeb849'), 'bar') + ] + ) + + class XAPIView(APIView): + @extend_schema(responses=XSerializer) + def get(self, request): + pass # pragma: no cover + + schema = generate_schema('x', view=XAPIView) + + assert 'FooEnum' in schema['components']['schemas'] + assert schema['components']['schemas']['FooEnum']['enum'] == [ + uuid.UUID('93d7527f-de3c-4a76-9cc2-5578675630d4'), + uuid.UUID('47a4b873-409e-4e43-81d5-fafc3faeb849') + ] From ae68f5c6652edbd7f491634c2cfe9f6a2898ce71 Mon Sep 17 00:00:00 2001 From: "T. Franzel" Date: Sat, 23 Sep 2023 01:14:26 +0200 Subject: [PATCH 02/10] update FAQ entry on extension loading --- docs/faq.rst | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/faq.rst b/docs/faq.rst index 4e4513d3..7973e77b 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -247,9 +247,27 @@ call is proxied through the entrypoint. Where should I put my extensions? / my extensions are not detected ------------------------------------------------------------------ -The extensions register themselves automatically. Just be sure that the python interpreter sees them at least once. -To that end, we suggest creating a ``PROJECT/schema.py`` file and importing it in your ``PROJECT/__init__.py`` -(same directory as ``settings.py`` and ``urls.py``) with ``import PROJECT.schema``. +The extensions register themselves automatically. Just be sure that the Python interpreter sees them at least once. +It is good practice to collect your extensions in ``YOUR_MAIN_APP_NAME/schema.py`` and to import that +file in your ``YOUR_MAIN_APP_NAME/apps.py``. Performing the import in the ``ready()`` method is the most robust +approach. It will make sure your environment (e.g. settings) is properly set up prior to loading. + + .. code-block:: python + + # your_main_app_name/apps.py + class YourMainAppNameConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "your_main_app_name" + + def ready(self): + import your_main_app_name.schema # noqa: E402 + + + +While there are certainly other ways of loading your extensions, this is a battle-proven and robust way to do it. +Generally in Django/DRF, importing stuff in the wrong order often results in weird errors or circular +import issues, which this approach tries to carefully circumvent. + My ``@action`` is erroneously paginated or has filter parameters that I do not want ----------------------------------------------------------------------------------- From cbfc84f26face4d2fa446d39aaf2f9c62b7bfb7e Mon Sep 17 00:00:00 2001 From: "T. Franzel" Date: Sat, 23 Sep 2023 01:41:45 +0200 Subject: [PATCH 03/10] Revert "remove versioning from HTML (UI) template views #1036" Even though this change makes sense in principle, it may be a regression and having the HTML views have versioning classes does make sense in certain situations. Revert the change as this is not a clear improvement on closer inspection. This reverts commit 388da8dedb3d3ec9d687146934bc6e41ef83dcf5. --- drf_spectacular/views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/drf_spectacular/views.py b/drf_spectacular/views.py index 48785644..95f0f975 100644 --- a/drf_spectacular/views.py +++ b/drf_spectacular/views.py @@ -122,7 +122,6 @@ class SpectacularSwaggerView(APIView): renderer_classes = [TemplateHTMLRenderer] permission_classes = spectacular_settings.SERVE_PERMISSIONS authentication_classes = AUTHENTICATION_CLASSES - versioning_class = None url_name = 'schema' url = None template_name = 'drf_spectacular/swagger_ui.html' @@ -232,7 +231,6 @@ class SpectacularRedocView(APIView): renderer_classes = [TemplateHTMLRenderer] permission_classes = spectacular_settings.SERVE_PERMISSIONS authentication_classes = AUTHENTICATION_CLASSES - versioning_class = None url_name = 'schema' url = None template_name = 'drf_spectacular/redoc.html' From 6e4180b7175e4fd07d09f1c9820c0aa40553f001 Mon Sep 17 00:00:00 2001 From: "T. Franzel" Date: Sat, 23 Sep 2023 02:04:04 +0200 Subject: [PATCH 04/10] version bump --- CHANGELOG.rst | 18 ++++++++++++++++++ drf_spectacular/__init__.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0a609632..90509da4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,24 @@ Changelog ========= +0.26.5 (2023-09-23) +------------------- + +- update FAQ entry on extension loading +- Fix (`#1079 `_) crash when generating schema for field with UUID choices. [Pedro Borges] +- chore: fix typos [Heinz-Alexander Fuetterer] +- Use schema_url in SpectacularElementsView (`#1067 `_) [q0w] +- add helper to disable viewset list detection `#1064 `_ +- pin django-allauth test dep due to breaking change with dj-rest-auth +- fix example building for pagination with basic list `#1055 `_ +- Fix discarded falsy examples values `#1049 `_ + +Breaking changes / important additions: + +- Added helper function ``forced_singular_serializer`` to disable a list detection on a endpoint, that has been quite difficult to properly + undo previously. This closes the functional gap for ``@extend_schema_serializer(many=False)`` in single-use (non-envelope) situations. +- Several small bugfixes + 0.26.4 (2023-07-23) ------------------- diff --git a/drf_spectacular/__init__.py b/drf_spectacular/__init__.py index a221e3e8..5e155f07 100644 --- a/drf_spectacular/__init__.py +++ b/drf_spectacular/__init__.py @@ -1,6 +1,6 @@ import django -__version__ = '0.26.4' +__version__ = '0.26.5' if django.VERSION < (3, 2): default_app_config = 'drf_spectacular.apps.SpectacularConfig' From f7f75a9671aa12a97f19005a5b81f80c3947ee26 Mon Sep 17 00:00:00 2001 From: "T. Franzel" Date: Wed, 18 Oct 2023 01:45:56 +0200 Subject: [PATCH 05/10] add test for django-filter and ListAPIView #1086 --- tests/contrib/test_django_filters.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/contrib/test_django_filters.py b/tests/contrib/test_django_filters.py index dc173cd2..2c30e804 100644 --- a/tests/contrib/test_django_filters.py +++ b/tests/contrib/test_django_filters.py @@ -5,7 +5,7 @@ from django.db import models from django.db.models import F from django.urls import include, path -from rest_framework import routers, serializers, viewsets +from rest_framework import generics, routers, serializers, viewsets from rest_framework.test import APIClient from drf_spectacular.types import OpenApiTypes @@ -419,3 +419,20 @@ class XViewSet(viewsets.ModelViewSet): assert schema['paths']['/x/']['get']['parameters'][0]['description'] == ( '* `one` - One\n* `two` - Two\n* `three` - Three' ) + + +@pytest.mark.contrib('django_filter') +def test_filter_on_listapiview(no_warnings): + class XListView(generics.ListAPIView): + queryset = Product.objects.all() + serializer_class = ProductSerializer + filter_backends = (DjangoFilterBackend,) + filterset_class = ProductFilter + + def get_queryset(self): + return Product.objects.all().annotate( + price_vat=F('price') * 1.19 + ) + + schema = generate_schema('/x/', view=XListView) + assert len(schema['paths']['/x/']['get']['parameters']) > 1 From 931b81b5096d43a2eb916194a2101c5f0545477c Mon Sep 17 00:00:00 2001 From: "T. Franzel" Date: Wed, 18 Oct 2023 02:09:46 +0200 Subject: [PATCH 06/10] fix broken readthedocs due to change --- .readthedocs.yaml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..68968885 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,22 @@ +# Read the Docs configuration file for Sphinx projects +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the OS, Python version and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +# Build documentation in the "docs/" directory with Sphinx +sphinx: + configuration: docs/conf.py + +# Optional but recommended, declare the Python requirements required +# to build your documentation +# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +python: + install: + - requirements: requirements/docs.txt \ No newline at end of file From c68d3b21242e073f0a05e661c7d3218ecd0f44a7 Mon Sep 17 00:00:00 2001 From: "T. Franzel" Date: Wed, 18 Oct 2023 02:32:18 +0200 Subject: [PATCH 07/10] fix broken readthedocs due to change 2 --- .readthedocs.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 68968885..1bc015f1 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -19,4 +19,5 @@ sphinx: # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html python: install: + - requirements: requirements/base.txt - requirements: requirements/docs.txt \ No newline at end of file From 6e48a704b9767fdcd72820c42fd8878b4f75cdd0 Mon Sep 17 00:00:00 2001 From: Marti Raudsepp Date: Sun, 22 Oct 2023 17:08:30 +0300 Subject: [PATCH 08/10] Avoid ChoiceField duplicate enum values for allow_null, allow_blank (#1085) * Avoid duplicate enum values for allow_null, allow_blank * imports/code style * Chop down line length * isort --- drf_spectacular/plumbing.py | 4 ++-- tests/test_plumbing.py | 23 +++++++++++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/drf_spectacular/plumbing.py b/drf_spectacular/plumbing.py index 85b61e96..95154759 100644 --- a/drf_spectacular/plumbing.py +++ b/drf_spectacular/plumbing.py @@ -410,9 +410,9 @@ def build_choice_field(field): else: type = None - if field.allow_blank: + if field.allow_blank and '' not in choices: choices.append('') - if field.allow_null: + if field.allow_null and None not in choices: choices.append(None) schema = { diff --git a/tests/test_plumbing.py b/tests/test_plumbing.py index d2ca7ef5..7a7020f5 100644 --- a/tests/test_plumbing.py +++ b/tests/test_plumbing.py @@ -20,8 +20,9 @@ from drf_spectacular.openapi import AutoSchema from drf_spectacular.plumbing import ( - analyze_named_regex_pattern, build_basic_type, detype_pattern, follow_field_source, - force_instance, get_list_serializer, is_field, is_serializer, resolve_type_hint, + analyze_named_regex_pattern, build_basic_type, build_choice_field, detype_pattern, + follow_field_source, force_instance, get_list_serializer, is_field, is_serializer, + resolve_type_hint, ) from drf_spectacular.validation import validate_schema from tests import generate_schema @@ -358,3 +359,21 @@ def test_analyze_named_regex_pattern(no_warnings, pattern, output): def test_unknown_basic_type(capsys): build_basic_type(object) assert 'could not resolve type for "' in capsys.readouterr().err + + +def test_choicefield_choices_enum(): + schema = build_choice_field(serializers.ChoiceField(['bluepill', 'redpill'])) + assert schema['enum'] == ['bluepill', 'redpill'] + assert schema['type'] == 'string' + + schema = build_choice_field(serializers.ChoiceField( + ['bluepill', 'redpill'], allow_null=True, allow_blank=True + )) + assert schema['enum'] == ['bluepill', 'redpill', '', None] + assert schema['type'] == 'string' + + schema = build_choice_field(serializers.ChoiceField( + choices=['bluepill', 'redpill', '', None], allow_null=True, allow_blank=True + )) + assert schema['enum'] == ['bluepill', 'redpill', '', None] + assert 'type' not in schema From 41fef0347b5acb97ad083bf4eb0e02b252267641 Mon Sep 17 00:00:00 2001 From: "T. Franzel" Date: Thu, 2 Nov 2023 11:56:47 +0100 Subject: [PATCH 09/10] add test for empty whitelist (no auth) #1094 --- tests/test_regressions.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 94440c6e..30dd7a33 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -2266,6 +2266,20 @@ class XViewset(viewsets.ReadOnlyModelViewSet): assert schema['paths']['/x/']['get']['security'] == [{'tokenAuth': []}, {}] +@mock.patch( + 'drf_spectacular.settings.spectacular_settings.AUTHENTICATION_WHITELIST', [] +) +def test_authentication_empty_whitelist(no_warnings): + class XViewset(viewsets.ReadOnlyModelViewSet): + serializer_class = SimpleSerializer + queryset = SimpleModel.objects.none() + authentication_classes = [BasicAuthentication, TokenAuthentication] + + schema = generate_schema('/x', XViewset) + assert 'securitySchemes' not in schema['components'] + assert schema['paths']['/x/']['get']['security'] == [{}] + + def test_request_response_raw_schema_annotation(no_warnings): @extend_schema( request={'application/pdf': {'type': 'string', 'format': 'binary'}}, From edce05317f7a8deec5a4c0940c70199e22be288a Mon Sep 17 00:00:00 2001 From: "T. Franzel" Date: Thu, 2 Nov 2023 20:10:45 +0100 Subject: [PATCH 10/10] JSONField may also be a non-object/primitive #1095 --- drf_spectacular/openapi.py | 4 ++-- tests/test_extend_schema.yml | 4 +--- tests/test_fields.yml | 4 +--- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/drf_spectacular/openapi.py b/drf_spectacular/openapi.py index 69e2c61f..04c78b7c 100644 --- a/drf_spectacular/openapi.py +++ b/drf_spectacular/openapi.py @@ -583,7 +583,7 @@ def _map_model_field(self, model_field, direction): return self._map_model_field(model_field.target_field, direction) elif hasattr(models, 'JSONField') and isinstance(model_field, models.JSONField): # fix for DRF==3.11 with django>=3.1 as it is not yet represented in the field_mapping - return build_basic_type(OpenApiTypes.OBJECT) + return build_basic_type(OpenApiTypes.ANY) elif isinstance(model_field, models.BinaryField): return build_basic_type(OpenApiTypes.BYTE) elif hasattr(models, model_field.get_internal_type()): @@ -847,7 +847,7 @@ def _map_serializer_field(self, field, direction, bypass_extensions=False): return append_meta(build_basic_type(OpenApiTypes.BOOL), meta) if isinstance(field, serializers.JSONField): - return append_meta(build_basic_type(OpenApiTypes.OBJECT), meta) + return append_meta(build_basic_type(OpenApiTypes.ANY), meta) if isinstance(field, (serializers.DictField, serializers.HStoreField)): content = build_basic_type(OpenApiTypes.OBJECT) diff --git a/tests/test_extend_schema.yml b/tests/test_extend_schema.yml index 27339828..673c4eae 100644 --- a/tests/test_extend_schema.yml +++ b/tests/test_extend_schema.yml @@ -320,9 +320,7 @@ components: type: string field_b: type: integer - field_c: - type: object - additionalProperties: {} + field_c: {} required: - field_a - field_b diff --git a/tests/test_fields.yml b/tests/test_fields.yml index 8cb08fee..b058b13e 100644 --- a/tests/test_fields.yml +++ b/tests/test_fields.yml @@ -190,9 +190,7 @@ components: type: object additionalProperties: type: integer - field_json: - type: object - additionalProperties: {} + field_json: {} field_sub_object_calculated: type: integer description: My calculated property