Skip to content

Commit

Permalink
Merge branch 'master' into pr901
Browse files Browse the repository at this point in the history
  • Loading branch information
tfranzel committed Mar 4, 2023
2 parents f6929d0 + 4c23bde commit aaae20a
Show file tree
Hide file tree
Showing 28 changed files with 517 additions and 61 deletions.
5 changes: 4 additions & 1 deletion docs/customization.rst
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ Step 6: Postprocessing hooks

The generated schema is still not to your liking? You are no easy customer, but there is one
more thing you can do. Postprocessing hooks run at the very end of schema generation. This is how
the choice ``Enum`` are consolidated into component objects. You can register additional hooks with the
the choice ``Enum`` are consolidated into component objects. You can register hooks with the
``POSTPROCESSING_HOOKS`` setting.

.. code-block:: python
Expand All @@ -267,6 +267,9 @@ the choice ``Enum`` are consolidated into component objects. You can register ad
# your modifications to the schema in parameter result
return result
.. note:: Please note that setting ``POSTPROCESSING_HOOKS`` will override the default. If you intend to
keep the ``Enum`` hook, be sure to add ``'drf_spectacular.hooks.postprocess_schema_enums'`` back into the list.

Step 7: Preprocessing hooks
---------------------------

Expand Down
16 changes: 8 additions & 8 deletions docs/faq.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Sometimes DRF libraries do not cooperate well with the introspection mechanics.
Check the :ref:`blueprints` for already available fixes. If there aren't any,
learn how to do easy :ref:`customization`. Feel free to contribute back missing fixes.

If you think this is a bug in *drf-spectacular*, open a
If you think this is a bug in *drf-spectacular*, open an
`issue <https://github.com/tfranzel/drf-spectacular/issues>`_.

My Swagger UI and/or Redoc page is blank
Expand Down Expand Up @@ -73,7 +73,7 @@ modify otherwise. Have a look at :ref:`customization` on how to use *Extensions*
I get an empty schema or endpoints are missing
----------------------------------------------

This is usually due versioning (or more rarely due to permissions).
This is usually due to versioning (or more rarely due to permissions).

In case you use versioning on all endpoints, that might be the intended output.
By default the schema will only contain unversioned endpoints. Explicitly specify
Expand All @@ -94,14 +94,14 @@ I expected a different schema
Sometimes views declare one thing (via ``serializer_class`` and ``queryset``) and do
a entirely different thing. Usually this is attributed to making a library code flexible
under varying situations. In those cases it is best to override what the introspection
decuded and state explicitly what is to be expected.
decided and state explicitly what is to be expected.
Work through the steps in :ref:`customization` to adapt your schema.

I get duplicated operations with a ``{format}``-suffix
------------------------------------------------------

Your app likely uses DRF's ``format_suffix_patterns``. If those operations are
undesireable in your schema, you can simply exclude them with an already provided
undesirable in your schema, you can simply exclude them with an already provided
:ref:`preprocessing hook <customization_preprocessing_hooks>`.

I get a lot of warnings
Expand Down Expand Up @@ -257,7 +257,7 @@ My ``@action`` is erroneously paginated or has filter parameters that I do not w
This usually happens when ``@extend_schema(responses=XSerializer(many=True))`` is used. Actions inherit filter
and pagination classes from their ``ViewSet``. If the response is then marked as a list, the ``pagination_class``
kicks in. Since actions are handled manually by the user, this behavior is usually not immediately obvious.
To make make your intentions clear to *drf-spectacular*, you need to clear the offening classes in the action
To make your intentions clear to *drf-spectacular*, you need to clear the offending classes in the action
decorator, e.g. setting ``pagination_class=None``.

Users of *django-filter* might also see unwanted query parameters. Since the same mechanics apply here too,
Expand All @@ -274,10 +274,10 @@ you can remove those parameters by resetting the filter backends with ``@action(
def custom_action(self):
pass
How to I wrap my responses? / My endpoints are wrapped in a generic envelope
How do I wrap my responses? / My endpoints are wrapped in a generic envelope
----------------------------------------------------------------------------

This non-native behavior can be conventiently modeled with a simple helper function. You simply need
This non-native behavior can be conveniently modeled with a simple helper function. You simply need
to wrap the actual serializer with your envelope serializer and provide it to ``@extend_schema``.

Here is an example on how to build an ``enveloper`` helper function. In this example, the actual
Expand Down Expand Up @@ -387,7 +387,7 @@ DRF provides a convenient ``FileField`` for storing files persistently within a
`recommended by Django <https://docs.djangoproject.com/en/4.0/ref/request-response/#telling-the-browser-to-treat-the-response-as-a-file-attachment>`_
for treating a ``Response`` as a file and sets up an appropriate ``Renderer`` that will handle the
client ``Accept`` header for this response content type. ``responses=bytes`` expresses that the
response is a binary blob without further details on it's structure.
response is a binary blob without further details on its structure.
.. code-block:: python
Expand Down
2 changes: 1 addition & 1 deletion docs/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Example: SwaggerUI settings
---------------------------

We currently support passing through all basic SwaggerUI `configuration parameters <https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/>`_.
For more customization options (e.g. JS functions), you can modify and override the
For more customization options (e.g. CSS, JS functions), you can extend or override the
`SwaggerUI template <https://github.com/tfranzel/drf-spectacular/blob/master/drf_spectacular/templates/drf_spectacular/swagger_ui.html>`_
in your project files.

Expand Down
45 changes: 36 additions & 9 deletions drf_spectacular/contrib/django_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
from drf_spectacular.drainage import add_trace_message, get_override, has_override, warn
from drf_spectacular.extensions import OpenApiFilterExtension
from drf_spectacular.plumbing import (
build_array_type, build_basic_type, build_parameter_type, follow_field_source, get_manager,
get_type_hints, get_view_model, is_basic_type,
build_array_type, build_basic_type, build_choice_description_list, build_parameter_type,
follow_field_source, get_manager, get_type_hints, get_view_model, is_basic_type,
)
from drf_spectacular.settings import spectacular_settings
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter

Expand Down Expand Up @@ -96,7 +97,7 @@ def resolve_filter_field(self, auto_schema, model, filterset_class, field_name,
schema = build_basic_type(annotation)
else:
# allow injecting raw schema via @extend_schema_field decorator
schema = annotation
schema = annotation.copy()
elif filter_method_hint is not _NoHint:
if is_basic_type(filter_method_hint):
schema = build_basic_type(filter_method_hint)
Expand Down Expand Up @@ -152,14 +153,10 @@ def resolve_filter_field(self, auto_schema, model, filterset_class, field_name,
# explicit filter choices may disable enum retrieved from model
if not schema_from_override and filter_choices is not None:
enum = filter_choices
if enum:
schema['enum'] = sorted(enum, key=str)

description = schema.pop('description', None)
if filter_field.extra.get('help_text', None):
description = filter_field.extra['help_text']
elif filter_field.label is not None:
description = filter_field.label
if not schema_from_override:
description = self._get_field_description(filter_field, description)

# parameter style variations based on filter base class
if isinstance(filter_field, filters.BaseCSVFilter):
Expand Down Expand Up @@ -194,6 +191,7 @@ def resolve_filter_field(self, auto_schema, model, filterset_class, field_name,
location=OpenApiParameter.QUERY,
description=description,
schema=schema,
enum=enum,
explode=explode,
style=style
)
Expand Down Expand Up @@ -249,6 +247,35 @@ def _get_schema_from_model_field(self, auto_schema, filter_field, model):
model_field = qs.query.annotations[filter_field.field_name].field
return auto_schema._map_model_field(model_field, direction=None)

def _get_field_description(self, filter_field, description):
# Try to improve description beyond auto-generated model description
if filter_field.extra.get('help_text', None):
description = filter_field.extra['help_text']
elif filter_field.label is not None:
description = filter_field.label

choices = filter_field.extra.get('choices')
if choices and callable(choices):
# remove auto-generated enum list, since choices come from a callable
if '\n\n*' in (description or ''):
description, _, _ = description.partition('\n\n*')
return description

choice_description = ''
if spectacular_settings.ENUM_GENERATE_CHOICE_DESCRIPTION and choices and not callable(choices):
choice_description = build_choice_description_list(choices)

if not choices:
return description

if description:
# replace or append model choice description
if '\n\n*' in description:
description, _, _ = description.partition('\n\n*')
return description + '\n\n' + choice_description
else:
return choice_description

@classmethod
def _is_gis(cls, field):
if not getattr(cls, '_has_gis', True):
Expand Down
38 changes: 31 additions & 7 deletions drf_spectacular/contrib/rest_auth.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from django.conf import settings
from django.utils.version import get_version_tuple
from rest_framework import serializers

from drf_spectacular.contrib.rest_framework_simplejwt import (
Expand All @@ -9,13 +10,33 @@
from drf_spectacular.utils import extend_schema


def get_dj_rest_auth_setting(class_name, setting_name):
from dj_rest_auth.__version__ import __version__

if get_version_tuple(__version__) < (3, 0, 0):
from dj_rest_auth import app_settings

return getattr(app_settings, class_name)
else:
from dj_rest_auth.app_settings import api_settings

return getattr(api_settings, setting_name)


def get_token_serializer_class():
from dj_rest_auth.app_settings import JWTSerializer, TokenSerializer
from dj_rest_auth.__version__ import __version__

if getattr(settings, 'REST_USE_JWT', False):
return JWTSerializer
if get_version_tuple(__version__) < (3, 0, 0):
use_jwt = getattr(settings, 'REST_USE_JWT', False)
else:
return TokenSerializer
from dj_rest_auth.app_settings import api_settings

use_jwt = api_settings.USE_JWT

if use_jwt:
return get_dj_rest_auth_setting('JWTSerializer', 'JWT_SERIALIZER')
else:
return get_dj_rest_auth_setting('TokenSerializer', 'TOKEN_SERIALIZER')


class RestAuthDetailSerializer(serializers.Serializer):
Expand Down Expand Up @@ -79,33 +100,35 @@ class RestAuthPasswordResetConfirmView(RestAuthDefaultResponseView):

class RestAuthVerifyEmailView(RestAuthDefaultResponseView):
target_class = 'dj_rest_auth.registration.views.VerifyEmailView'
optional = True


class RestAuthResendEmailVerificationView(RestAuthDefaultResponseView):
target_class = 'dj_rest_auth.registration.views.ResendEmailVerificationView'
optional = True


class RestAuthJWTSerializer(OpenApiSerializerExtension):
target_class = 'dj_rest_auth.serializers.JWTSerializer'

def map_serializer(self, auto_schema, direction):
from dj_rest_auth.app_settings import UserDetailsSerializer

class Fixed(self.target_class):
user = UserDetailsSerializer()
user = get_dj_rest_auth_setting('UserDetailsSerializer', 'USER_DETAILS_SERIALIZER')()

return auto_schema._map_serializer(Fixed, direction)


class CookieTokenRefreshSerializerExtension(TokenRefreshSerializerExtension):
target_class = 'dj_rest_auth.jwt_auth.CookieTokenRefreshSerializer'
optional = True

def get_name(self):
return 'TokenRefresh'


class RestAuthRegisterView(OpenApiViewExtension):
target_class = 'dj_rest_auth.registration.views.RegisterView'
optional = True

def view_replacement(self):
from allauth.account.app_settings import EMAIL_VERIFICATION, EmailVerificationMethod
Expand All @@ -125,6 +148,7 @@ def post(self, request, *args, **kwargs):

class SimpleJWTCookieScheme(SimpleJWTScheme):
target_class = 'dj_rest_auth.jwt_auth.JWTCookieAuthentication'
optional = True
name = ['jwtHeaderAuth', 'jwtCookieAuth'] # type: ignore

def get_security_requirement(self, auto_schema):
Expand Down
8 changes: 8 additions & 0 deletions drf_spectacular/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,14 @@ def create_enum_component(name, schema):
enum_schema = {k: v for k, v in prop_schema.items() if k in ['type', 'enum']}
prop_schema = {k: v for k, v in prop_schema.items() if k not in ['type', 'enum']}

# separate actual description from name-value tuples
if spectacular_settings.ENUM_GENERATE_CHOICE_DESCRIPTION:
if prop_schema.get('description', '').startswith('*'):
enum_schema['description'] = prop_schema.pop('description')
elif '\n\n*' in prop_schema.get('description', ''):
_, _, post = prop_schema['description'].partition('\n\n*')
enum_schema['description'] = '*' + post

components = [
create_enum_component(enum_name, schema=enum_schema)
]
Expand Down
36 changes: 30 additions & 6 deletions drf_spectacular/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -697,9 +697,16 @@ def _map_serializer_field(self, field, direction, bypass_extensions=False):
# be graceful and default to string.
model_field = follow_field_source(model, source, default=models.TextField())

# Special case: SlugRelatedField also allows to point to a callable @property.
if callable(model_field):
schema = self._map_response_type_hint(model_field)
elif isinstance(model_field, models.Field):
schema = self._map_model_field(model_field, direction)
else:
assert False, f'Field "{field.field_name}" must point to either a property or a model field.'

# primary keys are usually non-editable (readOnly=True) and map_model_field correctly
# signals that attribute. however this does not apply in the context of relations.
schema = self._map_model_field(model_field, direction)
schema.pop('readOnly', None)
return append_meta(schema, meta)

Expand All @@ -716,7 +723,10 @@ def _map_serializer_field(self, field, direction, bypass_extensions=False):
return append_meta(build_array_type(build_choice_field(field)), meta)

if isinstance(field, serializers.ChoiceField):
return append_meta(build_choice_field(field), meta)
schema = build_choice_field(field)
if 'description' in meta:
meta['description'] = meta['description'] + '\n\n' + schema.pop('description')
return append_meta(schema, meta)

if isinstance(field, serializers.ListField):
if isinstance(field.child, _UnvalidatedField):
Expand Down Expand Up @@ -961,7 +971,11 @@ def _get_serializer_field_meta(self, field, direction):
# a model instance or object (which we don't have) instead of a plain value.
default = field.default
else:
default = field.to_representation(field.default)
try:
# gracefully attempt to transform value or just use as plain on error
default = field.to_representation(field.default)
except: # noqa: E722
default = field.default
if isinstance(default, set):
default = list(default)
meta['default'] = default
Expand Down Expand Up @@ -1109,7 +1123,7 @@ def map_renderers(self, attribute):

# Either use whitelist or default back to old behavior by excluding BrowsableAPIRenderer
def use_renderer(r):
if spectacular_settings.RENDERER_WHITELIST:
if spectacular_settings.RENDERER_WHITELIST is not None:
return whitelisted(r, spectacular_settings.RENDERER_WHITELIST)
else:
return not isinstance(r, renderers.BrowsableAPIRenderer)
Expand Down Expand Up @@ -1181,9 +1195,19 @@ def _get_examples(self, serializer, direction, media_type, status_code=None, ext
continue
if direction == 'response' and example.request_only:
continue
if media_type and media_type != example.media_type:
# default to 'application/json' unless nested in OpenApiResponse, in which case inherit
if not example.media_type:
example_media_type = media_type if (example in extras) else 'application/json'
else:
example_media_type = example.media_type
if media_type and media_type != example_media_type:
continue
if status_code and status_code not in example.status_codes:
# default to [200, 201] unless nested in OpenApiResponse, in which case inherit
if not example.status_codes:
example_status_codes = (status_code,) if (example in extras) else ('200', '201')
else:
example_status_codes = tuple(map(str, example.status_codes))
if status_code and status_code not in example_status_codes:
continue

if (
Expand Down
Loading

0 comments on commit aaae20a

Please sign in to comment.